Self-Hosting

New API Keys and Asymmetric Authentication

Configure new API keys and ES256 asymmetric authentication for self-hosted Supabase.


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).

Before you begin#

  • Ensure OpenSSL and Node.js 16+ are available on the machine where you will generate new keys
  • Complete the Docker setup guide, including running generate-keys.sh so that JWT_SECRET, ANON_KEY, and SERVICE_ROLE_KEY are set in your .env file
  • If you are upgrading an existing self-hosted Supabase environment, make sure to check the changelog and add/update the following files:
    • .env.example (merge new sections into your .env file)
    • docker-compose.yml
    • utils/add-new-auth-keys.sh
    • utils/rotate-new-api-keys.sh
    • volumes/api/kong-entrypoint.sh
    • volumes/api/kong.yml

Adding the new keys#

From your project directory where you have docker-compose.yml:

1
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.

After updating .env, enable new authentication by uncommenting these lines in docker-compose.yml:

docker-compose.yml
1
auth:
2
environment:
3
# JSON array of signing JWKs (EC private + legacy symmetric)
4
GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]}
5
6
realtime:
7
environment:
8
# JWKS for token verification (EC public + legacy symmetric)
9
API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
10
11
storage:
12
environment:
13
# JWKS for token verification (EC public + legacy symmetric)
14
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.

Restart all services:

1
docker compose down && docker compose up -d

New API keys format#

The new API keys use the same format as the Supabase platform:

1
sb_publishable_<22-char-random>_<8-char-checksum>
2
sb_secret_<22-char-random>_<8-char-checksum>

Verifying the setup#

Test with the new publishable key:

1
curl http://<your-domain>/rest/v1/ \
2
-H "apikey: your-supabase-publishable-key"

You should receive a valid response from PostgREST. Then verify that the legacy key still works:

1
curl http://<your-domain>/rest/v1/ \
2
-H "apikey: your-anon-key"

Both should work and return the same result.

You can also verify the public JWKS endpoint:

1
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.

Environment variables configuration#

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)TypeDescription
JWT_SECRETSymmetric secretExisting: Shared secret for signing and verifying HS256 JWTs. Used by multiple services.
ANON_KEYHS256 JWTExisting: Legacy client-side API key. Embedded JWT with role: "anon".
SERVICE_ROLE_KEYHS256 JWTExisting: Legacy server-side API key. Embedded JWT with role: "service_role".
SUPABASE_PUBLISHABLE_KEYOpaqueNew: Short random key with checksum. Replaces ANON_KEY for client-side use.
SUPABASE_SECRET_KEYOpaqueNew: Short random key with checksum. Replaces SERVICE_ROLE_KEY for server-side use.
JWT_KEYSJSON arrayNew: JSON array of signing JWKs containing the new asymmetric key pair and the legacy symmetric key. Used by Auth to sign tokens.
JWT_JWKSJWKS (JSON)New: Contains the new public key and the legacy symmetric key. Used by PostgREST, Realtime, and Storage to verify tokens.

Differences from the Supabase platform#

  • One key per role. Self-hosted Supabase supports a single sb_publishable and a single sb_secret. The platform allows creating multiple sb_ keys per project.
  • No checksum validation. The opaque keys use the same format as the platform (sb_publishable_<random>_<checksum>), but the API gateway does not validate the checksum. Keys are matched as opaque strings by the API gateway.

Backward compatibility#

The new authentication configuration is fully backward compatible:

  • The API Gateway accepts both key types simultaneously. You can migrate clients incrementally - some using legacy API keys, others using the new ones.
  • JWKS includes the symmetric key. 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.
  • No database changes required. The asymmetric key system operates entirely at the API gateway and service configuration layer.

Rotating the new API keys#

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:

1
sh utils/rotate-new-api-keys.sh --update-env

After rotating, restart services and update your client applications with the new keys:

1
docker compose down && docker compose up -d

Regenerating asymmetric key pair#

If the EC private key is compromised or you need to regenerate everything:

1
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:

  • New user session tokens will be signed with the new EC key.
  • Existing user session tokens signed with the old EC key will fail verification. Users will need to sign in again.
  • Existing user session tokens signed with the legacy symmetric key (JWT_SECRET) will continue to work, since JWT_SECRET hasn't changed and is still included in the new JWKS.

How it works#

Below are a few notes on the details of the new authentication architecture.

What client SDK sends#

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 API gateway routing#

Kong is configured with two consumers that each accept both the legacy and new API keys:

volumes/api/kong.yml
1
consumers:
2
- username: anon
3
keyauth_credentials:
4
- key: $SUPABASE_ANON_KEY # legacy HS256 JWT (ANON_KEY)
5
- key: $SUPABASE_PUBLISHABLE_KEY # new opaque key (omitted when not configured)
6
- username: service_role
7
keyauth_credentials:
8
- key: $SUPABASE_SERVICE_KEY # legacy HS256 JWT (SERVICE_ROLE_KEY)
9
- 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.

RouteServiceAPI key requiredHeader substitution
/auth/v1/*AuthYesAuthorization
/rest/v1/*PostgRESTYesAuthorization
/graphql/v1PostgRESTYesAuthorization
/realtime/v1/api/*Realtime (REST)YesAuthorization
/realtime/v1/*Realtime (WebSocket)Yesx-api-key
/storage/v1/*StorageNoAuthorization
/functions/v1/*Edge FunctionsNo-

Request flows#

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.

Unauthenticated requests (API key only, no 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:

  1. The client sends apikey: sb_publishable_xxx (or legacy apikey: eyJ...).
  2. The API gateway checks the key and identifies the consumer (anon or service_role).
  3. The API gateway inspects the Authorization header. Since it is either absent or starts with Bearer sb_ (an opaque key, not a session JWT), the plugin replaces it:
    • The new sb_ key: Authorization header is set to the internal pre-signed ES256 JWT that corresponds to the role.
    • The Legacy JWT key: Authorization header is set to the legacy HS256 JWT (the apikey value is copied as-is).
  4. The upstream service receives a valid JWT in Authorization and verifies it using JWT_JWKS (or JWT_SECRET).

Authenticated requests (user session JWT)#

When the client has previously signed in through Auth and has a valid user session JWT token:

  1. The client sends Authorization: Bearer eyJ... (a JWT session token from Auth) alongside apikey: sb_publishable_xxx (or legacy apikey: eyJ...).
  2. The API gateway checks the API key and identifies the consumer.
  3. The API gateway inspects the 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.
  4. The upstream service verifies the session JWT. If Auth signed it with ES256 (when 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:

1
-- Pseudocode for the Authorization header logic:
2
if authorization exists AND does not start with "Bearer sb_" then
3
-- User session JWT: pass through unchanged
4
keep authorization
5
elseif apikey matches secret key then
6
-- Replace with pre-signed service_role ES256 JWT
7
set authorization = "Bearer <service_role ES256 JWT>"
8
elseif apikey matches publishable key then
9
-- Replace with pre-signed anon ES256 JWT
10
set authorization = "Bearer <anon ES256 JWT>"
11
else
12
-- Legacy JWT key: copy apikey as authorization
13
set authorization = apikey
14
end

Additional resources#

On GitHub: