This is the definitive spec for how identity, branding, and permissions work across the Tawa ecosystem. It defines:
profile.branding schema on vault entitiesIMPORTANT: This spec supersedes the identity-only Passport shape in
tawa-docs/conventions/passport.md. The existing socket transport and events remain unchanged — the Passport payload is extended.
The Passport is a self-contained identity object. It carries everything a service needs — identity, branding, permissions — so the service never makes a second call. Bio-ID assembles it at token mint time by combining the user record, the org's vault branding, and the service's permission requirements from Koko.
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
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' (platform dark)
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[]
}
A standard Bio-ID JWT carries identity fields (bioId, orgSlug, roles). The Passport extends the JWT with branding and permissions bundled at mint time. Services that only need identity can read the JWT directly. Services that need branding or permissions read the Passport — same token, richer payload.
The tradeoff: if an org updates their logo, existing Passports carry the old logo until the token refreshes (1 hour max). This is acceptable and matches how Google handles profile changes.
// In any route handler — the Passport IS req.user
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 extra API calls. No vault lookup. No Koko call.
Every service picks one mode. The mode determines the user experience. All three produce the same Passport object.
The platform identity is visible and is the trust signal — like "Sign in with Google."
User clicks "Sign in with Bio-ID"
→ Redirect to bio.tawa.pro/oauth/authorize
→ Bio-ID consent screen (InsurEco logo, platform colors)
→ Passport returned to service via callback
Used on: insurebio.com, policyblockchain.com, any surface where the InsurEco brand is the point.
catalog-info.yaml:
spec:
auth:
mode: sso
The Bio-ID consent screen is fully skinned with the org's branding. Custom domain. No InsurEco mention. The user authenticates through a hosted page they think belongs to the org.
User clicks "Sign in"
→ Redirect to auth.acmeagency.com/oauth/authorize
(CNAME → bio.tawa.pro; Acme's logo, colors; no "Powered by InsurEco")
→ Passport returned to service via callback
→ JWT iss = auth.acmeagency.com (Custom Issuer mode)
What makes it truly invisible:
| Leakage vector | How it's suppressed |
|---|---|
| URL bar | Custom domain via CNAME |
| "Powered by InsurEco" footer | hideEcoBranding: true in vault branding |
| Magic link email sender | emailFromName set to org's brand |
JWT iss claim | Custom Issuer mode (optional, advanced) |
| Error messages | Generic — no "Bio-ID" string in any user-facing error |
Used on: Portals built for agencies/carriers where the org's brand is in front.
catalog-info.yaml:
spec:
auth:
mode: sso
passport:
branding: true # bundle vault branding into Passport
White-label configuration is stored on the org's vault entity (profile.branding). Bio-ID reads it automatically when the org's users authenticate.
No hosted page. No redirect. No Bio-ID URL anywhere. The dev builds the entire auth UI. Bio-ID is an invisible identity store that the backend talks to server-to-server.
User fills out YOUR signup/login form
→ Your backend POSTs to bio.tawa.pro/api/embed/signup
→ Bio-ID returns tokens + Passport
→ Your backend sets cookies
→ User never knows Bio-ID exists
What makes it truly invisible:
| Leakage vector | How it's suppressed |
|---|---|
| Redirect URL | No redirect — server-to-server only |
| Network tab | Your backend proxies; Bio-ID URL never in browser |
Token iss | Validated server-side; user never sees it |
| Emails | You send them yourself (your SMTP, your templates) |
| Error messages | You write them — Bio-ID errors are caught server-side |
Used on: ISVs, products where "InsurEco" or "Bio-ID" would confuse users.
catalog-info.yaml:
spec:
auth:
mode: sso
passport:
branding: true
headless: true # enables embed API for this client
allowedOrigins: # browser SDK callers (optional)
- https://yourapp.com
Dev pattern (always server-to-server):
import { BioAuth } from '@insureco/bio'
const bio = BioAuth.fromEnv()
// Signup — returns tokens + full Passport
const { access_token, refresh_token, user } = await bio.embed.signup({
email: req.body.email,
password: req.body.password,
firstName: req.body.firstName,
orgSlug: req.body.orgSlug, // optional — omit to auto-create org
})
// Login
const tokens = await bio.embed.login({
email: req.body.email,
password: req.body.password,
})
// Magic link (you send the email yourself)
const { magicToken } = await bio.embed.createMagicLink({
email: req.body.email,
})
// → Send magicToken via your own email/SMS system
// → User clicks link → your verify route calls bio.embed.verify({ token })
// Refresh
const tokens = await bio.embed.refresh({ refreshToken })
| Question | Branded | White-Label | Headless |
|---|---|---|---|
| "Bio-ID" visible to users? | Yes | No | No |
| InsurEco brand visible? | Yes | Optional (footer toggle) | No |
| User redirected to hosted page? | Yes | Yes (custom domain) | No |
| Dev builds auth UI? | No | No (CSS customization only) | Yes — fully custom |
| Custom domain required? | No | Yes (CNAME) | No |
| Auth emails sent by | Bio-ID | Bio-ID (branded as org) | You (your SMTP) |
Branding lives on vault entities at profile.branding. This is the single source of truth for all surfaces — the Passport reads it, registry pages render it, Bio-ID applies it to consent screens.
interface VaultBranding {
// Public display — self-serve after claiming
displayName: string // shown on all surfaces (registry pages, headers)
tagline?: string // "Colorado's largest commercial agency"
logoUrl?: string // primary logo — SVG or PNG, hosted on vault CDN
logoMarkUrl?: string // icon/mark for small surfaces (32x32 avatars)
primaryColor: string // hex — buttons, links, accents
secondaryColor?: string // hex
websiteUrl?: string
phone?: string
email?: string
// White-label auth surface — requires whiteLabelApproved
customDomain?: string // auth.acmeagency.com (CNAME → bio.tawa.pro)
hideEcoBranding: boolean // remove "Powered by InsurEco" footer
emailFromName?: string // "Acme Insurance" — magic link sender name
customCss?: string // injected into consent page, max 10KB
// Platform approval state
verified: boolean // platform confirmed this entity is real
whiteLabelApproved: boolean // approved for customDomain + hideEcoBranding + customCss
}
| Tier | Who gets it | What they can set |
|---|---|---|
| Claimed | Any entity after Bio-ID claim flow | displayName, tagline, logoUrl, logoMarkUrl, colors, website, phone, email |
| White-Label Approved | Platform approval after verification | customDomain, hideEcoBranding, emailFromName, customCss |
Basic branding (logo, colors) is self-serve. White-label (custom domain, suppressing InsurEco) requires platform approval.
GET /v1/branding/:orgSlug → 0 gas, public
GET /v1/entities/:iecHash/branding → 0 gas, public
PATCH /v1/entities/:iecHash/branding → 2 gas, auth required (owner or admin)
Response always returns the full shape with platform defaults for unset fields:
{
"displayName": "Acme Insurance Agency",
"tagline": "Colorado commercial specialists",
"logoUrl": "https://vault.ins.bio/assets/acme-agency/logo.svg",
"logoMarkUrl": null,
"primaryColor": "#E63946",
"secondaryColor": "#1D3557",
"websiteUrl": "https://acmeinsurance.com",
"phone": "+17205550100",
"email": "[email protected]",
"customDomain": null,
"hideEcoBranding": false,
"emailFromName": null,
"customCss": null,
"verified": true,
"whiteLabelApproved": false
}
import { VaultClient } from '@insureco/bio-vault'
const vault = VaultClient.fromEnv()
// By orgSlug — most common (you have it from the JWT)
const branding = await vault.getBranding('acme-agency')
// By iecHash — for public entity pages
const branding = await vault.getBrandingByHash('0x1a2b3c...')
Branded / White-Label:
→ User clicks "Sign up" on consent screen
→ Bio-ID creates user + auto-creates personal org (slug from name)
→ Passport minted with new org
→ Redirect back to service
Headless:
→ User fills your signup form
→ bio.embed.signup({ email, password, firstName })
→ Bio-ID creates user + auto-creates personal org
→ Tokens returned → your backend sets session
The auto-created org slug is derived from the user's name: jane-doe. If taken, a numeric suffix is appended: jane-doe-2. The org is created with the user as owner.
Admin creates invite:
POST /api/v2/orgs/:orgSlug/invites
{ email, role, modules }
→ Bio-ID stores invite, sends email via relay (or returns inviteUrl for headless)
User clicks invite link:
→ Link format: https://{service}/join?invite=TOKEN
→ If user exists: attach to org with specified role
→ If user is new: signup form, then attach to org
→ 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
})
For products that manage their own user list and want users pre-attached to an org:
// Your backend creates the user in the right org directly
const tokens = await bio.embed.signup({
email: '[email protected]',
password: tempPassword,
firstName: 'New',
lastName: 'Agent',
orgSlug: 'acme-agency', // user is created IN this org
role: 'agent', // with this role
})
// → User belongs to acme-agency immediately, no invite needed
NOTE: Calling
bio.embed.signupwithorgSlugrequires the service's OAuth client to haveorg:managescope for that org. This is auto-granted when the service is deployed by the org.
Admin opens Console → Org Settings → Members → "Add Member"
→ Enters email, selects role, selects modules
→ Bio-ID creates invite record
→ Email sent via relay (branded with org's vault branding)
→ User clicks link → signup or login → lands in org
→ Admin sees new member in member list immediately (passport_updated event)
| Context | Org slug source |
|---|---|
| Self-signup (no invite) | Auto-generated from name: jane-doe |
| Invite link accepted | From invite record → inviting org's slug |
Headless signup with orgSlug param | Explicitly provided by the calling service |
Headless signup without orgSlug | Auto-generated (same as self-signup) |
| User already exists + invite | Org added to orgs[], becomes primary if user accepts |
spec:
auth:
mode: sso
This gives you standard OAuth. The Passport carries identity only. No branding in the token.
spec:
auth:
mode: sso
passport:
branding: true # bundle org's vault branding into Passport
scopes: [raterspot:rate] # service needs these scopes resolved in Passport
spec:
auth:
mode: sso
passport:
branding: true
headless: true
allowedOrigins:
- https://myapp.tawa.pro
- https://staging.myapp.tawa.pro
On deploy, the builder reads spec.auth.passport and:
passportConfig.branding: true → Bio-ID fetches vault branding at mint timepassportConfig.scopes: [...] → Bio-ID resolves from Koko and bundlesheadlessEnabled: true → enables the /api/embed/* endpoints for this clientallowedEmbedOrigins: [...] → CORS for browser SDK callersBIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL as always{
"serviceName": "my-service",
"passportConfig": {
"branding": true,
"scopes": ["raterspot:rate"],
"headless": true,
"allowedOrigins": ["https://myapp.tawa.pro"]
}
}
Bio-ID queries Koko when minting a Passport for a service: "what does this service want bundled?" Koko returns the config. Bio-ID assembles the Passport accordingly.
When a dev opens their service in the Tawa console, the Passport panel shows:
Passport Configuration
──────────────────────
Auth Mode: SSO
Branding: ✓ Enabled — org branding bundled into every token
Headless: ✓ Enabled — embed API active
Scopes: raterspot:rate, docman:read
Allowed Origins: https://myapp.tawa.pro
Passport Preview (for acme-agency)
──────────────────────────────────
{
bioId: "USR-example",
orgSlug: "acme-agency",
branding: {
displayName: "Acme Insurance Agency",
logoUrl: "https://vault.ins.bio/assets/...",
primaryColor: "#E63946",
verified: true,
whiteLabelApproved: false
},
permissions: {
scopes: ["raterspot:rate"]
}
}
Branding
────────
Display Name: Acme Insurance Agency
Logo: [uploaded] ✓
Primary Color: #E63946
Tagline: Colorado commercial specialists
White-Label Status
──────────────────
Verified: ✓
White-Label Approved: ✗ (Request Approval)
Custom Domain: — (set after approval)
When Bio-ID issues a token, here is the enrichment pipeline:
1. User authenticates (any mode — PKCE, headless, magic link)
2. Bio-ID loads user record (bioId, email, orgs, roles)
3. Bio-ID checks the OAuth client's passportConfig (from Koko cache, 60s TTL)
4. If passportConfig.branding == true:
→ Fetch vault.ins.bio/v1/branding/{orgSlug} (cached 5min)
→ Bundle into passport.branding
5. If passportConfig.scopes is non-empty:
→ Resolve scopes from Koko for this service + user's org
→ Bundle into passport.permissions.scopes
6. If vault entity exists for orgSlug:
→ Include iecHash in passport
7. Sign JWT with RS256 private key
8. Return token + publish on Passport socket
sequenceDiagram
participant User
participant Service
participant BioID
participant Koko
participant Vault
User->>Service: Login request
Service->>BioID: OAuth / Embed API
BioID->>BioID: Load user record
BioID->>Koko: Get passportConfig for service
Koko-->>BioID: { branding: true, scopes: [...] }
BioID->>Vault: GET /v1/branding/{orgSlug}
Vault-->>BioID: { logoUrl, primaryColor, ... }
BioID->>BioID: Assemble Passport
BioID->>BioID: Sign RS256 JWT
BioID-->>Service: Token (contains full Passport)
BioID-->>User: Passport pushed on socket
The 7,577 agencies seeded in vault have thePolicySpot branding data in RaterSpot. A migration script maps:
| RaterSpot field | Vault branding field |
|---|---|
thePolicySpot.color | primaryColor |
thePolicySpot.secondaryColor | secondaryColor |
thePolicySpot.logoBlack | logoUrl |
thePolicySpot.logoWhite | logoMarkUrl (inverted logo = mark variant) |
thePolicySpot.customText | tagline |
thePolicySpot.subdomain | (used to derive orgSlug if not already set) |
thePolicySpot.domain | customDomain (if verified) |
emailPortalBranding.logo | (fallback for logoUrl if thePolicySpot logo is missing) |
branding.copywriteText | (ignored — auto-generated from displayName) |
This migration gives Wave 3A (registry-bio carrier + program pages) real branding data to render from day one.
profile.branding to entity schema (Mongoose)PATCH /v1/entities/:iecHash/branding — update branding fields (2 gas, owner/admin only)GET /v1/entities/:iecHash/branding — public, 0 gasGET /v1/branding/:orgSlug — convenience route, resolves slug → iecHash → brandingverified and whiteLabelApproved fieldsthePolicySpot into profile.brandingvault.getBranding(orgSlug) — wraps GET route with defaults filled invault.getBrandingByHash(iecHash) — same, by hashvault.updateBranding(iecHash, fields) — wraps PATCHbranding: true: fetch vault branding, bundle into JWTscopes non-empty: resolve from Koko, bundle into JWTpassportConfig per service (branding, scopes, headless, allowedOrigins)GET /services/:name/passport-config (Bio-ID calls this at mint time)spec.auth.passport from catalog-info.yamlbio.embed.signup({ ..., orgSlug, inviteToken }) — headless signup with org/invitebio.embed.login() — headless loginbio.embed.createMagicLink() — generate magic token without sending emailbio.embed.verify() — exchange magic token for sessionbio.embed.refresh() — token rotationPOST /api/v2/orgs/:orgSlug/invites — create invite (email, role, modules)GET /api/v2/invites/:token — resolve invite (returns org, role, modules)DELETE /api/v2/orgs/:orgSlug/invites/:id — revoke inviteprofile.brandingspec.auth.passport is the only config; Builder + Koko + Bio-ID handle the restreq.user.branding from the Passport insteadinternalDependencies: bio-id for OAuth — use spec.auth: mode: ssohideEcoBranding: true without whiteLabelApproved — Bio-ID ignores itbio.embed.signup({ orgSlug }) without org:manage scope — returns 403passport.branding: true in catalog-info.yaml — Passport will carry identity only, no brandingLast updated: March 6, 2026