Authentication on Tawa

The Short Version

  • To enable user login, declare spec.auth: mode: sso in catalog-info.yaml — that's what injects BIO_CLIENT_ID and BIO_CLIENT_SECRET.
  • Routes registered in catalog-info.yaml with auth: required → Janus verifies tokens for you. No SDK needed.
  • Services with custom auth middleware (e.g. Next.js) → use verifyTokenJWKS(token). No secret needed.
  • Service-to-service → use BioAuth.fromEnv().getClientCredentialsToken(scopes).
  • NEVER use JWT_SECRET — that was HS256. Bio-ID 0.3.0+ issues RS256 tokens.

Step 1: Enable OAuth in catalog-info.yaml

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-id to internalDependencies does NOT enable OAuth. BIO_ID_URL is always auto-injected as a core platform variable — no declaration needed.

How It Works

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

When You DON'T Need the SDK

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

When You DO Need the SDK

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.

OAuth Flow (user login)

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".

Redirect URIs are merged on deploy (not overwritten)

On every deploy the builder syncs your OAuth client's redirect URIs. It auto-derives:

  • the platform hostname: https://{service}.{env-prefix}{PLATFORM_DOMAIN}/api/auth/callback
  • any verified custom domains for that environment

These 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}.

Allowed scopes are merged on deploy too (not overwritten)

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-prod lost personas:mint minutes after it was granted, and its client_credentials request started failing with invalid_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.

Who may manage an OAuth client (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.

CallerMay do
super_admin (and the internal key tawa-web uses)everything
Anyone elseonly 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.

  • Not yours = 404, not 403, so a client's existence is not disclosed.
  • List (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.
  • 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).
  • Update (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).

The builder owns the client secret — don't set it manually

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.

Self-healing a deleted / out-of-band-rotated client

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_client but the client is present, force a clean reprovision with tawa oauth delete {client-id} followed by tawa deploy — the delete clears the stored credential, and the deploy recreates the client and stores the matching secret.

Next.js App Router Patterns

Reading the token in API routes

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
}

Preventing redirect loops

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.

Authorization header fallback (custom domain cookie issues)

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}` } : {}),
    },
  })
}

Service-to-Service Auth

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}` },
})

Headless auth emails (white-label clients)

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

Sending the email yourself

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:

RequirementWhy
selfServiceEmail enabled on the clientOpt-in — this hands out a credential
X-Client-Secret (server-to-server) authOrigin 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.

Turning selfServiceEmail on

It 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.

Trusted invite signup (one email, no Bio-ID emails)

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:

RequirementWhy
trustedInviteSignup: true on the clientOpt-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 orgPer 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
  • No roles. Roles and permissions live on the org, not the user. A roles field, even [], is refused with 400 ROLES_NOT_ACCEPTED, and the user gets a plain membership.
  • Refresh through the standard endpoint, 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:

  • carries Bio-ID's onboardingPending marker. Only provision-org sets it, and any human login, password change or identity link clears it;
  • has never logged in and is a member of that org through this client.

Anything else returns a uniform 403 NOT_ELIGIBLE. Send the person to normal sign-in.

Turning trustedInviteSignup on

Only 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.

Environment Variables (auto-injected)

VariableWhen injectedPurpose
BIO_CLIENT_IDWhen spec.auth: mode: sso or service-onlyYour service's OAuth client ID
BIO_CLIENT_SECRETWhen spec.auth: mode: sso or service-onlyYour service's OAuth client secret
BIO_ID_URLAlways — core platform variableBio-ID base URL. No declaration needed.

What NOT to Do

// ❌ 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