DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We stopped asking Supabase who you are on every request, and the only thing that got worse is revocation

Munchable's marketing site and its 373 generated answer pages are statically rendered, and they still run a function on every request, because the header has to know whether you are signed in. That function used to ask Supabase Auth, in us-east-2, over the network. Now it checks a signature locally.

The whole change is one module, apps/web/lib/auth/session-claims.ts. The reason it is worth writing up is not the swap, which is two lines. It is the three things the swap drags in: a key document you now have to cache, a rotation you now have to notice, and a revocation window you now have to accept.

What the old call actually was

supabase.auth.getUser() reads like an accessor. It is a request. The Supabase reference says so in the entry for its replacement:

Extracts the JWT claims present in the access token by first verifying the JWT against the server's JSON Web Key Set endpoint /.well-known/jwks.json which is often cached, resulting in significantly faster responses. Prefer this method over GoTrueClient.getUser which always sends a request to the Auth server for each JWT.

Three words in there carry the cost: always sends a request. Not "when the cache misses". Every call, every time.

Our proxy runs on every request that is not a static asset or an image, which means every HTML page. Behind it, eighteen API routes resolve a user before doing anything. A page request that then fires an API call paid for two of those round trips before any work started. The comment at the top of the module records what we measured from Europe: 150 to 700ms.

The replacement

Supabase's JWT signing keys page lays out the two systems: the legacy shared JWT secret, and asymmetric keys based on public-key cryptography. Their own comparison table is unusually direct about the difference:

Legacy JWT secret Asymmetric signing key
Performance JWT validation is done by the Auth server Validation is fast and does not involve the Auth server
Reliability Auth server is in the hot path of your application Validation is local

Rotate the project onto an ES256 key and the public half is published at /auth/v1/.well-known/jwks.json. auth.getClaims() verifies against it with WebCrypto, locally, in microseconds.

The catch is that getClaims will fetch that document itself if you do not hand it one. The docs spell out when that bites:

A network request is sent to your project's JWT signing key discovery endpoint, which is cached locally. If your environment is ephemeral, such as a Lambda function that is destroyed after every request, a network request will be sent for each new invocation.

Serverless functions are exactly that environment. So the module's job is to own the JWKS document for the lifetime of the process and hand it over on every call.

Owning the key document

const JWKS_TTL_MS = 10 * 60_000;
/** After a failed fetch, do not retry for this long: one slow request, not every request. */
const JWKS_RETRY_MS = 30_000;
const JWKS_FETCH_TIMEOUT_MS = 3_000;
let jwksCache: { at: number; jwks: Jwks } | null = null;
let jwksFailedAt = 0;
let jwksInflight: Promise<Jwks | null> | null = null;

async function cachedJwks(): Promise<Jwks | undefined> {
  const now = Date.now();
  if (jwksCache && now - jwksCache.at < JWKS_TTL_MS) return jwksCache.jwks;
  if (now - jwksFailedAt < JWKS_RETRY_MS) return jwksCache?.jwks;
  jwksInflight ??= loadJwks().finally(() => {
    jwksInflight = null;
  });
  const jwks = await jwksInflight;
  if (jwks) jwksCache = { at: Date.now(), jwks };
  else jwksFailedAt = Date.now();
  return jwks ?? jwksCache?.jwks;
}
Enter fullscreen mode Exit fullscreen mode

Four decisions in fourteen lines, and each of them is about a failure rather than about the happy path.

jwksInflight is the one most easily left out. A cold instance taking ten concurrent requests would otherwise start ten fetches of the same document. The ??= means the first caller starts the fetch and the other nine await the same promise. The .finally clears it so the next TTL expiry can start a new one rather than resolving instantly against a stale settled promise.

jwksFailedAt is a negative cache, and it returns the stale document. If Supabase is unreachable, the choice is not between fresh keys and stale keys, it is between stale keys and no authentication at all. Keys rotate on the order of months. A document ten minutes past its TTL verifies every live token perfectly well. So a failure parks retries for thirty seconds and keeps serving what we had, which turns an outage into a slightly stale cache rather than into one three-second timeout per request.

The timeout is three seconds, not the default. This runs inside a request that a person is waiting on. An unbounded fetch to a key endpoint is an unbounded page load.

The TTL is ten minutes rather than an hour because it is the ceiling on how long a rotation can go unnoticed, and the floor is already handled by the next part.

Noticing a rotation without polling for one

Ten minutes of staleness after a key rotation would mean ten minutes of rejected sessions. The token itself tells you, if you read the header:

// A kid we have never seen means the keys rotated. Drop the cache so the next
// call fetches the new document, instead of getClaims paying its own fallback
// fetch on every call until the TTL runs out.
const kid = data.header.kid;
if (!options.jwks && jwks && kid && !jwks.keys.some((k) => k.kid === kid)) jwksCache = null;
Enter fullscreen mode Exit fullscreen mode

A JWT header carries the id of the key that signed it. If that kid is not in the document we hold, our document is out of date, full stop. There is no guessing and no clock involved. Dropping the cache means the next request refetches, and the window is one request rather than the remainder of the TTL.

The alternative is not "it breaks". The alternative is that getClaims quietly falls back to its own fetch on every single call for up to ten minutes, which is the exact behaviour the module exists to prevent, arriving silently at the worst possible moment.

A log line that tells you the fast path is off

The whole optimisation is contingent on the project using an asymmetric key. If it is still on the legacy HS256 secret, getClaims sends a request to the Auth server just like getUser does, and nothing fails. You simply do not get the speed-up, and you have no way of knowing.

console.info(
  alg.startsWith('HS')
    ? `[auth] sessions are signed with ${alg}, so each check is a round trip to Supabase. Rotate to an ES256 key (dashboard: Auth, JWT keys) to verify locally.`
    : `[auth] verifying sessions locally (${alg}); no Supabase round trip per request.`,
);
Enter fullscreen mode Exit fullscreen mode

Dev only, once per process, guarded by a module-level reportedAlg flag. It reads the alg off the verified header rather than off configuration, so it is reporting what actually happened rather than what someone intended.

This is the part I would most encourage copying. A performance change that degrades into "correct but slow" with no signal is a change that silently stops being true the next time somebody creates a project from a template.

The thing that genuinely got worse

Local verification means no server is consulted, which means a revoked session keeps working until the token expires. Supabase access tokens live an hour at most. That is not a subtlety to bury, so it is in the file header:

The trade-off is the one Supabase documents: a token stays valid until it expires (an hour at most) even if the session was revoked meanwhile. Nothing behind these gates spends money or writes on a revoked session's behalf in a way the hourly bound does not cover, and the destructive routes act on the caller's own account only.

That second sentence is the actual decision, and it is specific to what is behind the gate. Our authenticated routes read your own account, write your own account, open your own support ticket, or start a Stripe Checkout session in your own name. There is no route where an hour of extra validity lets a revoked session act on somebody else. An app with shared workspaces, admin impersonation or revocable API tokens would weigh this completely differently, and for those cases the answer is to keep the round trip on the routes that need it rather than to abandon local verification everywhere.

Both clients, one verifier

The same route handlers serve the phone app and the website, which authenticate differently. That difference is eighteen lines:

export async function getUser(request: NextRequest): Promise<AuthUser | null> {
  const authHeader = request.headers.get('authorization');
  if (authHeader?.startsWith('Bearer ')) {
    const token = authHeader.slice('Bearer '.length).trim();
    if (!token) return null;
    const supabase = createBearerClient(url, publishableKey, {
      auth: { persistSession: false, autoRefreshToken: false },
    });
    return verifiedClaims(supabase, { jwt: token });
  }

  const supabase = await createCookieClient();
  return verifiedClaims(supabase);
}
Enter fullscreen mode Exit fullscreen mode

The bearer path passes the token explicitly and verifies it against the cached JWKS without any session machinery at all: no storage, no refresh, nothing persisted. The cookie path passes no token, which is the one case that may still reach the network, because an expired cookie session has to be refreshed before there is anything to verify. That is the correct place for a round trip, and it is the only one left.

Fail open, but only on the refresh

The proxy wraps the call:

try {
  await verifiedClaims(supabase);
} catch {
  // The request proceeds with whatever session cookies it had.
}
Enter fullscreen mode Exit fullscreen mode

Worth being precise about what that swallows. It fails open on refreshing a session, not on authorising one. Every route that needs a user resolves it again and returns 401 when there is not one. The worst case here is that your token is not rotated on this request and you are asked to sign in again sooner than you would have been. If this function could throw, the entire site would be down whenever Supabase sneezed, including the pages that have nothing to do with accounts.

See it working

  • munchable.app runs the proxy on every HTML request, and the header you see is drawn after it.
  • munchable.app/support is the clearest demonstration: the form works whether you are signed in or not, and only the header changes on the strength of the check described here.
  • app.munchable.app is the product, and the one client that sends a bearer token instead of cookies.
  • munchable.app/answers indexes the static pages, such as is inulin low FODMAP. Prerendered at build time, and still matched by the proxy, because the header is per-visitor even when the page is not.

If you are on Supabase and still on the legacy JWT secret, the rotation is the whole win and the code above is the part that keeps it. If you have already rotated, check whether anything in your hot path is still calling getUser(), because it reads exactly like a function that does not make a request.

Top comments (1)

Collapse
 
ahmedznouda profile image
Ahmed ZNOUDA •

The kid check is the nicest part: the rotation is detected from the token itself, no clock involved.

On the hour you accepted, there's one case I'd look at separately from "revoked": account deletion. After a user deletes their account, their access token still verifies for up to an hour, and your routes act on the caller's own account. If one of them writes (a support ticket, a profile upsert), it can recreate rows for a user who no longer exists after your delete routine has already run. It's the server-side mirror of the storage-key list from your previous post.

Two cheap guards: a foreign key from your tables to auth.users with on-delete cascade, so a late insert simply fails instead of leaving an orphan; and keeping the network getUser() only on the write routes, where an extra round trip matters much less than on every HTML page.

We're wiring account deletion in our own app right now (Supabase too), and that late-write window is the part I trust least.