0001 — Electric-proxy auth verification posture
Status: candidate (decided the fast path — local ES256/JWKS verify; revocation follow-ups parked here)
Opened: 2026-07-07 · Area: api / auth (apps/api/src/middleware/auth.ts)
Reopen trigger: a revocation-latency incident in a real environment (a signed-out / disabled user
still transacting before token expiry), tokens gaining a materially longer lifetime, or the api
moving to a multi-tenant / higher-trust posture where “revocation invisible until expiry” is no
longer acceptable.
Decision taken (option 1 — local ES256/JWKS verification)
Section titled “Decision taken (option 1 — local ES256/JWKS verification)”The api now verifies bearer tokens locally against the Supabase auth JWKS instead of doing a network round-trip to GoTrue on every request:
createRemoteJWKSet(jose) against{auth base}/auth/v1/.well-known/jwks.json— the same internal in-cluster base-url resolution the/auth/v1/userendpoint used. jose owns key caching and refetch-on-unknown-kid; we add no key cache of our own.jwtVerify(token, jwks, { audience: "authenticated", algorithms: ["ES256"] }). Tokens carry noissclaim, so issuer is not enforced;audandalgare.- Claims map exactly as the old
/userpayload did:id ← sub,personId ← app_metadata.person_id(required — deny when missing/empty, the “session predates its person” rule),email ← email. - Fallback preserved: an infrastructure failure (JWKS endpoint unreachable / timed out /
returned a non-200 or unparseable body) falls back to the original GoTrue
/auth/v1/userround-trip for that request, so an auth-service JWKS outage degrades to the prior behavior rather than rejecting valid tokens. Token-level failures (bad signature, expired, wrongaud, unknownkid, missingperson_id) do not fall back — they deny, exactly as before.
Measurement that motivated this
Section titled “Measurement that motivated this”verifySupabaseToken fetched {supabaseUrl}/auth/v1/user per request; GoTrue serialises token
verification. Measured on the dev cluster:
- solo
/auth/v1/user: 130–370ms; - 40 parallel requests with the same token: median 833ms (GoTrue serialises at ~25ms per verification);
- the pgxsinkit boot sweep fires ~40 concurrent shape requests with one token, so the whole sweep convoyed to ~1s/request — the dominant cost of a cold boot.
Local verification removes the round-trip from the hot path entirely.
Accepted trade
Section titled “Accepted trade”Local verification cannot see a revocation (sign-out, disabled user, rotated session) until the
token itself expires — a locally-valid signature is honoured for the token’s whole lifetime. Dev
access tokens live 24h, so a revoked session can keep transacting for up to that long. The same
staleness applies to role-grant changes: app_metadata (authorization grants incl.
platform/organization admin) is frozen at token mint, so a revoked admin keeps REST-gate powers and a
newly-granted teacher sees nothing until token refresh. (The sync rail’s row filters were already
token-sourced and therefore already carried this staleness — this change makes the REST gates
consistent with it rather than fresher.) Accepted for the current single-tenant dev/prod posture; the
reopen trigger above is when it stops being acceptable.
Follow-ups to investigate (parked)
Section titled “Follow-ups to investigate (parked)”- Option 2 — per-token GoTrue-verification cache with a short TTL. Keep verifying against GoTrue
but memoise
(token → principal)for a small window (e.g. 5–30s) so a boot sweep collapses to one verification per token instead of ~40. Bounds revocation latency to the TTL and keeps GoTrue as the source of truth, at the cost of a cache and still paying one round-trip per TTL window per token. Cheapest to reason about for revocation; does not eliminate the round-trip. - Option 3 — hybrid: local verify fast path + a bounded revocation layer. Verify locally (option
1’s speed) but gate it with a cheap revocation check that is not a per-request GoTrue call:
either a TTL’d session-status probe (verify locally, and at most once per TTL confirm the session
is still live) or a sign-out / session-revocation event feed that invalidates cached principals
on demand (e.g. a Postgres
NOTIFY/ a revocation shape the api subscribes to). Gets both the latency win and bounded revocation, at the cost of building and operating the invalidation channel.