Bio-ID is the Tawa platform's identity provider. It handles user authentication, organization membership, and role assignments. Adding SSO to your service means declaring it in catalog-info.yaml — the builder provisions an OAuth client automatically on every deploy.
Two auth modes are available:
| Mode | Use case | What gets injected |
|---|---|---|
sso | User-facing login (browser redirect flow) | BIO_CLIENT_ID, BIO_CLIENT_SECRET |
service-only | Machine-to-machine only (no user login) | BIO_CLIENT_ID, BIO_CLIENT_SECRET |
BIO_ID_URL is always injected as a core platform variable — you never need to declare it.
Beyond basic SSO, you can configure the Passport to bundle branding and permissions into the JWT at mint time. This is configured via spec.auth.passport in catalog-info.yaml:
| Config | Effect |
|---|---|
passport.branding: true | Bio-ID fetches org's vault branding, bundles into token |
passport.scopes: [...] | Bio-ID resolves scopes from Koko, bundles into permissions |
passport.headless: true | Enables /api/embed/* endpoints — no redirect, Bio-ID invisible |
Three auth modes, all producing the same Passport:
auth: mode: sso — "Sign in with Bio-ID"passport.branding: true — org's domain and branding, Bio-ID invisiblepassport.headless: true — fully custom UI, server-to-server onlyNOTE: See
tawa-platform/architecture/passport-branding-authfor the full Passport, branding, and auth modes architecture.
spec:
auth:
mode: sso
That's all. On your next deploy, the builder will:
/api/auth/callback as the redirect URI for your environmentBIO_CLIENT_ID, BIO_CLIENT_SECRET, and BIO_ID_URL into your podIMPORTANT: Do not use
internalDependencies: bio-idto enable OAuth — that is incorrect.internalDependenciesis for resolving internal service URLs, not for OAuth provisioning. Always useauth: mode: ssoin spec.
npm install @insureco/bio
The login route redirects the user to Bio-ID to authenticate. Store state and codeVerifier in httpOnly cookies so the callback can verify them.
import { BioAuth } from '@insureco/bio'
const bio = BioAuth.fromEnv()
// Reads BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL automatically
app.get('/api/auth/login', (req, res) => {
const { url, state, codeVerifier } = bio.getAuthorizationUrl({
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
})
// Store in httpOnly cookies — never expose in the URL or response body
res.cookie('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'lax' })
res.cookie('oauth_verifier', codeVerifier, { httpOnly: true, secure: true, sameSite: 'lax' })
res.redirect(url)
})
IMPORTANT: The callback route MUST be at exactly
/api/auth/callback. The builder registers this path as the redirect URI. Any other path — including/api/auth/bio-id/callbackor/auth/callback— will be rejected by Bio-ID with an "Invalid Redirect URI" error.
Register the handler directly on the app at the full path — do not rely solely on Express sub-router composition (e.g. app.use('/api/auth', router) + router.get('/callback', ...)). The tawa preflight scanner uses static analysis and checks for the literal string /api/auth/callback in your route registrations. If the path is split across a mount point and a sub-router, preflight will report a FAIL even though the route works correctly at runtime.
If you use sub-routers, export the callback handler and register it explicitly at the full path in your main app file:
// routes/auth.routes.ts
export async function handleOAuthCallback(req: Request, res: Response): Promise<void> {
// ... callback logic
}
router.get('/callback', handleOAuthCallback) // keeps sub-router working
// app.ts
import authRoutes, { handleOAuthCallback } from './routes/auth.routes'
app.get('/api/auth/callback', handleOAuthCallback) // explicit — preflight finds this
app.use('/api/auth', authRoutes) // sub-router — other auth routes
app.get('/api/auth/callback', async (req, res) => {
const { code, state } = req.query
const storedState = req.cookies.oauth_state
const codeVerifier = req.cookies.oauth_verifier
// Always verify state to prevent CSRF
if (state !== storedState) {
return res.status(400).send('Invalid state parameter')
}
try {
const tokens = await bio.exchangeCode(
code as string,
codeVerifier,
`${process.env.APP_URL}/api/auth/callback`
)
// Store access token in a secure httpOnly cookie
res.cookie('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1 hour
})
res.redirect('/dashboard')
} catch (err) {
console.error('OAuth callback failed:', err)
res.redirect('/api/auth/login')
}
})
Most services do not need to verify tokens directly. If your routes are registered in Koko with auth: required, Janus verifies the token before forwarding the request to your service.
Only implement token verification if your service runs its own auth middleware (for example, a Next.js edge middleware or a standalone API that handles requests before they reach Janus).
import { verifyTokenJWKS } from '@insureco/bio'
// In middleware or a route guard:
async function requireAuth(req, res, next) {
const token = req.headers.authorization?.slice(7) // strip "Bearer "
try {
const payload = await verifyTokenJWKS(token)
// payload.bioId — unique user identifier
// payload.orgSlug — user's organization slug
// payload.roles — array of role names
// payload.email — user's email address
req.user = payload
next()
} catch {
res.status(401).json({ error: 'Unauthorized' })
}
}
NOTE: Never use the old
verifyToken(token, secret)HS256 pattern. Bio-ID uses RS256. TheverifyTokenJWKSfunction fetches the public key from Bio-ID's JWKS endpoint and verifies locally. No shared secret is needed in your service.
When your service needs to call another service's API on behalf of itself (not a user), use client credentials:
const bio = BioAuth.fromEnv()
const tokens = await bio.getClientCredentialsToken(['service:read'])
// tokens.access_token carries your service identity — use it as a Bearer token
const response = await fetch(`${process.env.OTHER_SERVICE_URL}/api/data`, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
})
| Token | Lifetime | Notes |
|---|---|---|
| Access token | 1 hour | RS256 JWT, carries user claims |
| Refresh token | 30 days | Rotated on each use |
| Client credentials | 1 hour | No refresh token issued |
| Authorization code | 10 minutes | Single-use |
| Variable | Description |
|---|---|
BIO_CLIENT_ID | OAuth client ID for your service |
BIO_CLIENT_SECRET | OAuth client secret |
BIO_ID_URL | Bio-ID base URL — always injected, no declaration needed |
These are injected by the builder on every deploy. Do not hardcode these values and do not set them manually via tawa config set — the builder manages them.
Use tawa local-setup to configure your service for local development in one step. It registers the localhost redirect URI, regenerates the client secret, pulls all sandbox config vars, and writes .env.local with APP_URL overridden to localhost.
cd my-service
tawa local-setup --port 3000 # or whatever port your dev server uses
This replaces the manual steps of tawa oauth add-uri, tawa oauth regenerate-secret, and tawa config pull. Run it once after cloning, and again any time you add new config vars to sandbox.
The APP_URL override is critical — your service uses it to construct redirect URIs. Without it pointing to localhost, the OAuth callback would redirect back to the sandbox domain instead of your local server.
WARNING: Never commit
.env.localto git. It contains decrypted secrets and is written with restricted (0600) permissions.
tawa oauth list # List all OAuth clients for your org
tawa oauth get <client-id> # Inspect redirect URIs and scopes
tawa oauth add-uri <client-id> <uri> # Add a redirect URI manually
tawa oauth remove-uri <client-id> <uri> # Remove a redirect URI
tawa oauth regenerate-secret <client-id> # Rotate the client secret
tawa oauth delete <client-id> # Delete an OAuth client
/api/auth/bio-id/callback or any path other than /api/auth/callback. The builder only registers /api/auth/callback.app.use('/api/auth', router) + router.get('/callback', ...) makes the route work at runtime but fail tawa preflight. The scanner uses static analysis and requires the literal path /api/auth/callback to appear in a route registration. Fix: export the handler and also register it directly with app.get('/api/auth/callback', handler) in your main app file.internalDependencies: bio-id — This is for resolving service URLs, not for enabling OAuth. Use auth: mode: sso in spec.auth: mode: sso — Without it, BIO_CLIENT_ID and BIO_CLIENT_SECRET are never injected and your service cannot authenticate users.verifyToken(token, secret) is deprecated. Use verifyTokenJWKS(token).JWT_SECRET in your service — Not needed with RS256. Bio-ID's private key signs tokens; your service verifies using the public JWKS endpoint.state in the callback — Always compare state from the query string against the value stored in the cookie to prevent CSRF attacks.bio.getAuthorizationUrl — This method is synchronous and returns immediately. It does not make network calls.Last updated: March 6, 2026