By default, users authenticate through Bio-ID's hosted consent screen at bio.tawa.insureco.io. White-label mode lets you replace that experience entirely — your own branding, your own domain, or your own UI — while users still live in Bio-ID's identity store.
IMPORTANT: White-label branding is now driven by vault entity
profile.branding. Whenpassport.branding: trueis declared in catalog-info.yaml, Bio-ID reads the org's vault branding automatically at token mint time. Manual white-label configuration in the admin panel is still supported but vault branding takes precedence. Seetawa-platform/architecture/passport-branding-authfor the full architecture.
There are two separate features, which can be combined or used independently:
| Feature | What it does |
|---|---|
| White Label | Show your logo, colors, and CSS on the Bio-ID consent screen. Point a custom domain at Bio-ID. |
| Headless Embed API | Build your own signup/login UI. Your app calls Bio-ID's /api/embed/* endpoints directly and gets back tokens. No redirect to Bio-ID at all. |
Both are configured per OAuth client in the Bio-ID admin panel at bio.tawa.insureco.io/admin/clients.
bio.tawa.insureco.io/admin/clients⋯ menu on your client → Edit Client| Setting | Description |
|---|---|
| App Name | Shown as the page title on the consent screen |
| Logo URL | Your logo image (SVG or PNG recommended, hosted publicly) |
| Primary Color | Hex color for buttons and accents (e.g. #E63946) |
| Hide InsurEco Branding | Removes the Bio-ID logo and "Powered by InsurEco" footer |
| Email From Name | Sender display name for magic-link emails sent through this client |
| Custom CSS | Injected into the consent page <head>. Max 10 KB. |
:root {
--primary: #e63946;
--primary-hover: #c1121f;
}
.bio-login-card {
border-radius: 24px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
}
.bio-app-logo {
width: 120px;
}
TIP: Set
--primaryto change the button color across the entire page without targeting individual elements.
Custom domains let users see auth.yourapp.com in the URL bar instead of bio.tawa.insureco.io. Bio-ID uses Cloudflare for TLS — you don't need to manage certificates.
1. Add the domain in the admin panel
In your client's White Label tab, enter your domain(s) in Custom Domains (one per line):
auth.yourapp.com
2. Create a Cloudflare CNAME
In your Cloudflare dashboard, add a proxied (orange cloud) CNAME record:
| Type | Name | Target | Proxy |
|---|---|---|---|
| CNAME | auth | bio.tawa.insureco.io | ✓ Proxied |
IMPORTANT: The record must be proxied (orange cloud), not DNS-only. Cloudflare handles the TLS certificate for your domain. DNS-only will not work because Bio-ID doesn't serve a certificate for your domain directly.
3. Add your custom domain as a redirect URI
In the General tab, add your custom domain's callback URL:
https://auth.yourapp.com/oauth/callback
Bio-ID resolves custom domain requests to your OAuth client automatically, using a 60-second Redis cache.
By default, the iss claim in tokens always stays as https://bio.tawa.insureco.io — even when the user authenticates through your custom domain. This means your existing token validators work without any changes.
If you need the iss to match your custom domain, enable Custom Issuer in the White Label tab.
WARNING: Enabling Custom Issuer changes the
issclaim in all tokens issued through your client. Any code that validatesiss— includingverifyTokenJWKS()in the@insureco/bioSDK — must be updated to accept the custom domain URL. Your next deploy will fail smoke tests until the issuer mismatch is resolved.
The headless embed API lets you build a fully custom login/signup UI without any redirect to Bio-ID. Your frontend calls Bio-ID's /api/embed/* endpoints directly and gets back access and refresh tokens.
https://yourapp.com
https://staging.yourapp.com
Server-to-server — Include X-Client-Id and X-Client-Secret headers. Requires a confidential client.
Browser SDK — Include only X-Client-Id. Bio-ID validates the Origin header against your allowedEmbedOrigins list.
All embed endpoints are at https://bio.tawa.insureco.io/api/embed/* (or your custom domain).
Create a new user account and return tokens immediately.
const res = await fetch('https://bio.tawa.insureco.io/api/embed/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret', // server-to-server only
},
body: JSON.stringify({
email: '[email protected]',
password: 'SecurePassword123!',
firstName: 'Jane',
lastName: 'Doe',
orgSlug: 'acme-corp', // optional: attach to existing org
}),
})
const { data } = await res.json()
// data.access_token — JWT bearer token
// data.refresh_token — opaque refresh token
// data.expires_in — seconds until access token expires
// data.user.bioId — unique Bio-ID identifier
// data.user.orgSlug — org the user belongs to
If orgSlug is omitted, Bio-ID automatically creates an organization for the new user.
Authenticate with email and password.
const res = await fetch('https://bio.tawa.insureco.io/api/embed/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret',
},
body: JSON.stringify({
email: '[email protected]',
password: 'SecurePassword123!',
}),
})
Returns the same token shape as signup.
Send a passwordless login link to an email address.
await fetch('https://bio.tawa.insureco.io/api/embed/magic-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret',
},
body: JSON.stringify({
email: '[email protected]',
redirectUrl: 'https://yourapp.com/auth/verify', // where to redirect after verification
}),
})
// { success: true, data: { message: 'Magic link sent' } }
The link expires in 15 minutes and is single-use. The email sender name is taken from your client's emailFromName white-label setting.
Exchange a magic-link token for session tokens. Your redirectUrl should call this endpoint with the token query param.
// In your /auth/verify route handler:
const token = new URL(request.url).searchParams.get('token')
const res = await fetch('https://bio.tawa.insureco.io/api/embed/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret',
},
body: JSON.stringify({ token }),
})
const { data } = await res.json()
// data.access_token, data.refresh_token, data.user
Rotate a refresh token and get a new access token.
const res = await fetch('https://bio.tawa.insureco.io/api/embed/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret',
},
body: JSON.stringify({ refresh_token: storedRefreshToken }),
})
const { data } = await res.json()
// data.access_token — new JWT
// data.refresh_token — new refresh token (old one is invalidated)
// data.expires_in
IMPORTANT: Refresh tokens are rotated on every use. Store the new token and discard the old one immediately. Using an old refresh token will return 401.
Revoke a refresh token, ending the session.
await fetch('https://bio.tawa.insureco.io/api/embed/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': 'your-client-id',
'X-Client-Secret': 'your-client-secret',
},
body: JSON.stringify({ refresh_token: storedRefreshToken }),
})
// { success: true, data: { message: 'Logged out successfully' } }
| Endpoint | Limit |
|---|---|
/api/embed/login | 20 requests / 60 seconds per IP |
/api/embed/signup | 10 requests / 60 seconds per IP |
/api/embed/magic-link | 3 requests / 10 minutes per IP |
/api/embed/refresh | 60 requests / 60 seconds per IP |
/api/embed/logout | 60 requests / 60 seconds per IP |
| Token | Lifetime |
|---|---|
| Access token | Configurable per client (default: 1 hour) |
| Refresh token | Configurable per client (default: 30 days) |
| Magic link token | 15 minutes, single use |
All embed endpoints return consistent error shapes:
{
"success": false,
"error": {
"code": "INVALID_CREDENTIALS",
"message": "Invalid email or password"
}
}
| Code | HTTP | Meaning |
|---|---|---|
MISSING_CLIENT | 401 | X-Client-Id header missing |
INVALID_CLIENT | 401 | Client ID not found or inactive |
HEADLESS_DISABLED | 403 | Client doesn't have headless enabled |
ORIGIN_NOT_ALLOWED | 403 | Browser origin not in allowedEmbedOrigins |
INVALID_CLIENT_SECRET | 401 | Wrong client secret |
VALIDATION_ERROR | 400 | Request body failed validation |
USER_EXISTS | 409 | Email already registered (signup) |
INVALID_CREDENTIALS | 401 | Wrong email or password (login) |
INVALID_TOKEN | 401 | Magic link token invalid or expired |
TOKEN_ALREADY_USED | 401 | Magic link already consumed |
RATE_LIMITED | 429 | Too many requests |
For first-party apps where showing a consent screen is unnecessary, you can suppress it.
In the Headless API tab, check the scopes you want silently approved. Users won't see a consent screen for those scopes.
☑ openid
☑ profile
☑ email
☐ offline_access
Enable Trusted Client to bypass the consent screen entirely for all scopes. Use this only for apps you fully control — users will never be prompted to approve permissions.
WARNING: Trusted client is appropriate for your own first-party app. Do not enable it for third-party integrations — those should go through normal OAuth consent.
// Your login page handler
export async function handleLogin(email: string, password: string) {
const res = await fetch('https://auth.yourapp.com/api/embed/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.BIO_CLIENT_ID!,
'X-Client-Secret': process.env.BIO_CLIENT_SECRET!,
},
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(error.message)
}
const { data } = await res.json()
// Store tokens securely (httpOnly cookies recommended)
await setAuthCookies({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
})
return data.user
}
X-Client-Secret only from server-side code — never expose it in frontend bundlesallowedEmbedOrigins instead of a client secretposition: fixed overrides that could obscure security warningsLast updated: March 6, 2026