Auth Strategy
Principals
Three kinds of caller ever hit the API: merchant reps (dashboard/API-key holders), marketplace members (Seller — TBBN Exchange login), and internal services (gateway-issued internal principal). Each has its own credential type — never share a token format across principal kinds.
Merchant reps
- Password or email-OTP login during the temporary-account/onboarding phase.
- JWT access token, 15-minute expiry,
sub=users.id,merchant_id,roleclaims. - Refresh token, 30-day expiry, stored hashed in
sessions, rotated on every use (rotate-on-use), with reuse detection: presenting an already-rotated refresh token revokes the entire session chain immediately (signals token theft). - API keys (
sk_live_.../sk_sandbox_...) are a separate bearer scheme, not a JWT — long lived, scoped, revocable independently of any human session. Stored askey_hash(never plaintext after creation-time display), looked up bykey_prefixfor speed.
Marketplace members (Seller)
- No password, ever. OTP (email, then optionally phone) or magic link only.
- JWT access token, 15-minute expiry,
sub=users.id,seller_idclaim, nomerchant_id(a marketplace member isn't scoped to one merchant). - Every
Sellerhas aUserfrom creation (Phase 22 — seedocs/business/conflict-resolution-log.mditem 18): direct self-registration (POST /v1/auth/member/register) or a merchant's firstverify()call. There is no federation-threshold gate on login anymore — seeseller-identity-serviceREADME for what the 2+ linked-merchant-accounts "federated" label now means (a derived display fact, not an access gate).
OTP mechanics
6-digit numeric code, hashed at rest (otp_challenges.code_hash), 10-minute expiry, max 5
requests/hour per (target_type, target_value) pair, max 5 verify attempts per challenge before
it's invalidated and a new one must be requested. Email OTP always precedes phone OTP in any flow
requiring both (seller identity linking) — never issue both simultaneously.
Internal service-to-service
api-gateway validates the inbound merchant API key or seller JWT exactly once, then forwards a
short-lived (60s) internal-principal JWT signed with a repo-internal secret
(INTERNAL_SERVICE_SECRET, distinct per environment) to downstream services. Downstream services
trust this header and do not re-validate the original credential — this keeps auth logic in one
place (auth-service + api-gateway) rather than duplicated across every service.
Secrets
Every secret (JWT_SECRET, INTERNAL_SERVICE_SECRET, Stripe keys, OTP provider credentials)
comes from environment variables validated at boot by packages/config (zod schema) — a service
that's missing a required secret fails to start, not fails on first request.