Authentication
Three trust boundaries: user → API (our session JWT), API → Infinia (HTTP Basic + company header), and Infinia → API (secret-in-path + swappable signature verification).
End-user auth (mobile → API)
Every non-webhook API route requires a session JWT signed by us. The mobile app obtains this by exchanging a native Apple or Google ID token for a session at POST /auth/session. The session is stored in expo-secure-store (Keychain / Keystore) and attached as Authorization: Bearer <token> on every request.
Verification
apps/api/src/auth/middleware.ts:
- Parses
Authorization: Bearer <token>header. - Verifies the session JWT with
SESSION_SECRET(HS256). - On success, sets
c.set("userId", claims.sub)on the Hono context. - On any failure (missing header, expired, invalid signature) returns
401.
POST /auth/session handles the exchange:
- Verifies the incoming Apple or Google ID token against the provider's JWKS.
- Apple:
https://appleid.apple.com/auth/keys;iss = https://appleid.apple.com;aud = <APPLE_BUNDLE_ID>. - Google:
https://www.googleapis.com/oauth2/v3/certs;iss = https://accounts.google.com;aud = <GOOGLE_OAUTH_CLIENT_ID>.
- Apple:
- Extracts stable subject (
sub) and email if provided. - Upserts a
usersrow by unique(auth_provider, auth_provider_user_id). - Signs a session JWT and returns it.
What's in a session JWT
Minimum viable claims:
{
"sub": "<internal users.id UUID>",
"iat": 1735689600,
"exp": 1738281600
}
sub is our internal users.id, not the provider's sub. This isolates session state from OAuth identifiers and keeps the migration door open — if we ever move a user to a different auth provider, the session JWT contract doesn't change.
Session lifetime: 30 days by default. Long-lived JWT with no refresh flow for MVP simplicity. Server-side revocation is added later (KV-backed deny-list on jti) if we need "log out everywhere".
Provider quirks worth knowing
Apple's "Hide My Email". On first Apple sign-in Apple returns a real or private-relay email — only once. Subsequent sign-ins return no email. If the user chose Hide My Email, we get a <random>@privaterelay.appleid.com address that forwards to their real inbox. Persist it on the initial POST /auth/session; we won't see it again. Real email is captured during Infinia HOSTED KYC and stored on kyc_verifications.verified_identity.
Apple's sub is app-specific. The sub we get is unique to our app (Team ID + Bundle ID). Users can't be cross-referenced across other apps that use Sign in with Apple. That's fine.
Google's email_verified. Trust only email if email_verified: true in the id_token. Untrusted email is treated as null.
Account linking
Not implemented in MVP. If a user signs in with Apple, then later with Google using the same email, they get two separate users rows. Explicit "link accounts" flow can be added later once we have data on how often it happens.
Server-side auth (API → Infinia)
Every Infinia request carries:
Authorization: Basic <base64(INFINIA_API_USERNAME:INFINIA_API_PASSWORD)>— HTTP Basic.x-company-id: <INFINIA_COMPANY_ID>— identifies which company (our tenant) the call is for.
Both credentials are Cloudflare Worker secrets set via wrangler secret put. The Infinia client (apps/api/src/providers/infinia/client.ts) attaches them to every request.
Webhook auth (Infinia → API)
Key properties:
- Path secret (
:secretmatched againstINFINIA_WEBHOOK_SECRET) is the first-line defence today. Infinia's OpenAPI doesn't publish a signature scheme yet — this is tracked as an open question. - Verification is behind a swappable strategy in
InfiniaProvider.parseWebhook— plugging in an HMAC signature later doesn't touch the handler. - Deduplication:
webhook_eventsis unique on(provider, external_event_id); replayed events are silently 200'd. Since Infinia doesn't emit a unique id, we synthesise one fromsha256(kind + ":" + providerRef + ":" + updatedAt). - Retries on non-2xx (Infinia's stated behaviour). Our handler is idempotent so retries are safe.
Secrets summary
| Secret | Set via | Purpose |
|---|---|---|
SESSION_SECRET | wrangler secret put | HS256 key for signing session JWTs |
PII_ENCRYPTION_KEY | wrangler secret put | Key used with pgcrypto to encrypt sensitive columns at rest (see privacy) |
INFINIA_API_USERNAME | wrangler secret put | HTTP Basic username |
INFINIA_API_PASSWORD | wrangler secret put | HTTP Basic password |
INFINIA_WEBHOOK_SECRET | wrangler secret put | Path secret for /webhooks/infinia/:secret |
APPLE_BUNDLE_ID | [vars] in wrangler.toml (public) | Expected aud on Apple id_tokens |
GOOGLE_OAUTH_CLIENT_ID | [vars] in wrangler.toml (public) | Expected aud on Google id_tokens |
INFINIA_COMPANY_ID | [vars] in wrangler.toml (public) | x-company-id header |
INFINIA_BASE_URL | [vars] in wrangler.toml | Sandbox vs production base URL |