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#

  • 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.
  • Ensure openssl and node version 16 or newer are available on the machine where you will generate new keys.
  • 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:

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.

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

  • All new variables are optional. If left with empty values, the API gateway (Kong) and all services behave exactly as before.
  • Kong 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.
  • Services fall back gracefully. PostgREST uses ${JWT_JWKS:-${JWT_SECRET}} - if JWT_JWKS is empty, it uses JWT_SECRET directly.
  • No database changes required. The asymmetric key system operates entirely at the API gateway and service configuration layer.

Rotating sb_ 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 supabase-js sends#

Every request 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.

API gateway routing#

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

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: