The answer. A backend that is not Supabase (a Cloudflare Worker, an API gateway) can still accept Supabase sessions: the user's access token is a JWT signed by the project's current signing key, and the public half is published at /auth/v1/.well-known/jwks.json on the project URL or custom auth domain. Verification is standard: fetch the JWKS, verify the signature, check aud is authenticated and exp is in the future, then read the claims. Our API Worker does exactly this on every request with jose, caching the key set in KV. Two decisions matter. Pin the algorithms you accept (hosted projects sign user tokens with ES256; RS256 for forward compatibility; never HS256 in production, since the symmetric secret would have to live in the Worker). And cache the key set for minutes, not days: Supabase's edge caches the endpoint for ten minutes and a rotation becomes visible on that schedule.
The pattern.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(`${env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`), { cooldownDuration: 600_000 });
const { payload } = await jwtVerify(token, jwks, { audience: "authenticated", algorithms: ["ES256", "RS256"] });
const claims = claimsSchema.parse(payload); // sub, role, aal, session_id, is_anonymous, app_metadata
if (claims.app_metadata.mfa_enrolled && claims.aal !== "aal2") return c.json({ error: "mfa_required" }, 403);
Watch out.
- A rotated signing key that your cache has not seen yet 401s every user; flush the cache on rotation and wait the documented twenty minutes before retiring the old key.
rolein the token is the Postgres role (authenticated), not your app role. App roles live inapp_metadata, and only if a hook or the admin API put them there.- Verifying the token does not talk to Auth, so a signed-out or banned session stays valid until
exp. Re-check membership in the database for sensitive mutations.
Related: hosted-user-tokens-es256-not-hs256 · hosted-cutover-verified-table-by-table · server-side-trust-getclaims-not-getsession