Passport, Branding & Auth Modes — Full Architecture

What This Document Covers

This is the definitive spec for how identity, branding, and permissions work across the Tawa ecosystem. It defines:

  • The Passport — the enriched identity object that travels with every user
  • Three auth modes — Branded, White-Label, and Headless
  • Vault branding — the profile.branding schema on vault entities
  • Onboarding and invite flows — how users join orgs across all three modes
  • catalog-info.yaml declaration — how devs wire it up
  • Builder → Koko → Bio-ID pipeline — how the platform auto-configures everything

IMPORTANT: 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 Object

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.

Full Shape

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[]
}

What Makes This Different From a JWT

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.

How a Vibecoder Uses It

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

Three Auth Modes

Every service picks one mode. The mode determines the user experience. All three produce the same Passport object.

Mode 1: Branded ("Sign in with Bio-ID")

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

Mode 2: White-Label Hosted

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 vectorHow it's suppressed
URL barCustom domain via CNAME
"Powered by InsurEco" footerhideEcoBranding: true in vault branding
Magic link email senderemailFromName set to org's brand
JWT iss claimCustom Issuer mode (optional, advanced)
Error messagesGeneric — 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.

Mode 3: Fully Headless

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 vectorHow it's suppressed
Redirect URLNo redirect — server-to-server only
Network tabYour backend proxies; Bio-ID URL never in browser
Token issValidated server-side; user never sees it
EmailsYou send them yourself (your SMTP, your templates)
Error messagesYou 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 })

Choosing a Mode

QuestionBrandedWhite-LabelHeadless
"Bio-ID" visible to users?YesNoNo
InsurEco brand visible?YesOptional (footer toggle)No
User redirected to hosted page?YesYes (custom domain)No
Dev builds auth UI?NoNo (CSS customization only)Yes — fully custom
Custom domain required?NoYes (CNAME)No
Auth emails sent byBio-IDBio-ID (branded as org)You (your SMTP)

Vault Branding Schema

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.

Schema

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
}

Two Tiers

TierWho gets itWhat they can set
ClaimedAny entity after Bio-ID claim flowdisplayName, tagline, logoUrl, logoMarkUrl, colors, website, phone, email
White-Label ApprovedPlatform approval after verificationcustomDomain, hideEcoBranding, emailFromName, customCss

Basic branding (logo, colors) is self-serve. White-label (custom domain, suppressing InsurEco) requires platform approval.

Vault API

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
}

SDK

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...')

Onboarding Flows

Flow 1: New User, No Invite

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.

Flow 2: Invited User (Standard)

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

Flow 3: Service-Initiated Signup (with OrgSlug)

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.signup with orgSlug requires the service's OAuth client to have org:manage scope for that org. This is auto-granted when the service is deployed by the org.

Flow 4: Org Admin Defines User + Role

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)

Org Slug Defaulting Rules

ContextOrg slug source
Self-signup (no invite)Auto-generated from name: jane-doe
Invite link acceptedFrom invite record → inviting org's slug
Headless signup with orgSlug paramExplicitly provided by the calling service
Headless signup without orgSlugAuto-generated (same as self-signup)
User already exists + inviteOrg added to orgs[], becomes primary if user accepts

catalog-info.yaml Declaration

Minimal (Branded, No Branding Bundle)

spec:
  auth:
    mode: sso

This gives you standard OAuth. The Passport carries identity only. No branding in the token.

With Passport Branding (White-Label or Branded + Branding)

spec:
  auth:
    mode: sso
    passport:
      branding: true             # bundle org's vault branding into Passport
      scopes: [raterspot:rate]   # service needs these scopes resolved in Passport

Full Headless

spec:
  auth:
    mode: sso
    passport:
      branding: true
      headless: true
      allowedOrigins:
        - https://myapp.tawa.pro
        - https://staging.myapp.tawa.pro

What the Builder Does

On deploy, the builder reads spec.auth.passport and:

  1. Creates/updates the Bio-ID OAuth client with:
    • passportConfig.branding: true → Bio-ID fetches vault branding at mint time
    • passportConfig.scopes: [...] → Bio-ID resolves from Koko and bundles
    • headlessEnabled: true → enables the /api/embed/* endpoints for this client
    • allowedEmbedOrigins: [...] → CORS for browser SDK callers
  2. Registers the passport config with Koko so it can resolve scopes for this service
  3. Injects BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL as always

What Koko Stores

{
  "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.


Console Visibility

Service Detail Page

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"]
  }
}

Org Settings Page

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)

Bio-ID Mint Pipeline

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

Migration: RaterSpot Branding → Vault

The 7,577 agencies seeded in vault have thePolicySpot branding data in RaterSpot. A migration script maps:

RaterSpot fieldVault branding field
thePolicySpot.colorprimaryColor
thePolicySpot.secondaryColorsecondaryColor
thePolicySpot.logoBlacklogoUrl
thePolicySpot.logoWhitelogoMarkUrl (inverted logo = mark variant)
thePolicySpot.customTexttagline
thePolicySpot.subdomain(used to derive orgSlug if not already set)
thePolicySpot.domaincustomDomain (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.


What Needs to Be Built

iec-bio-vault

  • Add 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 gas
  • GET /v1/branding/:orgSlug — convenience route, resolves slug → iecHash → branding
  • Only platform service can set verified and whiteLabelApproved fields
  • Migration script: seed RaterSpot thePolicySpot into profile.branding

@insureco/bio-vault SDK

  • vault.getBranding(orgSlug) — wraps GET route with defaults filled in
  • vault.getBrandingByHash(iecHash) — same, by hash
  • vault.updateBranding(iecHash, fields) — wraps PATCH

Bio-ID

  • On token mint: check OAuth client passportConfig (from Koko, cached 60s)
  • If branding: true: fetch vault branding, bundle into JWT
  • If scopes non-empty: resolve from Koko, bundle into JWT
  • Store passportConfig on OAuth client model
  • Passport socket: push enriched Passport (not just identity)

Koko

  • Store passportConfig per service (branding, scopes, headless, allowedOrigins)
  • Expose via GET /services/:name/passport-config (Bio-ID calls this at mint time)

iec-builder

  • Read spec.auth.passport from catalog-info.yaml
  • On deploy: POST passport config to Koko
  • On deploy: update OAuth client in Bio-ID with passportConfig + headless + allowedOrigins

Console (tawa-web)

  • Service detail page: Passport Configuration panel
  • Service detail page: Passport Preview (live example with current org)
  • Org settings: Branding editor (logo upload, colors, tagline)
  • Org settings: White-Label request/status

@insureco/bio SDK

  • bio.embed.signup({ ..., orgSlug, inviteToken }) — headless signup with org/invite
  • bio.embed.login() — headless login
  • bio.embed.createMagicLink() — generate magic token without sending email
  • bio.embed.verify() — exchange magic token for session
  • bio.embed.refresh() — token rotation

Bio-ID Invite API

  • POST /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 invite
  • Invite acceptance: on signup/login with inviteToken, attach user to org with specified role

Key Rules

  1. The Passport is the enriched JWT — branding and permissions are bundled at mint time, not fetched at request time
  2. Vault is the master branding record — all surfaces pull from profile.branding
  3. Three modes, one Passport — Branded, White-Label, and Headless all produce the same object
  4. Declare in YAML, auto-wire on deployspec.auth.passport is the only config; Builder + Koko + Bio-ID handle the rest
  5. Basic branding is self-serve — logo and colors after claiming. White-label requires platform approval
  6. Headless is first-class — not an afterthought. The embed API is the primary integration path for ISVs
  7. Bio-ID can be completely invisible — no URL, no email, no error message reveals it in headless mode

Common Mistakes

  • Reading branding from RaterSpot agency doc instead of vault — vault is the master record
  • Calling vault at request time for branding — read req.user.branding from the Passport instead
  • Using internalDependencies: bio-id for OAuth — use spec.auth: mode: sso
  • Exposing Bio-ID URLs in headless mode — always proxy through your backend
  • Setting hideEcoBranding: true without whiteLabelApproved — Bio-ID ignores it
  • Calling bio.embed.signup({ orgSlug }) without org:manage scope — returns 403
  • Hardcoding branding in a service instead of reading from Passport — branding changes won't propagate
  • Not declaring passport.branding: true in catalog-info.yaml — Passport will carry identity only, no branding

Last updated: March 6, 2026