Skip to content

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/user endpoint 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 no iss claim, so issuer is not enforced; aud and alg are.
  • Claims map exactly as the old /user payload 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/user round-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, wrong aud, unknown kid, missing person_id) do not fall back — they deny, exactly as before.

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.

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.

  • 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.