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

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

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: July 15, 2026