spec.auth: mode: sso in catalog-info.yaml — that's what injects BIO_CLIENT_ID and BIO_CLIENT_SECRET.auth: required → Janus verifies tokens for you. No SDK needed.verifyTokenJWKS(token). No secret needed.BioAuth.fromEnv().getClientCredentialsToken(scopes).JWT_SECRET — that was HS256. Bio-ID 0.3.0+ issues RS256 tokens.Without this, BIO_CLIENT_ID and BIO_CLIENT_SECRET are never injected. Your service cannot log users in.
spec:
auth:
mode: sso # REQUIRED for user login
If you deploy without spec.auth: mode: sso, the build log will say:
OAuth skipped (spec.auth not configured)
and all calls to BioAuth.fromEnv() will throw BIO_CLIENT_ID is required.
Common mistake: Adding
bio-idtointernalDependenciesdoes NOT enable OAuth.BIO_ID_URLis always auto-injected as a core platform variable — no declaration needed.
Bio-ID issues RS256-signed JWTs. The private key lives only in Bio-ID. Consuming services verify using Bio-ID's public JWKS endpoint — no shared secret.
Developer → OAuth flow → Bio-ID → RS256 JWT
JWT → request header → Janus (verifies via JWKS) → your service
If your service registers routes in catalog-info.yaml with auth: required, Janus verifies the token before proxying. Your handler receives a verified request. Most Express/Hono/Fastify services never call verifyTokenJWKS() directly.
spec:
routes:
- path: /api/my-service/data
methods: [GET]
auth: required # Janus handles verification
Services with their own auth middleware — primarily Next.js — need to verify tokens themselves because requests arrive before Janus can proxy them.
import { verifyTokenJWKS } from '@insureco/bio'
// In middleware or route handler:
const token = req.headers.authorization?.slice(7)
if (!token) return res.status(401).json({ error: 'Unauthorized' })
const payload = await verifyTokenJWKS(token)
// payload.bioId, payload.orgSlug, payload.roles, payload.email
BIO_ID_URL is auto-injected by the builder on every deploy. verifyTokenJWKS() reads it automatically.
import { BioAuth } from '@insureco/bio'
// All env vars auto-injected: BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL
const bio = BioAuth.fromEnv()
// 1. Build authorization URL
const { url, state, codeVerifier } = bio.getAuthorizationUrl({
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
})
// Store state + codeVerifier in httpOnly cookies, redirect user to url
// 2. Handle callback — MUST be at /api/auth/callback (builder registers this exact path)
const tokens = await bio.exchangeCode(code, codeVerifier, redirectUri)
// Store tokens.access_token and tokens.refresh_token
// 3. Refresh when expired
const newTokens = await bio.refreshToken(refreshToken)
Critical: Your callback MUST be at /api/auth/callback. The builder registers this exact path. Any other path fails with "Invalid Redirect URI".
On every deploy the builder syncs your OAuth client's redirect URIs. It auto-derives:
https://{service}.{env-prefix}{PLATFORM_DOMAIN}/api/auth/callbackThese derived URIs are merged with whatever is already registered on the client — the deploy only ever adds URIs, it never removes them. This means redirect URIs you add manually (e.g. a http://localhost:PORT/api/auth/callback dev callback added with tawa oauth add-uri) survive across deploys instead of being clobbered.
# Add a local dev callback once — it now persists across deploys
tawa oauth add-uri {service}-{env} http://localhost:9010/api/auth/callback
Tradeoff: because deploys never strip URIs, removing a verified custom domain does not auto-remove its callback URI from the client. Remove a stale URI explicitly with tawa oauth remove-uri {client-id} {uri}.
On first deploy the builder creates the OAuth client in Bio-ID, receives the secret, and stores it (encrypted) on the service record. Subsequent deploys re-inject that stored secret. Do not set BIO_CLIENT_SECRET with tawa config set — a manual override drifts from Bio-ID's record and breaks login with invalid_client. If you have a stale manual override, remove it (tawa config unset BIO_CLIENT_SECRET) and redeploy so the builder's value flows through.
If the OAuth client is deleted from Bio-ID (or its secret is regenerated out-of-band, e.g. via the Bio-ID admin UI or tawa oauth regenerate-secret), the builder's stored secret becomes orphaned. The builder detects this on the next deploy: when the redirect-URI sync finds the client missing in Bio-ID, it recreates the client and overwrites the stored credential with the freshly issued secret — login recovers automatically with no manual intervention.
tawa oauth delete {client-id} also clears the builder's stored credential for that client, so the next deploy provisions a clean client rather than re-injecting the deleted one.
The one case the builder cannot auto-detect is an out-of-band secret rotation where the client still exists (the redirect-URI sync succeeds, so there's no missing-client signal). If login fails with
invalid_clientbut the client is present, force a clean reprovision withtawa oauth delete {client-id}followed bytawa deploy— the delete clears the stored credential, and the deploy recreates the client and stores the matching secret.
Always pass request to your auth helper. Without it, Next.js falls back to cookies() which is unreliable in some App Router contexts:
// ✅ CORRECT — pass request explicitly
export async function GET(request: Request) {
const user = await getAuthUser(request) // reads Authorization header first, then cookies
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
// ...
}
// ❌ WRONG — no request passed, relies on cookies() fallback
export async function GET() {
const user = await getAuthUser() // may fail in certain App Router contexts
}
Only auto-redirect to login from your main dashboard or landing page. Sub-pages should show an error or "Log in" button instead:
// ✅ dashboard/page.tsx — auto-redirect is fine here
'use client'
export default function Dashboard() {
const { user, loading } = useAuth()
// Loop guard: if we just came back from OAuth and still have no user, something is broken
const searchParams = useSearchParams()
const authFresh = searchParams.get('auth') === 'fresh'
if (!loading && !user) {
if (authFresh) {
// Already tried OAuth — show error instead of redirecting again
return <div>Login failed. Please try again.</div>
}
window.location.href = '/api/auth/login'
return null
}
// ...
}
// ✅ leads/[id]/page.tsx — show inline error on auth failure, don't redirect
export default function LeadDetail() {
const { user, loading } = useAuth()
if (!loading && !user) {
return (
<div>
<p>You need to log in to view this page.</p>
<a href="/api/auth/login">Log in</a>
</div>
)
}
// ...
}
After OAuth callback, redirect to /?auth=fresh (or /dashboard?auth=fresh) so the loop guard can detect a broken auth cycle.
On custom domains, httpOnly cookies may not persist reliably across requests in some Cloudflare + Next.js standalone configurations. A robust pattern is to capture the token in sessionStorage on first load and forward it via Authorization header:
// In your auth helper — check Authorization header first, then fall back to cookies
export async function getAuthUser(request: Request): Promise<User | null> {
let token: string | undefined
// 1. Check Authorization header (from client sessionStorage fallback)
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7)
}
// 2. Fall back to cookies
if (!token) {
token = request.cookies.get('bd-token')?.value
}
if (!token) return null
// IMPORTANT: always read refresh token from cookies regardless of above
const refreshToken = request.cookies.get('bd-refresh')?.value
try {
const payload = await verifyTokenJWKS(token)
return { ...payload, refreshToken }
} catch {
return null
}
}
Client-side — capture token on first load while cookies still work, then use Authorization header for all subsequent requests:
// On app init / layout — capture token while cookies work
useEffect(() => {
if (sessionStorage.getItem('auth-token')) return // already have it
fetch('/api/auth/me', { credentials: 'include' })
.then(r => r.json())
.then(r => {
if (r.token) sessionStorage.setItem('auth-token', r.token)
})
.catch(() => {})
}, [])
// All API calls
async function apiFetch(path: string, options?: RequestInit) {
const token = sessionStorage.getItem('auth-token')
return fetch(path, {
...options,
credentials: 'include',
headers: {
...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
}
const bio = BioAuth.fromEnv()
const { access_token } = await bio.getClientCredentialsToken(['target-service:scope'])
await fetch(`${process.env.TARGET_URL}/api/endpoint`, {
headers: { Authorization: `Bearer ${access_token}` },
})
| Variable | When injected | Purpose |
|---|---|---|
BIO_CLIENT_ID | When spec.auth: mode: sso or service-only | Your service's OAuth client ID |
BIO_CLIENT_SECRET | When spec.auth: mode: sso or service-only | Your service's OAuth client secret |
BIO_ID_URL | Always — core platform variable | Bio-ID base URL. No declaration needed. |
// ❌ WRONG: HS256 with shared secret
import { verifyToken } from '@insureco/bio'
const payload = verifyToken(token, process.env.JWT_SECRET) // throws on RS256 tokens
// ❌ WRONG: JWT_SECRET env var — not used with RS256
JWT_SECRET=some-secret-value
// ❌ WRONG: BIO_ID_BASE_URL — renamed to BIO_ID_URL
BIO_ID_BASE_URL=https://bio.tawa.insureco.io
// ❌ WRONG: internalDependencies: bio-id to enable OAuth — only injects BIO_ID_URL (already injected)
internalDependencies:
- service: bio-id # does NOT give you BIO_CLIENT_ID or BIO_CLIENT_SECRET
// ✅ CORRECT: spec.auth to enable OAuth
spec:
auth:
mode: sso
// ✅ CORRECT: verifyTokenJWKS with no secret
import { verifyTokenJWKS } from '@insureco/bio'
const payload = await verifyTokenJWKS(token)
Last updated: July 15, 2026