CRUMB a card from devarno-cloud

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
#StrategyVerificationWho uses it in production
1X-API-Keyequals INTERNAL_API_KEY, or SHA-256 hash lookup in api_keysdeveloper API keys; internal callers
2aBearer service JWTHS256 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)
2bBearer legacy JWTHMAC with JWT_SECRET, any sub (auth.go:341-375)no known caller; still required to boot in lion (config.go:212-214)
3Session cookieforwards 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-web and 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 boot cat or lion without a SERVICE_JWT_SECRET of at least 32 bytes, and refuses any environment where it equals JWT_SECRET.
  • On success the user row is upserted with EnsureExists (tier free, level 1). An email already owned by another id returns 409 account_conflict and 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

  • BetterAuthURL fallback in validateSessionToken. BETTER_AUTH_URL still defaults to http://localhost:3000 (config.go:122). The BetterAuth tables were dropped by migration 000014 and BETTER_AUTH_SECRET stopped 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_token cookie names, which also appear in the x402 hasCredential test (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 sub arrives 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