apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx
You can configure self-hosted Supabase to use the new API keys alongside the legacy API keys (ANON_KEY and SERVICE_ROLE_KEY HS256-signed JWTs).
generate-keys.sh so that JWT_SECRET, ANON_KEY, and SERVICE_ROLE_KEY are set in your .env file.openssl and node version 16 or newer are available on the machine where you will generate new keys..env.example (merge new sections into your .env file)docker-compose.ymlutils/add-new-auth-keys.shutils/rotate-new-api-keys.shvolumes/api/kong-entrypoint.shvolumes/api/kong.ymlFrom your project directory where you have docker-compose.yml:
sh utils/add-new-auth-keys.sh --update-env
This generates new configuration environment variables and writes them to .env. Without --update-env, the script prints the values and prompts you interactively.
The script reads JWT_SECRET from .env and includes it as a symmetric key inside both JWT_KEYS and JWT_JWKS. If you later change JWT_SECRET, you must regenerate the JWKS as well.
After updating .env, enable new authentication by uncommenting these lines in docker-compose.yml:
auth:
environment:
# JSON array of signing JWKs (EC private + legacy symmetric)
GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]}
realtime:
environment:
# JWKS for token verification (EC public + legacy symmetric)
API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
storage:
environment:
# JWKS for token verification (EC public + legacy symmetric)
JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
PostgREST does not need uncommenting - it already uses PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}} which automatically picks up JWT_JWKS when set.
Then restart all services:
docker compose down && docker compose up -d
The new API keys use the same format as the Supabase platform:
sb_publishable_<22-char-random>_<8-char-checksum>
sb_secret_<22-char-random>_<8-char-checksum>
Test with the new publishable key:
curl http://<your-domain>/rest/v1/ \
-H "apikey: your-supabase-publishable-key"
You should receive a valid response from PostgREST. Then verify that the legacy key still works:
curl http://<your-domain>/rest/v1/ \
-H "apikey: your-anon-key"
Both should work and return the same result.
You can also verify the public JWKS endpoint:
curl http://<your-domain>/auth/v1/.well-known/jwks.json
This should return the EC public key (the symmetric key is excluded). Third-party services can use this endpoint to obtain the public key and verify asymmetric user session JWTs without needing the private key.
New variables default to empty values in .env.example. When empty, the API gateway and all services operate in legacy-only mode: sb_publishable and sb_secret API keys are not configured.
| Environment variable (existing and new) | Type | Description |
|---|---|---|
JWT_SECRET | Symmetric secret | Existing: Shared secret for signing and verifying HS256 JWTs. Used by multiple services. |
ANON_KEY | HS256 JWT | Existing: Legacy client-side API key. Embedded JWT with role: "anon". |
SERVICE_ROLE_KEY | HS256 JWT | Existing: Legacy server-side API key. Embedded JWT with role: "service_role". |
SUPABASE_PUBLISHABLE_KEY | Opaque | New: Short random key with checksum. Replaces ANON_KEY for client-side use. |
SUPABASE_SECRET_KEY | Opaque | New: Short random key with checksum. Replaces SERVICE_ROLE_KEY for server-side use. |
JWT_KEYS | JSON array | New: JSON array of signing JWKs containing the new asymmetric key pair and the legacy symmetric key. Used by Auth to sign tokens. |
JWT_JWKS | JWKS (JSON) | New: Contains the new public key and the legacy symmetric key. Used by PostgREST, Realtime, and Storage to verify tokens. |
sb_publishable and a single sb_secret. The platform allows creating multiple sb_ keys per project.sb_publishable_<random>_<checksum>), but the API gateway does not validate the checksum. Keys are matched as opaque strings by the API gateway.The new authentication configuration is fully backward compatible:
JWT_JWKS contains both the EC public key (for verifying new ES256 tokens) and the legacy JWT_SECRET as a symmetric JWK (for verifying old HS256 tokens). Services that receive JWT_JWKS can verify both token types.${JWT_JWKS:-${JWT_SECRET}} - if JWT_JWKS is empty, it uses JWT_SECRET directly.When JWT_KEYS is set, Auth will start signing new user session JWTs with the new asymmetric ES256 key pair. Make sure all services that verify tokens (PostgREST, Realtime, Storage) are configured with JWT_JWKS so they can verify both the new ES256 and legacy HS256 tokens.
If your new API keys are compromised or you want to rotate them periodically, you can regenerate sb_publishable and sb_secret without touching the asymmetric key pair:
sh utils/rotate-new-api-keys.sh --update-env
After rotating, restart services and update your client applications with the new keys:
docker compose down && docker compose up -d
Rotating new API keys does not invalidate existing user sessions. User session JWTs issued by Auth are unaffected because they are verified using the asymmetric key pair, which remains unchanged.
</Admonition>If the EC private key is compromised or you need to regenerate everything:
sh utils/add-new-auth-keys.sh --update-env
This generates a new EC P-256 key pair, new JWKS, new asymmetric JWTs, and new sb_ API keys. After updating .env and restarting services:
JWT_SECRET) will continue to work, since JWT_SECRET hasn't changed and is still included in the new JWKS.Regenerating asymmetric keys invalidates all ES256 user sessions. Plan a maintenance window if your users have active sessions.
</Admonition>Below are a few notes on the details of the new authentication architecture.
Every request via supabase-js includes two headers:
apikey - the API key (sb_ or legacy JWT)Authorization - when unauthenticated, the client SDK copies the API key here (Bearer sb_publishable_xxx or Bearer eyJ...). When authenticated, this contains the user session JWT minted by Auth.For Realtime WebSocket connections, the API key is sent as a ?apikey= query parameter in the upgrade URL instead of an apikey header.
Storage and Edge Functions accept requests without an API key. These services handle their own authentication.
Kong is configured with two consumers that each accept both the legacy and new API keys:
consumers:
- username: anon
keyauth_credentials:
- key: $SUPABASE_ANON_KEY # legacy HS256 JWT (ANON_KEY)
- key: $SUPABASE_PUBLISHABLE_KEY # new opaque key (omitted when not configured)
- username: service_role
keyauth_credentials:
- key: $SUPABASE_SERVICE_KEY # legacy HS256 JWT (SERVICE_ROLE_KEY)
- key: $SUPABASE_SECRET_KEY # new opaque key (omitted when not configured)
When new API keys have not been added yet, the kong-entrypoint.sh script removes the empty credential entries before Kong loads the config.
To assist with the authorization flows a specialized configuration in kong.yml substitutes internal, gateway-level-only pre-signed JWTs for sb_publishable and sb_secret API keys. These pre-signed JWTs are also auto-configured in .env but should not be used in any application code.
| Route | Service | API key required | Header substitution |
|---|---|---|---|
/auth/v1/* | Auth | Yes | Authorization |
/rest/v1/* | PostgREST | Yes | Authorization |
/graphql/v1 | PostgREST | Yes | Authorization |
/realtime/v1/api/* | Realtime (REST) | Yes | Authorization |
/realtime/v1/* | Realtime (WebSocket) | Yes | x-api-key |
/storage/v1/* | Storage | No | Authorization |
/functions/v1/* | Edge Functions | No | - |
The API gateway (Kong) configuration has the logic to decide what Authorization header the upstream service, such as Auth, receives. The logic handles two cases: requests that only carry an API key (no user session), and requests that carry a user session JWT.
When the client sends only an apikey header with the API key (no Authorization header), or also the API key duplicated in Authorization by supabase-js:
apikey: sb_publishable_xxx (or legacy apikey: eyJ...).anon or service_role).Authorization header. Since it is either absent or starts with Bearer sb_ (an opaque key, not a session JWT), the plugin replaces it:
sb_ key: Authorization header is set to the internal pre-signed ES256 JWT that corresponds to the role.Authorization header is set to the legacy HS256 JWT (the apikey value is copied as-is).Authorization and verifies it using JWT_JWKS (or JWT_SECRET).When the client has previously signed in through Auth and has a valid user session JWT token:
Authorization: Bearer eyJ... (a JWT session token from Auth) alongside apikey: sb_publishable_xxx (or legacy apikey: eyJ...).Authorization header. Since it exists and does not start with Bearer sb_ (it's a real JWT, not an sb_ API key), the plugin passes it through unchanged. This works the same way regardless of whether the apikey is a new sb_ key or a legacy JWT - the gateway only looks at the Authorization header to decide whether a user session is present.JWT_KEYS is configured), verification uses the EC public key. If Auth signed it with HS256 (legacy), verification uses the symmetric key. Both keys are available in JWT_JWKS.The request-transformer expression in kong.yml implements this as a single Lua conditional:
-- Pseudocode for the Authorization header logic:
if authorization exists AND does not start with "Bearer sb_" then
-- User session JWT: pass through unchanged
keep authorization
elseif apikey matches secret key then
-- Replace with pre-signed service_role ES256 JWT
set authorization = "Bearer <service_role ES256 JWT>"
elseif apikey matches publishable key then
-- Replace with pre-signed anon ES256 JWT
set authorization = "Bearer <anon ES256 JWT>"
else
-- Legacy JWT key: copy apikey as authorization
set authorization = apikey
end
On GitHub: