Hosted Supabase signs user JWTs with ES256, not RS256 or HS256

An algorithm allow-list of RS256/HS256 rejects every hosted login with a 401. Add ES256, verify against the JWKS, and keep HS256 out of production.

RLS & Auth

· Chapter

2

·

2

min read

The answer. On a hosted Supabase project with asymmetric signing keys, user access tokens are signed with ES256 (an ECDSA P-256 key published at the project's JWKS endpoint), while the anon and service_role API keys are still legacy HS256 JWTs signed with the project secret. If your API verifies user tokens with an explicit algorithms allow-list — which it should, to block alg-confusion — and that list says ['RS256', 'HS256'] because that's what your self-hosted instance did, every login fails with a 401. Fix: allow ES256 (keep RS256 for forward-compat), verify against the JWKS, and allow HS256 only outside production, for a local instance that still signs with a shared secret.

The pattern.

import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL(`${env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`));

export async function verifyUserToken(token: string, env: Env) {
  const { payload } = await jwtVerify(token, jwks, {
    audience: "authenticated",
    // Hosted Supabase signs user tokens ES256. HS256 only for local dev,
    // where the instance still signs with a shared secret. Never in prod.
    algorithms: env.APP_ENV === "prod" ? ["ES256", "RS256"] : ["ES256", "RS256", "HS256"],
  });
  return payload;
}
# Look before you guess: what does the project actually sign with?
curl -s "$SUPABASE_URL/auth/v1/.well-known/jwks.json" | jq '.keys[] | {kty, alg, kid}'
# ...and what is in a fresh token's header?
echo "$ACCESS_TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .alg

The receipt. In our API Worker, 2026-07-09, the day before the self-hosted → hosted cutover: the middleware's allow-list was RS256/HS256, inherited from the self-hosted instance. The hosted project's JWKS held exactly one key, alg: ES256. Left alone, that would have 401'd every login after the switch. The fix landed as a one-line commit that day — production ['ES256', 'RS256'], staging additionally HS256 for the local dev instance — and the cutover proof on 2026-07-10 was a real hosted session, header alg: ES256, returning 200 from the authenticated /me route. The API needs no JWT secret for this path; the JWKS does the work.

Watch out.

  • If you cache the JWKS (we do, in KV), the cache still holds the old instance's keys after a migration. Flush the remote namespace, not the local simulator (hosted-cutover-verified-table-by-table).
  • Don't "fix" the 401 by adding HS256 in production. A verifier that accepts HS256 alongside asymmetric keys is the classic alg-confusion hole.
  • The anon/service_role keys being HS256 is normal; they're API keys, not user sessions. Don't run them through the user-token verifier.

Related: hosted-cutover-verified-table-by-table · custom-access-token-hook-is-instance-config