Airlock JWT Handoff & the smo1_session Cookie
smo1 advanced 7 min read
ELI5
app.smo1.io is not under devarno.cloud, so it cannot read Airlock’s login cookie. Instead the dashboard sends you to Airlock, Airlock signs you in and sends you back with a short-lived signed note saying who you are. The dashboard checks the signature against Airlock’s public key, then writes its own wristband cookie, good for eight hours. purr-api never sees that cookie: the dashboard turns it into a fresh five-minute pass for each API call.
Technical Deep Dive
Source: meow-web README.md:61-69, src/lib/airlock.ts:30-65, 82-114, 132-196, 205-210, src/app/api/auth/login/route.ts:13-28, src/app/api/auth/callback/route.ts:15-61, src/middleware.ts (origin/main e5d808c), read 2026-10-25. Airlock-side registration (handoff_consumers migration 0029, OIDC client retired in airlock#112) is as stated in meow-web README.md:67; not re-read in airlock for this card.
Login sequence
sequenceDiagram autonumber actor U as User participant M as meow-web participant A as Airlock U->>M: GET /dashboard without smo1_session M-->>U: 302 to /api/auth/login?callbackUrl=/dashboard U->>M: GET /api/auth/login M-->>U: 302 to AIRLOCK_HANDOFF_URL?return=app origin /api/auth/callback?next=/dashboard U->>A: handoff request, sign in if needed A-->>U: 302 to /api/auth/callback?token=JWT and next U->>M: GET /api/auth/callback M->>A: GET JWKS, cached 5 minutes M->>M: jwtVerify EdDSA, iss = AIRLOCK_ISSUER_URL, aud = NEXT_PUBLIC_APP_URL M-->>U: Set-Cookie smo1_session, 302 to nextPoints that the earlier version of this card got wrong:
- No PKCE, no OIDC client, no code exchange.
buildHandoffRedirectsends only areturnparameter (airlock.ts:205-210). Airlock decides whether to hand off by checking the return origin against its registeredhandoff_consumers;https://app.smo1.iois registered there. - No user sync call. meow-web does not call purr-api at login. The user row is created on the first proxied API call, when purr-api validates the service JWT and runs
EnsureExists(smo1-004). - The key is picked by type, not
kid.verifyHandoffTokentakes the first JWKS entry withkty=OKP, crv=Ed25519(airlock.ts:91). A rotation that publishes two Ed25519 keys at once would verify against whichever comes first.
The smo1_session cookie
Format base64url(payload).base64url(HMAC-SHA256(SESSION_SECRET, payloadB64)) with payload {userId, email, role, iat, exp} (airlock.ts:132-155).
| Attribute | Value | Source |
|---|---|---|
| Name | smo1_session | callback/route.ts:13 |
| Lifetime | maxAge: 28800 (8 h) and exp = iat + 28800 | callback/route.ts:50, airlock.ts:134 |
| Flags | httpOnly, sameSite: lax, path: /, secure only when NODE_ENV=production | callback/route.ts:45-51 |
next handling | only same-origin paths (/ but not //) are honoured | callback/route.ts:41 |
verifyLocalSession checks the HMAC with a plain !== string compare, then exp (airlock.ts:160-196). The edge middleware.ts only checks that the cookie exists; the real verification happens in route handlers such as the BFF proxy (smo1-027).
Logout
/api/auth/logout clears the cookie and, for browser navigations, redirects to NEXT_PUBLIC_LANDING_URL, defaulting to https://smo1.io since meow-web #36 (2026-10-22; it previously defaulted to localhost because the variable is unset on Vercel prod).
Key Terms
- JWT handoff — Airlock’s cross-apex pattern: a signed, short-lived token in a redirect, then a local session
- handoff_consumers — Airlock’s registry of origins allowed to receive a handoff token
- EdDSA / Ed25519 — the signature algorithm on Airlock handoff tokens
- SESSION_SECRET — meow-web’s HMAC key for
smo1_session; never shared with purr-api - SERVICE_JWT_SECRET — the separate key meow-web shares with purr-api (smo1-027)
Q&A
Q: What cryptographic algorithm does Airlock use for its JWT?
A: Ed25519 (JOSE EdDSA). meow-web imports the OKP/Ed25519 key from Airlock’s JWKS and verifies issuer and audience with jose.jwtVerify.
Q: How long does a smo1_session cookie last?
A: Eight hours: the cookie’s maxAge is 28,800 s and the signed payload carries the same exp.
Q: What happens if the Airlock JWKS endpoint is unreachable?
A: Within five minutes of the last fetch nothing changes, because the keys are cached in module memory. After that fetchJWKS throws, the callback answers 401 Invalid or expired token, and new sign-ins fail. Existing smo1_session cookies keep working because they never touch Airlock.
Q: Why is there no OIDC client or PKCE in this flow?
A: The handoff pattern returns a signed token directly to a registered origin, so there is no authorization code to protect. smo1’s OIDC client was retired in airlock#112; AIRLOCK_CLIENT_ID and AIRLOCK_CLIENT_SECRET are unused.
Examples
A support agent opens app.smo1.io/settings/billing in a clean window. The middleware sees no cookie and redirects to /api/auth/login?callbackUrl=/settings/billing, Airlock signs them in, the callback verifies the token and lands them back on billing with an eight-hour cookie. Their first page load calls /api/purr/billing, which is where purr-api first learns who they are.
neighbors on the map
- meow-web BFF: How the Dashboard Calls purr-api with a Service JWT a dashboard call to purr-api returns 401, 500 server_misconfigured or 502 bad_gateway
- Link Protection implementing password-protected links
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users