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}.
The same applies to the client's allowed scopes and grant types. The builder derives
openid, profile, email plus any scopes your spec.dependencies declare, and merges that with
whatever the client already holds in Bio-ID. A deploy only ever adds — so a scope an admin
granted out of band survives every subsequent deploy:
tawa oauth update {service}-{env} --scopes "openid,profile,email,personas:mint"
tawa deploy --prod # personas:mint is still there afterwards
When a deploy preserves something the catalog does not derive, it says so in the build log (names only):
OAuth scopes preserved from Bio-ID (not derived from the catalog): personas:mint
Same tradeoff as URIs: removal is an explicit admin action. Dropping a dependency from
catalog-info.yaml does not revoke its scope — remove it with tawa oauth update {client-id} --scopes "…" naming the list you want.
If Bio-ID cannot be read during the sync, the deploy sends neither list rather than risk narrowing the client on a transient error, and warns in the build log. Fail-open, like the rest of the pipeline.
Fixed 2026-09-20 (iec-builder#159). Before this, the deploy sent its derived scope list verbatim, and Bio-ID's PATCH replaces the whole list — so every deploy of every service silently revoked any scope an admin had granted its client.
iec-test-prodlostpersonas:mintminutes after it was granted, and itsclient_credentialsrequest started failing withinvalid_scope. If you granted a scope before that date and it vanished, grant it again; it will stick now.
Catalog-declared fields are not merged — spec.auth.passport (includeBranding,
includeIecHash, headless, allowedEmbedOrigins) is written from the catalog on every deploy,
because the catalog is their source of truth.
tawa oauth …, /oauth/clients)The builder's /oauth/* routes proxy to Bio-ID's admin API with the builder's own key, so
the builder decides who may touch which client (iec-builder#224). Before that fix any signed-in
user of any org could list, read, patch, regenerate or delete every client.
| Caller | May do |
|---|---|
super_admin (and the internal key tawa-web uses) | everything |
| Anyone else | only clients their org owns |
A client is owned by your org when its Bio-ID record names your org (orgSlug), or it
belongs to a builder service your org owns or is an authorized org of
(matched by the stored credential's client id, else {service}-{env}). So
tawa oauth add-uri / get / update / regenerate-secret on your own service keeps working.
404, not 403, so a client's existence is not disclosed.GET /oauth/clients) returns only the clients you own. A client you created by hand
is listed once Bio-ID's record names your org; the builder stamps your org on every create.POST /oauth/clients) is for your own org only, with scopes limited to
openid profile email and grants to authorization_code refresh_token. Anything else, or
another org, needs super_admin (403).PATCH): allowedScopes, allowedGrantTypes, trustedClient and
autoApproveScopes need super_admin (even to narrow). headlessEnabled,
selfServiceEmail and passportConfig flags need super_admin to turn on; an owner may
turn them off. Redirect URIs, name, description, TTLs, branding, embed origins and custom
domains are the owner's to edit. A deploy still provisions the scopes your catalog derives
(that path does not go through these routes).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}` },
})
A headless client (headlessEnabled) calling /api/embed/* gets Bio-ID's transactional
emails — password reset, magic link, verification — under its own branding. Set
whiteLabel.emailFromName, logoUrl and primaryColor on the client in the Tawa
console (Console → OAuth → your client) and every email sent for that client uses
them; no code change.
Templates can also be overridden per client or per org in Bio-ID's admin (Email Templates → Add override). Resolution is most-specific-first:
client override → org override → platform default
To send your own mail instead, ask for the token rather than the send:
// POST /api/embed/forgot-password
{
"email": "[email protected]",
"sendEmail": false, // skip Bio-ID's email
"resetBaseUrl": "https://app.example.com/reset" // optional: your own reset page
}
// → { "sent": false, "resetToken": "…", "resetUrl": "…", "expiresAt": "…" }
Two requirements, both enforced server-side:
| Requirement | Why |
|---|---|
selfServiceEmail enabled on the client | Opt-in — this hands out a credential |
X-Client-Secret (server-to-server) auth | Origin auth only proves the call came from an allowed page, which any visitor can do. Returning a reset token there would be account takeover. |
A browser-origin call with sendEmail: false is refused with 403 SELF_SEND_REQUIRES_SECRET.
selfServiceEmail onIt is a per-client toggle in the console, in the same headless block as
headlessEnabled and allowedEmbedOrigins (Console → OAuth → your client), because it
only means anything for a headless client. Enabling it lets that client receive
password-reset tokens over the API — a credential-issuing capability, not a formatting
preference — so grant it to a client you would trust with a reset link, and leave it off
otherwise. Turning it on requires super_admin (see Who may manage an OAuth client).
The console's write goes through the builder: tawa-web → PATCH /oauth/clients/:clientId → UpdateClientSchema → Bio-ID. UpdateClientSchema is a strict allowlist and zod strips
anything it does not declare, silently — so a client setting that is missing from it
returns 200 and changes nothing. If a toggle in the console appears to do nothing, check
that schema before looking anywhere else.
The read path has no such field gate: the builder returns Bio-ID's client object verbatim on
GET /oauth/clients[/:clientId] (ownership-filtered, see above — it maps and projects nothing) so whatever Bio-ID stored
for a client is what the console renders. That asymmetry is the diagnostic. A setting that
reads back as you left it, after a 200 on the write, was dropped on the way in.
This mode deliberately reveals whether the account exists — you cannot send your own email otherwise. Every other path keeps enumeration protection.
When your app already proved the person owns their email, because they clicked an invite link you emailed, a headless client can create their account with the password they choose on your page. Bio-ID sends no email, and the account is signed in immediately. iec-split-pay's partner onboarding uses this: one branded welcome email, then a choose-your-password page.
Three things must all hold, and each is enforced server-side:
| Requirement | Why |
|---|---|
trustedInviteSignup: true on the client | Opt-in. It lets the client assert "this email is verified" |
X-Client-Id + X-Client-Secret (server-to-server) | Never browser-origin |
An approved org:manage org-grant for this client on the target org | Per org, not a scope. The target org can never be insureco |
// POST {BIO_ID_URL}/api/embed/signup
{ "email": "…", "password": "…", "firstName": "…", "lastName": "…",
"orgSlug": "acme", "emailVerified": true }
// 201 → { access_token, refresh_token, token_type, expires_in, scope, user: {…} }
// 409 USER_EXISTS → see set-initial-password below
roles field,
even [], is refused with 400 ROLES_NOT_ACCEPTED, and the user gets a plain membership.POST /api/oauth/token with
grant_type=refresh_token, not /api/embed/refresh.Accounts provisioned before the person arrived. An account created headlessly by
provision-org, with a temporary password nobody knows, returns USER_EXISTS from
signup. Call POST /api/embed/set-initial-password with { email, orgSlug, password },
under the same three requirements. It works once, and only on an account that:
onboardingPending marker. Only provision-org sets it, and any human
login, password change or identity link clears it;Anything else returns a uniform 403 NOT_ELIGIBLE. Send the person to normal sign-in.
trustedInviteSignup onOnly an insureco super_admin, in their own session, can set it. The builder, the
console (UpdateClientSchema does not carry it), service keys and the internal key are
all refused. With a Bio-ID session open at bio.tawa.pro, run this in the browser console:
await fetch('/api/admin/oauth-clients/<clientId>', { method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trustedInviteSignup: true }) }).then(r => r.status) // 200
Grant it only to a client whose secret you would trust to create verified users in the orgs it manages. That is exactly what the secret can do once the flag is on.
| 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: October 6, 2026