mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
Closes DOCS-1318 ## Problem An agent was asked to build an Edge Function returning the order history for whoever is signed in and calling it. Three runs, all correct: each one used the page's `auth: 'user'` pattern and read through the caller-scoped client. The eval scores 7 of 7, including the guide-read check. These are the gaps that showed up around it. | Finding | What the page does now | Why it matters | | --- | --- | --- | | Two clients, no guidance | The first example destructures `supabase` and `supabaseAdmin` together and labels the second "bypasses RLS (service role)" | A reader skimming for the client to use sees two, and one of them is wrong for that section | | No consequence named | "Bypasses RLS" is the strongest phrasing anywhere | A handler querying a shared table through the privileged client without a filter returns every user's rows. The page never said so | | `verify_jwt` as a value to set | Appears six times, five of them as something to change | Switching it off to clear a 401 in development is a reported failure. The page never said the default is the safe one | ## Solution - **Say which client to reach for**, in the section where both are handed over. - **Name the outcome** in a `danger` admonition: a handler that queries a shared table through `ctx.supabaseAdmin` without filtering by the caller's ID returns every user's rows. - **Frame `verify_jwt = true` as the default to leave alone** on user-facing functions, and say what turning it off costs. - **Say every project starts with a secret key named `default`.** **Not asserted here:** the eval is not re-run. It was already at 7 of 7, so there is no headroom to measure an improvement. A candidate new check is proposed on DOCS-1318. ## Preview links | Site | Live | Preview | Search for | | ---- | ---- | ------- | ---------- | | Docs | [/docs/guides/functions/auth](https://supabase.com/docs/guides/functions/auth) | [/docs/guides/functions/auth](https://docs-git-docs-functions-auth-eval-findings-supabase.vercel.app/docs/guides/functions/auth) | returns every user's rows | ## Review instructions 1. Open the live and preview links side-by-side. 2. Read Authenticated user calls on the preview. See a paragraph on choosing between `ctx.supabase` and `ctx.supabaseAdmin`, then a `danger` admonition naming the every-user's-rows outcome. 3. See the same section say to leave `verify_jwt = true` on for user-facing functions. 4. Read the note under Service-to-service calls. See it mention the `default` secret key. ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Clarified that `secret` and `publishable` authentication modes accept the `default` key, and documented how default, wildcard, and additional keys are handled. * Explained that JWT verification is enabled by default and that disabling it leaves `withSupabase` as the only caller-verification step. * Added guidance that admin queries bypass row-level security and should be scoped to the caller when accessing shared tables. * Clarified that service-to-service authentication with `secret` validates only the `default` key. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
210 lines
9.5 KiB
Plaintext
210 lines
9.5 KiB
Plaintext
---
|
|
id: 'auth'
|
|
title: 'Securing Edge Functions'
|
|
description: 'Authentication patterns for Supabase Edge Functions.'
|
|
subtitle: 'Authentication patterns for Edge Functions'
|
|
---
|
|
|
|
Secure an Edge Function by declaring which credentials it accepts. The `withSupabase` wrapper from the [`@supabase/server` package](https://github.com/supabase/server) on GitHub checks each caller against the `auth` mode you set. Your handler receives a preconfigured Supabase client on `ctx`.
|
|
|
|
- [Choose an auth mode](#choose-an-auth-mode) compares the four modes. Start here if you aren't sure which one your function needs.
|
|
- [Secure your function](#secure-your-function) has an example for each mode, plus how to combine modes and shape your own error response.
|
|
- [Environment variables](#environment-variables) lists what `@supabase/server` reads from the environment.
|
|
|
|
## Choose an auth mode
|
|
|
|
Each mode names the credential a caller must present. `withSupabase` accepts four:
|
|
|
|
| Mode | Accepts |
|
|
| --------------- | ------------------------------------------ |
|
|
| `'user'` | A valid user JWT on `Authorization` |
|
|
| `'secret'` | The `default` secret key on `apikey` |
|
|
| `'publishable'` | The `default` publishable key on `apikey` |
|
|
| `'none'` | Any caller, no check (for signed webhooks) |
|
|
|
|
For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers).
|
|
|
|
## Secure your function
|
|
|
|
Each of the following sections covers one mode, then how to combine modes and shape your own error response. Every example wraps the handler in `withSupabase` and reads its Supabase client from `ctx`.
|
|
|
|
### Authenticated user calls
|
|
|
|
When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies.
|
|
|
|
The default is `verify_jwt = true`, so the platform validates the JWT before your handler runs. Leave it on for user-facing functions. Turning it off to clear a 401 during development removes the platform's check, leaving `withSupabase` as the only thing that verifies the caller.
|
|
|
|
```ts
|
|
import { withSupabase } from 'npm:@supabase/server@1'
|
|
|
|
export default {
|
|
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
|
|
const { supabase, supabaseAdmin, userClaims, jwtClaims, authMode } = ctx
|
|
// supabase — RLS-scoped to the authenticated user
|
|
// supabaseAdmin — bypasses RLS (service role)
|
|
// userClaims — user identity from JWT (id, email, role)
|
|
// jwtClaims — full JWT claims
|
|
// authMode — which auth mode matched
|
|
|
|
// your business logic goes here
|
|
return Response.json({ email: ctx.userClaims?.email })
|
|
}),
|
|
}
|
|
```
|
|
|
|
Reach for `ctx.supabase` when the handler serves the signed-in caller. It applies their RLS policies to every query. Use `ctx.supabaseAdmin` only for work that has to cross those policies.
|
|
|
|
<Admonition type="danger">
|
|
|
|
A handler that queries a shared table through `ctx.supabaseAdmin` without filtering by the caller's ID returns every user's rows. `ctx.supabaseAdmin` bypasses Row Level Security, so filter by the caller's ID from `ctx.userClaims`, or use `ctx.supabase` instead.
|
|
|
|
</Admonition>
|
|
|
|
### Service-to-service calls
|
|
|
|
Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against the secret key named `default` in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work.
|
|
|
|
```ts
|
|
import { withSupabase } from 'npm:@supabase/server@1'
|
|
|
|
export default {
|
|
fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
|
|
// your business logic. ctx.supabaseAdmin bypasses RLS
|
|
return Response.json({ ok: true })
|
|
}),
|
|
}
|
|
```
|
|
|
|
<Admonition type="note">
|
|
|
|
`auth: 'secret'` accepts only the key named `default`. To accept a different key, use `auth: 'secret:<name>'`. For example, `auth: 'secret:automations'` accepts only the secret key named `automations`. To accept any secret key on the project, use `auth: 'secret:*'`. Every project starts with a secret key named `default`, and you can add more. To name a new key, open [**Settings > API keys**](/dashboard/project/_/settings/api-keys) in the Supabase Dashboard. The same syntax works for publishable keys: `auth: 'publishable:<name>'` and `auth: 'publishable:*'`.
|
|
|
|

|
|
|
|
</Admonition>
|
|
|
|
### Public functions
|
|
|
|
For a genuinely public function, such as a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler.
|
|
|
|
```toml
|
|
[functions.health]
|
|
verify_jwt = false
|
|
```
|
|
|
|
```ts
|
|
import { withSupabase } from 'npm:@supabase/server@1'
|
|
|
|
export default {
|
|
fetch: withSupabase({ auth: 'none' }, async () => {
|
|
// your business logic
|
|
return Response.json({ ok: true })
|
|
}),
|
|
}
|
|
```
|
|
|
|
`auth: 'none'` accepts every caller. For a function that authenticates callers itself, see the [External webhooks](#external-webhooks) section of this page.
|
|
|
|
### External webhooks
|
|
|
|
External providers such as Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the wrapper's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`.
|
|
|
|
```ts
|
|
import { withSupabase } from 'npm:@supabase/server@1'
|
|
import Stripe from 'npm:stripe'
|
|
|
|
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
|
|
|
|
// Deno has no synchronous Node crypto, so signature verification must go through
|
|
// Stripe's SubtleCryptoProvider. The synchronous `constructEvent()` throws
|
|
// "SubtleCryptoProvider cannot be used in a synchronous context" on this runtime.
|
|
const cryptoProvider = Stripe.createSubtleCryptoProvider()
|
|
|
|
export default {
|
|
fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
|
|
const signature = req.headers.get('stripe-signature') ?? ''
|
|
const body = await req.text()
|
|
|
|
try {
|
|
await stripe.webhooks.constructEventAsync(
|
|
body,
|
|
signature,
|
|
Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
|
|
undefined,
|
|
cryptoProvider
|
|
)
|
|
} catch (err) {
|
|
// Log the reason so a configuration error isn't mistaken for a forged payload.
|
|
console.error('Stripe signature verification failed:', err)
|
|
return new Response('bad signature', { status: 400 })
|
|
}
|
|
|
|
// your business logic. ctx.supabaseAdmin available for database work
|
|
return Response.json({ received: true })
|
|
}),
|
|
}
|
|
```
|
|
|
|
<Admonition type="danger">
|
|
|
|
`auth: 'none'` disables every credential check, so your handler is fully responsible for authenticating the caller. When the function reads or writes sensitive data, verify the caller yourself inside the handler.
|
|
|
|
</Admonition>
|
|
|
|
### Combining modes
|
|
|
|
Functions that answer both users and internal callers take an array on `auth`. The wrapper tries each mode in order and uses the first one that matches. `ctx.authMode` tells you which mode matched.
|
|
|
|
```ts
|
|
import { withSupabase } from 'npm:@supabase/server@1'
|
|
|
|
export default {
|
|
fetch: withSupabase({ auth: ['user', 'secret'] }, async (req, ctx) => {
|
|
if (ctx.authMode === 'user') {
|
|
// your business logic for user calls. ctx.supabase is scoped to them
|
|
return Response.json({ ok: true })
|
|
}
|
|
|
|
// your business logic for service calls. ctx.supabaseAdmin bypasses RLS
|
|
return Response.json({ ok: true })
|
|
}),
|
|
}
|
|
```
|
|
|
|
### Custom error responses
|
|
|
|
To shape the 401 response yourself, use `createSupabaseContext` instead of `withSupabase`. It returns a `{ data, error }` tuple instead of rejecting the request for you.
|
|
|
|
```ts
|
|
import { createSupabaseContext } from 'npm:@supabase/server@1'
|
|
|
|
export default {
|
|
fetch: async (req: Request) => {
|
|
const { data: ctx, error } = await createSupabaseContext(req, { auth: 'user' })
|
|
if (error) {
|
|
return Response.json({ message: error.message, code: error.code }, { status: error.status })
|
|
}
|
|
return Response.json({ message: `hello ${ctx.userClaims?.email}` })
|
|
},
|
|
}
|
|
```
|
|
|
|
## Environment variables
|
|
|
|
`@supabase/server` reads its configuration from a standard set of environment variables, which the Supabase Platform and the Supabase CLI provision for you.
|
|
|
|
| Variable | What it is |
|
|
| --------------------------- | ----------------------------------------- |
|
|
| `SUPABASE_URL` | Your project URL |
|
|
| `SUPABASE_PUBLISHABLE_KEYS` | Named publishable keys as a JSON object |
|
|
| `SUPABASE_SECRET_KEYS` | Named secret keys as a JSON object |
|
|
| `SUPABASE_JWKS` | JSON Web Key Set used to verify user JWTs |
|
|
|
|
Local development with the CLI uses a single-key setup. `@supabase/server` also accepts `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` as a fallback.
|
|
|
|
<Admonition type="note">
|
|
|
|
`@supabase/server` configures itself the same way on other runtimes. Install it in your Node.js, Bun, Cloudflare Workers, or self-hosted Deno app, then set the same variables. For the full reference, see the [environment variables guide](https://github.com/supabase/server/blob/main/docs/environment-variables.md) in the `@supabase/server` repository.
|
|
|
|
</Admonition>
|