Skip to main content

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:

  1. Parses Authorization: Bearer <token> header.
  2. Verifies the session JWT with SESSION_SECRET (HS256).
  3. On success, sets c.set("userId", claims.sub) on the Hono context.
  4. On any failure (missing header, expired, invalid signature) returns 401.

POST /auth/session handles the exchange:

  1. 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>.
  2. Extracts stable subject (sub) and email if provided.
  3. Upserts a users row by unique (auth_provider, auth_provider_user_id).
  4. 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 (:secret matched against INFINIA_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_events is unique on (provider, external_event_id); replayed events are silently 200'd. Since Infinia doesn't emit a unique id, we synthesise one from sha256(kind + ":" + providerRef + ":" + updatedAt).
  • Retries on non-2xx (Infinia's stated behaviour). Our handler is idempotent so retries are safe.

Secrets summary

SecretSet viaPurpose
SESSION_SECRETwrangler secret putHS256 key for signing session JWTs
PII_ENCRYPTION_KEYwrangler secret putKey used with pgcrypto to encrypt sensitive columns at rest (see privacy)
INFINIA_API_USERNAMEwrangler secret putHTTP Basic username
INFINIA_API_PASSWORDwrangler secret putHTTP Basic password
INFINIA_WEBHOOK_SECRETwrangler secret putPath 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.tomlSandbox vs production base URL