Bio-ID White Label & Headless Auth

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. When passport.branding: true is 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. See tawa-platform/architecture/passport-branding-auth for the full architecture.

There are two separate features, which can be combined or used independently:

FeatureWhat it does
White LabelShow your logo, colors, and CSS on the Bio-ID consent screen. Point a custom domain at Bio-ID.
Headless Embed APIBuild 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.


White Label Branding

Configuring in the Admin Panel

  1. Go to bio.tawa.insureco.io/admin/clients
  2. Click the menu on your client → Edit Client
  3. Open the White Label tab
  4. Toggle Enable White Labeling on
  5. Fill in your branding settings and save

Branding Options

SettingDescription
App NameShown as the page title on the consent screen
Logo URLYour logo image (SVG or PNG recommended, hosted publicly)
Primary ColorHex color for buttons and accents (e.g. #E63946)
Hide InsurEco BrandingRemoves the Bio-ID logo and "Powered by InsurEco" footer
Email From NameSender display name for magic-link emails sent through this client
Custom CSSInjected into the consent page <head>. Max 10 KB.

Custom CSS Example

: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 --primary to change the button color across the entire page without targeting individual elements.


Custom Domains

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.

Setup

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:

TypeNameTargetProxy
CNAMEauthbio.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.

Custom Issuer (Advanced)

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 iss claim in all tokens issued through your client. Any code that validates iss — including verifyTokenJWKS() in the @insureco/bio SDK — must be updated to accept the custom domain URL. Your next deploy will fail smoke tests until the issuer mismatch is resolved.


Headless Embed API

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.

Enabling in the Admin Panel

  1. Open your client's Edit ClientHeadless API tab
  2. Toggle Enable Headless Auth API on
  3. Add your app's origin(s) to Allowed Embed Origins (for browser SDK callers):
    https://yourapp.com
    https://staging.yourapp.com
    
  4. Save

Authentication Methods

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.

Endpoints

All embed endpoints are at https://bio.tawa.insureco.io/api/embed/* (or your custom domain).

POST /api/embed/signup

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.

POST /api/embed/login

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.

POST /api/embed/magic-link

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.

POST /api/embed/verify

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

POST /api/embed/refresh

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.

POST /api/embed/logout

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

Rate Limits

EndpointLimit
/api/embed/login20 requests / 60 seconds per IP
/api/embed/signup10 requests / 60 seconds per IP
/api/embed/magic-link3 requests / 10 minutes per IP
/api/embed/refresh60 requests / 60 seconds per IP
/api/embed/logout60 requests / 60 seconds per IP

Token Lifetimes

TokenLifetime
Access tokenConfigurable per client (default: 1 hour)
Refresh tokenConfigurable per client (default: 30 days)
Magic link token15 minutes, single use

Error Responses

All embed endpoints return consistent error shapes:

{
  "success": false,
  "error": {
    "code": "INVALID_CREDENTIALS",
    "message": "Invalid email or password"
  }
}
CodeHTTPMeaning
MISSING_CLIENT401X-Client-Id header missing
INVALID_CLIENT401Client ID not found or inactive
HEADLESS_DISABLED403Client doesn't have headless enabled
ORIGIN_NOT_ALLOWED403Browser origin not in allowedEmbedOrigins
INVALID_CLIENT_SECRET401Wrong client secret
VALIDATION_ERROR400Request body failed validation
USER_EXISTS409Email already registered (signup)
INVALID_CREDENTIALS401Wrong email or password (login)
INVALID_TOKEN401Magic link token invalid or expired
TOKEN_ALREADY_USED401Magic link already consumed
RATE_LIMITED429Too many requests

Auto-Approve and Trusted Clients

For first-party apps where showing a consent screen is unnecessary, you can suppress it.

Auto-Approve Scopes

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

Trusted Client

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.


Full Example: Custom Login Form

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

Security Checklist

  • Store refresh tokens in httpOnly, secure, SameSite=Strict cookies — never localStorage
  • Use X-Client-Secret only from server-side code — never expose it in frontend bundles
  • For browser-only apps, use allowedEmbedOrigins instead of a client secret
  • Custom CSS is sanitized but keep it minimal — avoid position: fixed overrides that could obscure security warnings
  • Rotate your client secret with Regenerate Secret in the admin panel if it is exposed

Last updated: March 6, 2026