A Passport is a self-contained identity object that travels with a user across every Tawa service. It carries identity, org branding, and permissions — all bundled at token mint time by Bio-ID. Services never make a second call to get this data.
One login, everywhere. One object, everything you need.
Canonical spec:
tawa-platform/architecture/passport-branding-authin the Bible.
interface Passport {
// Identity
bioId: string // unique user ID (immutable)
email: string
firstName: string
lastName: string
displayName: string
avatar?: string
userType: string // 'user' | 'service' | 'admin'
roles: string[] // roles in primary org
// Primary org
orgSlug: string
orgId: string // ORG-xxx format
orgName: string
// All org memberships (multi-org users)
orgs: {
orgSlug: string
orgId: string
orgName: string
roles: string[]
modules: string[]
}[]
// Aggregated modules across all orgs
modules: string[]
// Branding — loaded from vault at mint time (when passport.includeBranding: true)
branding: {
displayName: string // org display name for UI chrome
tagline?: string
logoUrl?: string // primary logo (SVG or PNG)
logoMarkUrl?: string // icon/mark for small surfaces
primaryColor: string // hex — defaults to '#0F172A'
secondaryColor?: string // hex
websiteUrl?: string
verified: boolean // platform verified this entity
whiteLabelApproved: boolean // approved for custom domain + hideEcoBranding
}
// Permissions — resolved from Koko at mint time
permissions: {
scopes: string[] // e.g. ['raterspot:rate', 'docman:read']
}
// Vault link
iecHash?: string // entity's chain address (if vault entity exists)
// Session metadata
issuedAt: string
connectedServices: string[]
}
// req.user IS the Passport — already populated by Janus or your middleware
const { orgSlug, branding, permissions } = req.user
// Brand the page
document.title = branding.displayName
headerLogo.src = branding.logoUrl
root.style.setProperty('--primary', branding.primaryColor)
// Check permission
if (permissions.scopes.includes('raterspot:rate')) {
showRatingPanel()
}
// No vault lookup. No Koko call. It's already in the token.
import { useEffect, useState } from 'react'
const BIO_ID_URL = process.env.NEXT_PUBLIC_BIO_ID_URL || 'https://bio.insureco.io'
export function usePassport(token: string | null) {
const [passport, setPassport] = useState<Passport | null>(null)
useEffect(() => {
if (!token) return
const ws = new WebSocket(`${BIO_ID_URL.replace(/^http/, 'ws')}/passport?token=${token}`)
ws.onmessage = (e) => {
const { type, passport } = JSON.parse(e.data)
if (type === 'identity' || type === 'passport_updated') setPassport(passport)
if (type === 'revoked') setPassport(null)
}
ws.onclose = () => setPassport(null)
return () => ws.close()
}, [token])
return passport
}
import WebSocket from 'ws'
const BIO_ID_URL = process.env.BIO_ID_URL || 'https://bio.insureco.io'
export function connectPassport(token: string, onIdentity: (passport: Passport) => void) {
const ws = new WebSocket(`${BIO_ID_URL.replace(/^http/, 'ws')}/passport?token=${token}`)
ws.on('message', (raw) => {
const { type, passport } = JSON.parse(raw.toString())
if (type === 'identity' || type === 'passport_updated') onIdentity(passport)
if (type === 'revoked') process.exit(0)
})
return () => ws.close()
}
wss://bio.insureco.io/passport?token=<access_token>
Advertised in Bio-ID's OIDC configuration as passport_endpoint.
| Event type | When | Payload |
|---|---|---|
identity | On connect (if session valid) | { type, passport } |
passport_updated | Role/org/branding change | { type, passport } |
revoked | Logout or token invalidated | { type } |
| Code | Reason |
|---|---|
4001 | Missing token — no ?token= in URL |
4003 | Invalid token — expired or tampered |
4004 | User not found or suspended |
All three modes produce the same Passport object. The mode determines the user experience.
| Mode | Bio-ID visible? | User redirected? | Dev builds UI? |
|---|---|---|---|
| Branded | Yes — "Sign in with Bio-ID" | Yes (bio.tawa.pro) | No |
| White-Label | No — org's domain + branding | Yes (custom domain) | No (CSS only) |
| Headless | No — completely invisible | No — server-to-server | Yes — fully custom |
spec:
auth:
mode: sso
spec:
auth:
mode: sso
passport:
includeBranding: true
Org's vault branding auto-applies to the consent screen. Custom domain + hideEcoBranding requires whiteLabelApproved: true on the vault entity.
spec:
auth:
mode: sso
passport:
includeBranding: true
headless: true
allowedOrigins:
- https://yourapp.com
const bio = BioAuth.fromEnv()
// Signup — server-to-server, no redirect
const tokens = await bio.embed.signup({
email, password, firstName,
orgSlug: 'acme-agency', // optional — omit to auto-create org
})
// Login
const tokens = await bio.embed.login({ email, password })
// Magic link (you send the email yourself)
const { magicToken } = await bio.embed.createMagicLink({ email })
// Verify magic token
const tokens = await bio.embed.verify({ token: magicToken })
// Refresh
const tokens = await bio.embed.refresh({ refreshToken })
Any mode → user signs up → Bio-ID auto-creates personal org (slug from name)
→ Passport minted with new org → user lands in service
Admin: POST /api/v2/orgs/:orgSlug/invites { email, role, modules }
→ Bio-ID stores invite, sends email (or returns inviteUrl for headless)
→ User clicks link → signup (if new) or login (if existing)
→ Attached to inviting org with specified role
→ Passport minted with inviting org as primary
Headless invite acceptance:
const tokens = await bio.embed.signup({
email, password, firstName,
inviteToken: 'TOKEN', // resolves to org + role
})
// Create user directly in an org (requires org:manage scope)
const tokens = await bio.embed.signup({
email: '[email protected]',
password: tempPassword,
firstName: 'New',
orgSlug: 'acme-agency',
role: 'agent',
})
Branding lives on vault entities at profile.branding. The Passport reads it at mint time.
| Tier | Who | What they can set |
|---|---|---|
| Claimed | Any entity after claim flow | displayName, tagline, logoUrl, logoMarkUrl, colors, website, phone, email |
| White-Label Approved | Platform approval | customDomain, hideEcoBranding, emailFromName, customCss |
GET /v1/branding/:orgSlug → 0 gas, public
PATCH /v1/entities/:iecHash/branding → 2 gas, auth required
import { VaultClient } from '@insureco/bio-vault'
const vault = VaultClient.fromEnv()
const branding = await vault.getBranding('acme-agency')
| Flow | Use when |
|---|---|
| Full-page redirect | First login, consent screens |
| Popup | Staying on page during auth |
| Headless embed API | Full control, Bio-ID invisible |
| Passport socket | Already authenticated — propagate identity silently |
After any OAuth flow completes, Bio-ID publishes the Passport on the socket. Any connected service receives the identity immediately.
req.user.branding.logoUrl renders the org's logo instantlyreq.user.permissions.scopes controls feature accesspassport.includeBranding is not declared (or false) in catalog-info.yaml, Passport carries identity only (no branding)bio.embed.signup({ orgSlug }) requires org:manage scope — auto-granted when deployed by that orgLast updated: July 15, 2026