Skip to content
Search

Supabase:JWTSigningKeys

lastmod 2026-09-10created 2026-09-102 edits

Supabase Auth's JWT Signing Keys are a signing key management system that replaces the legacy single JWT secret (HS256). There are three key points.

  • Signing uses asymmetric keys (ES256/RS256).
  • Multiple keys are used and rotated by state.
  • Separately, the API keys (anon/service_role) are split off into non-JWT keys (sb_publishable_…/sb_secret_…).

TOC

One-line summary

Until now, every token was signed with a single password (the JWT secret). The new system uses public-key signatures, and keys can be rotated without downtime. And the anon/service_role keys are no longer JWTs.

Legacy structure

A single JWT secret in the dashboard (HS256, symmetric key) signs every token.

            JWT secret (HS256, 대칭키 하나)
                 │ 서명
   ┌─────────────┼──────────────────┐
   ▼             ▼                  ▼
 anon 키     service_role 키     로그인 사용자 세션 토큰
 {role:anon} {role:service_role} {role:authenticated, sub:<uuid>}
 만료 10년    만료 10년           만료 1시간

Here is a little-known fact: the anon key and the service_role key are actually JWTs too. Paste one into jwt.io and you see a payload like {"role":"anon", "exp": 10년 뒤}. PostgREST looks at this role claim and does SET ROLE anon / service_role.

Problems with the legacy system

Problem

Description

HS256 = symmetric key

The signing key and the verification key are the same. Even a server that only wants to verify must hold this secret, and whoever holds the secret can forge any token (including role: service_role).

Leaks go unnoticed

If the secret leaks through environment variables, git, a mistaken VITE_/NEXT_PUBLIC_ prefix, and so on, there is no way to notice.

Rotation is painful

Changing the secret invalidates the anon/service_role keys at the same time and logs out every user. Mobile apps stay broken for the entire store review period.

Symmetric and asymmetric keys

  • HS256 (symmetric): Everyone shares one stamp. To check authenticity you need the stamp, but anyone who has the stamp can also forge right away.
  • ES256 / RS256 (asymmetric): Only Supabase holds the registered seal (private key), and the seal certificate (public key) is published to everyone. Anyone can check authenticity, but no one can forge.

The public keys are published through the JWKS endpoint.

GET https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json

The kid (key id) in the JWT header tells which public key in the JWKS to verify with.

{ "alg": "ES256", "kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e", "typ": "JWT" }

Supported algorithms

Algorithm

JWT alg

Notes

NIST P-256

ES256

Recommended default. Signatures are much shorter and faster than RSA.

RSA 2048

RS256

Widest support but slow.

Ed25519

EdDSA

Coming soon (limited runtime support).

HMAC shared secret

HS256

Not recommended for production. Revocation and leak detection are difficult.

The new system splits into two branches

This is an easy point to confuse. There are two independent changes.

(a) API keys: anon/service_role → publishable/secret

Legacy

New key

Nature

anon (JWT)

sb_publishable_…

Opaque string, not a JWT

service_role (JWT)

sb_secret_…

Opaque string, not a JWT

  • After the API gateway checks the key, it internally creates a new short-lived JWT and passes it to PostgREST and others. In the DB it still appears as the anon/service_role role.
  • Because they are independent of the signing keys, each key can be issued, revoked, and replaced individually.
  • They can run side by side with the legacy keys, so you can switch over without downtime. Check whether a key is in use with the "last used" indicator in the dashboard, then turn it off.

(b) JWT signing keys: JWT secret → Signing Keys

This is about what signs user session tokens (authenticated). It is the main topic of this page.

Key lifecycle

State

Signs new tokens

Verifies existing tokens

Published in JWKS

Standby

✗

(waiting for its turn)

✓ published in advance

Current (In use)

✓

✓

✓

Previously used

✗

✓ (for remaining tokens)

✓

Revoked

✗

✗

✗

Rotation flow

시간 →
키 A: [ current ]──rotate──[ previously used ]────(1시간+여유 뒤)──[ revoked ]
키 B: [ standby ]──rotate──[ current ────────────────────────────────────── ]
                  ↑
         이 순간부터 새 로그인은 B로 서명.
         A로 서명된 기존 토큰도 계속 통과 → 아무도 로그아웃되지 않음
  • Why the standby key is published in advance: B's public key must already be spread through the verifiers' caches so that tokens signed with B right after the rotation are not rejected.
  • Every state change except permanent deletion can be reverted. If something goes wrong, roll back.
  • After a state change, a limit (throttle) of about 5 minutes applies (platform-wide sync).

JWKS cache (up to about 20 minutes)

  • Cached for 10 minutes at the Supabase edge and 10 minutes in client library memory, for up to about 20 minutes in total.
  • Supabase's own products (Auth, Data API, Storage, Realtime) do not depend on this cache, so revocation takes effect immediately.
  • On the other hand, if a backend you built yourself caches the JWKS for verification, it may keep trusting a revoked key for up to 20 more minutes. If you need emergency revocation, set up a separate means of cache invalidation.
  • Failures right after importing or revoking a key may not be a key problem. Measure after 20 minutes.

Migration steps from legacy

  1. Dashboard Project Settings → JWT Keys → "Migrate JWT secret"
    • Registers the existing secret as current in the new system. The signing key stays the same, so nothing changes and there is no downtime.
    • At the same time, Supabase automatically creates one ES256 standby key.
  2. "Rotate keys": New logins are signed with ES256 from then on. The legacy secret becomes previously used.
  3. Wait for the token expiry time (default 1 hour) plus a margin (e.g., 1 hour 15 minutes), then revoke the legacy secret.
    • Before that, you must turn off the legacy anon/service_role keys first. Both are JWTs signed with the legacy secret, so revoking the secret kills them too. In other words, switch to the publishable/secret keys first.
    • The legacy JWT secret cannot be deleted (kept for rollback).

Pre-rotation checklist

  • If your code verifies by passing the JWT secret directly to jose/jsonwebtoken or similar, it breaks the moment you rotate. Switch to supabase.auth.getClaims() or JWKS-based verification.
  • If the "Verify JWT" option of Edge Functions is on, turn it off and verify directly inside the function.

When you need to create JWTs yourself (Private key Import)

For keys created by the dashboard, the private key cannot be extracted (for compliance). Only the legacy secret can be extracted. If your server needs to sign JWTs itself, follow these steps.

  1. Create your own ES256 key pair. E.g., supabase gen signing-key --algorithm ES256. Creating it with WebCrypto generateKey also works.
  2. In the dashboard, import the private key (JWK) as a standby key.
  3. (Documented procedure) Activate it by rotating.
  4. When signing, set the header's kid to exactly match the imported key (required).
  5. Put the required claims in the payload: role (an existing Postgres role such as anon/authenticated/service_role), exp, and (optionally) sub (a UUID from auth.users).
  6. When making requests, send the apikey header separately along with Authorization: Bearer<JWT>. The JWT cannot replace the apikey.

Import format (one line of private JWK JSON):

{"kty":"EC","crv":"P-256","kid":"<uuid>","d":"<base64url>","x":"<base64url>","y":"<base64url>"}
  • d is the private scalar. kty·crv·kid·x·y are published in the JWKS.
  • You can also create a test token with the CLI: supabase gen bearer-jwt --role authenticated --sub<uuid>

Facts confirmed by testing (2026-09-11)

These are not stated in the documentation.

  • The import UI appears only after "Migrate JWT secret". In the legacy-only state, only the Migrate button is shown.
  • There is one standby slot. You cannot keep separate development and production keys on standby.
  • The Data API (PostgREST) also accepts JWTs signed with a key imported in the standby state. No rotation is needed, and Supabase's own key keeps signing Supabase sessions. However, this is not behavior guaranteed by the documentation, so it may stop working if the policy changes.

Implementation pitfalls

  • ECDSA signature format: JWS ES256 requires the 64-byte r‖s (IEEE P1363) format. The output of WebCrypto crypto.subtle.sign({name:'ECDSA', hash:'SHA-256'}) is in this format, so you can base64url it as-is. The default output of Node crypto.sign is DER, so using it as-is gets the signature rejected outright (dsaEncoding:'ieee-p1363' required).
  • When calling importKey on a JWK, it is safer to leave out the alg field (some implementations reject it).
  • Do not leave the private JWK in a file. When registering the secret, pass it through stdin instead of as an argument so it does not end up in shell history.

Relationship between API keys and signing keys (verification path of a single request)

From Supabase's side, the two request headers are checked by different components.

요청 ─▶ [API 게이트웨이] ─────────────────────▶ [PostgREST]
         │ apikey 헤더 확인                     │ Bearer JWT 서명 검증
         │ (sb_publishable / sb_secret)         │ (JWT 서명 키 = 이 문서의 주제)
         │                                      │
         └ sb_secret 이면 → service_role 용     └ 통과하면 JWT의 role 로 SET ROLE
           단기 JWT를 게이트웨이가 직접 만들어 전달   (authenticated + sub → RLS)
  • apikey: Checks "is this allowed into the project". Handled by the API key system.
  • Bearer: Checks "who did it come in as". This is the domain of the JWT signing key system.
  • The service client (sb_secret_…) does not send a separate Bearer. The gateway creates the service_role JWT itself with its own key and passes it on, so it is unaffected by signing key replacement.

If you already use the new API keys

sb_publishable_…/sb_secret_… are not JWTs, so they are unaffected by Migrate, rotate, and legacy revocation alike. Even if the variable is named SUPABASE_SERVICE_ROLE_KEY, it is fine as long as the value is sb_secret_.

What to watch out for is that the legacy anon/service_role keys are still alive in the dashboard. Unless you turned them off separately, they are valid JWTs. The legacy service_role in particular is a 10-year master key, so if it leaks, turning it off is practically the only way to stop it. Before turning them off, check two things.

  • Check recent usage with the "last used" indicator in the dashboard's API Keys.
  • Check whether the production secret value is eyJ… (legacy JWT) or sb_secret_. If it is eyJ…, that server stops the moment you revoke the legacy secret.

When rotation and revocation are needed

  • Leak: The legacy secret ended up in logs, source control, or a frontend build, or a former employee knows it.
  • Compliance: Annual rotation and the like (SOC2, PCI-DSS, ISO27000, HIPAA).
  • Algorithm change: Switching between RSA, EC, and shared secrets.

Case study: CVP CLI PAT

  • Situation: The CLI personal access token (cvp_pat_…) is its own credential, so a Cloudflare Worker mints that user's Supabase JWT itself and sends it to PostgREST (keeping RLS).
  • Choice: The legacy HS256 secret was not used (because it is a shared key that signs every token). Instead, a self-made ES256 key was imported as standby, and the private key is kept only in a Worker secret (SUPABASE_JWT_SIGNING_KEY, one line of JWK).
  • Result: Supabase session signing stays as is, and only our minting key can be revoked separately. Since there is one standby slot, development and production use the same key.
  • Request composition: apikey: sb_publishable_… + Authorization: Bearer<minted ES256 JWT> (TTL 600 seconds).

See also

Favorite site

author Zer0 sitemap