diff --git a/apps/docs/content/guides/getting-started/api-keys.mdx b/apps/docs/content/guides/getting-started/api-keys.mdx
index 78be04b482c..2b4218f3aa2 100644
--- a/apps/docs/content/guides/getting-started/api-keys.mdx
+++ b/apps/docs/content/guides/getting-started/api-keys.mdx
@@ -10,7 +10,7 @@ This guide covers:
- [Which key do you use?](#which-key-do-you-use) answers that in one table.
- [How API keys work](#how-api-keys-work) explains what each key type is and which Postgres role it maps to.
-- [Find and use your keys](#find-and-use-your-keys) covers the ways to retrieve a key and how to rotate one that leaked.
+- [Find and use your keys](#find-and-use-your-keys) covers the ways to retrieve a key, wiring it into your code, and rotating one that leaked.
- [Security reference](#security-reference) lists what publishable keys don't protect against, how to handle secret keys, and the known limitations.
## Which key do you use?
@@ -24,7 +24,7 @@ Pick the key by asking where the code runs.
Think of your project's data as a building. The publishable key is taped to the front door, and it only opens the lobby. The secret key is the master key, and it stays in your pocket.
-With the key chosen, go to [Find and use your keys](#find-and-use-your-keys) to get its value, or to [How API keys work](#how-api-keys-work) for what that key authorizes and which Postgres role it maps to.
+After you know which key you need, see [Find and use your keys](#find-and-use-your-keys) to get its value and wire it in. To swap keys in an application that already ships legacy keys, follow [Migrating to new API keys](/docs/guides/getting-started/migrating-to-new-api-keys) instead.
<$Partial path="api_keys_deprecation.mdx" />
@@ -47,11 +47,15 @@ Supabase supports four types of API keys:
| Type | Format | Privileges | Availability | Use |
| ---------------------------------------------------------- | ---------------------------------------------------------------- | ---------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Publishable key | `sb_publishable_...` | Low | Platform | Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code. |
-| Secret keys | `sb_secret_...` | Elevated | Platform | Only use in backend components of your app, such as servers, APIs with their own authorization checks, [Edge Functions](/docs/guides/functions), and microservices. They provide full access to your project's data, bypassing [Row Level Security](/docs/guides/database/postgres/row-level-security). |
+| Publishable key | `sb_publishable_...` | Low | Platform, CLI | Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code. |
+| Secret keys | `sb_secret_...` | Elevated | Platform, CLI | Only use in backend components of your app, such as servers, APIs with their own authorization checks, [Edge Functions](/docs/guides/functions), and microservices. They provide full access to your project's data, bypassing [Row Level Security](/docs/guides/database/postgres/row-level-security). |
| `anon` | JWT (long-lived) | Low | Platform, CLI | Legacy version of publishable keys. |
| `service_role` | JWT (long-lived) | Elevated | Platform, CLI | Legacy version of secret keys. |
+Publishable and secret keys are short strings, not JWTs. If a tool, tutorial, or AI assistant tells you to copy a long key that begins with `eyJ`, it was written for the legacy keys.
+
+Running `supabase start` prints a publishable key and a secret key for your local project. The local secret key takes the place of the local `service_role` key. See the [CLI getting started guide](/docs/guides/local-development/cli/getting-started) for the full output.
+
### Legacy `anon` and `service_role` keys
Creating publishable and secret keys doesn't revoke your legacy keys. Both key systems work at the same time.
@@ -73,12 +77,17 @@ These environments are always considered public because anyone can retrieve the
### Postgres roles and Row Level Security
-Using a publishable key doesn't mean your user is anonymous. Your application authenticates with the publishable key while your user authenticates separately through Supabase Auth with their own JWT:
+Every key resolves to a built-in Postgres role, and that role is what your Row Level Security policies match on. Using a publishable key doesn't mean your user is anonymous: your application authenticates with the publishable key while your user authenticates separately through Supabase Auth with their own JWT.
-| Key | User logged in via Supabase Auth | Postgres role |
-| --------------- | -------------------------------- | --------------- |
-| Publishable key | No | `anon` |
-| Publishable key | Yes | `authenticated` |
+| Key | User signed in through Supabase Auth | Postgres role |
+| --------------- | ------------------------------------ | --------------- |
+| Publishable key | No | `anon` |
+| Publishable key | Yes | `authenticated` |
+| Secret key | Not applicable | `service_role` |
+
+Write your Row Level Security policies for the `anon` and `authenticated` roles. Policies never apply to a secret key, because `service_role` has the `BYPASSRLS` attribute.
+
+Postgres evaluates table grants first, and only then applies Row Level Security. The two failures look different. A missing grant returns a permission error, including for `service_role`, whereas a policy that matches no rows returns an empty result. Check the grant before you debug the policy. See [Securing your API](/docs/guides/api/securing-your-api#grants-and-rls).
### Secret keys and elevated access
@@ -104,6 +113,18 @@ Secret keys improve on the old JWT-based `service_role` key, and we recommend th
## Find and use your keys
+Getting a key into an app has two halves. You copy the value, which needs a signed-in Dashboard session, and your code reads it by name. The key itself never belongs in source code, so start by setting these names in `.env`:
+
+```bash .env
+# Safe to expose to the browser. Prefix per your framework.
+NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
+
+# Server-only. Never prefix these, or your bundler will ship the key.
+SUPABASE_URL=https://your-project.supabase.co
+SUPABASE_SECRET_KEY=sb_secret_...
+```
+
### Find your keys
Pick the path that matches where you are working.
@@ -122,8 +143,8 @@ Every path below reads keys that already exist. If your project has no publishab
Use the Dashboard when you are setting up a project by hand. It needs nothing but a signed-in session.
1. Open your project's [**Connect** dialog](/dashboard/project/_?showConnect=true). It shows the URL and publishable key for the framework you select, ready to paste into `.env`.
-2. To pick a specific key, or to see every key your project has, open the [**Settings > API Keys**](/dashboard/project/_/settings/api-keys/) section of the Dashboard instead.
-3. Copy each value into your project's `.env` file.
+2. To pick a specific key, or to see every key your project has, open the [**Settings > API Keys**](/dashboard/project/_/settings/api-keys/) section of the Dashboard instead. Every key lives there, legacy or not, and there is no separate **Settings > API** page.
+3. Copy each value into `.env` under the variable names above.
@@ -192,6 +213,71 @@ This path needs the Supabase CLI and a container runtime. See [Running a local S
+### Use a publishable key in client code
+
+Pass the publishable key to `createClient` in any code that reaches a user's device.
+
+```ts lib/supabase.ts
+import { createClient } from '@supabase/supabase-js'
+
+export const supabase = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
+)
+```
+
+Row Level Security decides what this client can reach, so enable it on every table before you deploy.
+
+### Use a secret key in backend code
+
+Pass the secret key to `createClient` only in code that never reaches a user's device, such as a server route or a worker.
+
+```ts server/supabase.ts
+import { createClient } from '@supabase/supabase-js'
+
+export const supabaseAdmin = createClient(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_SECRET_KEY!
+)
+```
+
+#### Inside an Edge Function
+
+Don't read the key from the environment inside an Edge Function. Use the [`@supabase/server`](/docs/guides/functions/auth) SDK instead. It verifies the caller's secret key for you and hands back a privileged client on `ctx`, so the key never appears in your code.
+
+```ts supabase/functions/roster/index.ts
+import { withSupabase } from 'npm:@supabase/server'
+
+export default {
+ fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
+ // ctx.supabaseAdmin bypasses Row Level Security
+ const { data } = await ctx.supabaseAdmin.from('profiles').select('email')
+ return Response.json({ data })
+ }),
+}
+```
+
+Set `verify_jwt = false` for a function called with a secret key rather than a user's token.
+
+```toml supabase/config.toml
+[functions.roster]
+verify_jwt = false
+```
+
+If you build the client yourself instead of using the SDK, read the key from `SUPABASE_SECRET_KEYS`. The runtime injects it as a JSON dictionary keyed by key name, so pick the one you want.
+
+```ts supabase/functions/roster/index.ts
+import { createClient } from 'npm:@supabase/supabase-js@2'
+
+const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
+
+const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL')!, secretKeys['default'])
+```
+
+`SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` still exist in the runtime, but they carry the legacy keys, so reading them keeps you on the deprecated path. See [Environment Variables](/docs/guides/functions/secrets) for the full list of injected variables.
+
+If you are replacing legacy keys in an existing application rather than starting fresh, follow [Migrating to new API keys](/docs/guides/getting-started/migrating-to-new-api-keys).
+
### Rotate a leaked or compromised key [#leaked-key]
This procedure covers both a leaked secret key and a leaked `service_role` key.
@@ -262,6 +348,6 @@ Handle secret keys using [secure coding practices](https://owasp.org/www-project
Publishable and secret keys aren't JWTs, which creates a few compatibility differences to plan for:
-- You can't send a publishable or secret key in the `Authorization: Bearer ...` header unless the value exactly matches the `apikey` header. Supabase forwards that request to your project's database, which rejects it because the value isn't a JWT.
-- Edge Functions only support JWT verification through the `anon` and `service_role` JWT-based API keys. Use the `--no-verify-jwt` option with publishable and secret keys. The Supabase platform doesn't verify the `apikey` header for Edge Functions called this way, so implement your own `apikey` authorization inside the function.
+- Send publishable and secret keys on the `apikey` header, not on `Authorization: Bearer`. Because the keys aren't JWTs, anything that tries to verify one as a JWT fails. For migration compatibility the `verify_jwt` platform check accepts them on either header, but passing that check doesn't authenticate the caller. See [Authorization headers](/docs/guides/functions/auth-headers).
+- Edge Functions need to authorize API keys in code. The `verify_jwt` check alone doesn't authenticate a caller that sends only an API key. Use the `@supabase/server` SDK, as shown in [Securing Edge Functions](/docs/guides/functions/auth), rather than relying on the platform check.
- Public Realtime connections last a maximum of 24 hours, unless the connection is upgraded to user-level authentication through Supabase Auth or a supported third-party auth provider.
diff --git a/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx b/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx
index 089b1b3f3f4..66bec46fce7 100644
--- a/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx
+++ b/apps/docs/content/guides/getting-started/migrating-to-new-api-keys.mdx
@@ -210,8 +210,8 @@ Once you've confirmed nothing uses the legacy keys, deactivate them in the [**Se
A few behaviors differ from the legacy JWT-based keys. Plan for them during the migration:
-- You can't send a publishable or secret key in the `Authorization: Bearer ...` header. Send it on the `apikey` header instead.
-- Edge Functions don't verify the `apikey` header for the new keys. Use `verify_jwt = false` and authorize in code, as shown in [Step 4](#step-4-update-edge-functions).
+- Send publishable and secret keys on the `apikey` header. For migration compatibility the `verify_jwt` check also accepts them on `Authorization: Bearer ...`, but passing that check doesn't authenticate the caller, and the keys aren't JWTs, so nothing downstream can verify them as one.
+- Edge Functions need to authorize API keys in code. The `verify_jwt` platform check alone doesn't authenticate a caller that sends only an API key, so authorize the key in your handler, as shown in [Step 4](#step-4-update-edge-functions).
- Public Realtime connections are limited to 24 hours unless the connection is upgraded with user-level authentication through Supabase Auth or a supported third-party auth provider.
## Next steps