Multi-Strategy Authentication in purr-api
smo1 advanced 9 min read
ELI5
purr-api checks three kinds of ID at its door, in a fixed order: an API key, then a signed token in the Authorization header, then an old-style session cookie. The dashboard always uses the second kind: it signs a five-minute pass saying “this is user X” with a secret only it and purr-api share. The cookie check is left over from an older login system and no longer has anyone using it in production.
Technical Deep Dive
Source: purr-api internal/middleware/auth.go:23-35, 96-160, 184-251, 267-330, 341-375, 429-530, 603-640 (origin/main bb6cfc2); internal/config/config.go:122-127, 190-215; cmd/server/main.go:416-427; smo1-io docs/decisions/2026-10-09-meow-purr-bff-service-jwt.md (status draft, sign-off line flight@arno.host 2026-10-15), read 2026-10-25.
RequireAuth order
flowchart TD A[Request] --> K{"X-API-Key header ?"} K -->|Yes| KV[validateAPIKey] KV -->|valid| OK[pass: resolveTier from users row] KV -->|invalid| U1[401 Invalid API key] K -->|No| B{"Authorization: Bearer ?"} B -->|Yes| SJ[validateServiceJWT HS256] SJ -->|valid| OK SJ -->|"invalid, iss = meow-web"| U2[401] SJ -->|"invalid, other iss"| LJ[validateJWT legacy JWT_SECRET] LJ -->|valid| OK LJ -->|invalid| U2 B -->|No| C{"cookie __Secure-smo1.session_token or smo1.session_token ?"} C -->|Yes| SV[validateSessionToken: meow-web then BetterAuth URL] SV -->|valid| OK C -->|No| U3[401 Authentication required] SV -->|invalid| U3| # | Strategy | Verification | Who uses it in production |
|---|---|---|---|
| 1 | X-API-Key | equals INTERNAL_API_KEY, or SHA-256 hash lookup in api_keys | developer API keys; internal callers |
| 2a | Bearer service JWT | HS256 with SERVICE_JWT_SECRET; iss=meow-web, aud=purr-api, exp and iat required, 30 s leeway, lifetime at most 300 s, sub and email non-empty (auth.go:221-224, 267-305) | meow-web BFF, every dashboard call (smo1-027) |
| 2b | Bearer legacy JWT | HMAC with JWT_SECRET, any sub (auth.go:341-375) | no known caller; still required to boot in lion (config.go:212-214) |
| 3 | Session cookie | forwards to meow-web /api/auth/session, then to BETTER_AUTH_URL /api/auth/get-session (auth.go:429-530) | none known: meow-web sets smo1_session, a different name, on a different host |
There is no Airlock JWKS verification in purr-api. AirlockIssuer and AirlockJWKSURL are read from env and copied into AuthConfig (main.go:424-425) but no code path reads them. Airlock tokens are verified only by meow-web (smo1-005).
Service-JWT policy details
- A token whose unverified payload says
iss: meow-weband fails validation is rejected outright (auth.go:238-251). It never falls through to the weaker legacy validator, so the service claim policy cannot be bypassed. config.go:195-207 refuses to bootcatorlionwithout aSERVICE_JWT_SECRETof at least 32 bytes, and refuses any environment where it equalsJWT_SECRET.- On success the user row is upserted with
EnsureExists(tierfree, level 1). An email already owned by another id returns409 account_conflictand is never auto-linked.
Tier comes from the database, always
pass() calls resolveTier, which reads users.tier and overwrites whatever tier a token claimed (auth.go:603-626). That is the column TOLL sell-through turns into a cache (smo1-026). Then passAuthenticated refuses the x402-anon principal so nobody can act as the owner of paid anonymous links (smo1-025).
OptionalAuth
Mounted on all of /api (router.go:119). It tries the same sources, sets context when one works, and never answers 401. RequireAuth runs on the protected group.
Legacy branches still in code
BetterAuthURLfallback invalidateSessionToken.BETTER_AUTH_URLstill defaults tohttp://localhost:3000(config.go:122). The BetterAuth tables were dropped by migration 000014 andBETTER_AUTH_SECRETstopped being required in #75, but removing the URL fallback is a behaviour change left for its own PR (smo1-io session ledger, 2026-10-22).- The
smo1.session_tokencookie names, which also appear in the x402hasCredentialtest (paid_link_handler.go:89-99).
Key Terms
- Service JWT — a five-minute HS256 token meow-web mints per proxied request
- resolveTier — sets the request’s tier from
users.tier, ignoring token claims - errAccountConflict — the 409 path when a new
subarrives with an email another user owns - INTERNAL_API_KEY — a static key compared directly in
validateAPIKey - OptionalAuth — context-only auth on public API routes
Q&A
Q: List the authentication strategies RequireAuth tries, in order.
A: X-API-Key, then Authorization: Bearer (service JWT first, legacy JWT_SECRET JWT second, unless the token claims to be from meow-web), then the __Secure-smo1.session_token or smo1.session_token cookie validated against meow-web and then BetterAuth. A present-but-invalid API key or Bearer token is a 401 immediately; later strategies are not tried.
Q: What is the difference between RequireAuth and OptionalAuth? A: RequireAuth aborts with 401 (or 409 or 500 on upsert failure) when no strategy succeeds. OptionalAuth sets user context if it can and always continues.
Q: Which claims does purr-api require on a meow-web service JWT, and why is a failed one never retried on the legacy path?
A: HS256, iss=meow-web, aud=purr-api, iat, exp, a lifetime of at most 300 s, and non-empty sub and email. A token claiming that issuer is rejected rather than retried so a weaker legacy check cannot accept what the service policy refused.
Q: Which branches are legacy, and why are they still in the code?
A: The legacy JWT_SECRET Bearer path and the session-cookie path with its BetterAuth fallback. JWT_SECRET is still a lion boot requirement, and the BetterAuth URL fallback is kept until a dedicated behaviour-change PR removes it.
Examples
A dashboard request arrives at purr-api with Authorization: Bearer <service JWT> and no cookie. The service-JWT branch verifies it, upserts the user if new, resolveTier reads business from the users row, and the tier rate limiter allows 6,000 requests per hour. A script sending X-API-Key: <revoked key> gets 401 Invalid API key without the Bearer header ever being read.
neighbors on the map
- Link Protection implementing password-protected links
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users
- x402 Paid Anonymous Links: One Payment Buys One Link working on POST /api/links or anything in internal/x402