From c8b665caf239ce40f81d3e3964d4817b6e416af0 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:14:47 +0200 Subject: [PATCH] feat(self-hosted): add api gateway logic for functions (#46810) --- .../content/guides/self-hosting/docker.mdx | 5 +- .../guides/self-hosting/self-hosted-envoy.mdx | 21 +++- .../self-hosting/self-hosted-functions.mdx | 8 +- docker/tests/test-auth-keys.sh | 74 +++++++++++++ docker/tests/test-self-hosted.sh | 10 ++ docker/volumes/api/envoy/lds.template.yaml | 101 ++++++++++++++++++ docker/volumes/api/kong-entrypoint.sh | 12 +++ docker/volumes/api/kong.yml | 31 ++++++ docker/volumes/functions/main/index.ts | 35 ++++-- 9 files changed, 284 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index d1ec5d98bd1..ca11c567ab9 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -326,10 +326,11 @@ To change the database password, read [Changing database password](#changing-dat ## Accessing Edge Functions -Edge Functions live in `volumes/functions`. The default setup includes a `hello` function you can invoke with `curl`: +Edge Functions live in `volumes/functions`. The default setup includes a `hello` function you can invoke with `curl`. It requires your publishable or secret key in the `apikey` header: ```sh -curl http://:8000/functions/v1/hello +curl http://:8000/functions/v1/hello \ + --header 'apikey: ' ``` Add new functions at `volumes/functions//index.ts`, then restart the service to pick them up: diff --git a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx index 1772afdde01..60a0d76065c 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx @@ -113,7 +113,7 @@ Routes are matched in the order declared. The first matching prefix wins. Protec | `/.well-known/oauth-authorization-server` | auth | - | Open | OAuth 2.0 Authorization Server Metadata (RFC 8414) | | `/auth/v1/sso/saml/acs` | auth | `/sso/saml/acs` | Open | SAML assertion consumer | | `/auth/v1/sso/saml/metadata` | auth | `/sso/saml/metadata` | Open | SAML metadata | -| `/functions/v1/` | functions | `/` | Bypass | Edge Functions runtime performs its own JWT verification; 150s timeout | +| `/functions/v1/` | functions | `/` | `sb_` keys | Rejects invalid and conflicting `sb_` keys; others pass; timeout: 150s | | `/storage/v1/` | storage | `/` | Bypass | Storage performs its own authorization | | `/auth/v1/` | auth | `/` | API key | Protected Auth endpoints | | `/rest/v1/` | rest | `/` | API key | PostgREST Open API root (requires secret key) | @@ -154,7 +154,7 @@ A Lua filter rejects missing or invalid keys with HTTP `401 Unauthorized`. An RB ### Opaque key translation -When the new API keys (`sb_publishable_*`, `sb_secret_*`) are configured, a chain of Lua filters translates opaque keys into the corresponding pre-signed internal JWTs before the request reaches API key enforcement and upstream services. **The entire chain is skipped on `/functions/v1/`**: the Edge Runtime receives the original `apikey` and `Authorization` headers unchanged. +When the new API keys (`sb_publishable_*`, `sb_secret_*`) are configured, a chain of Lua filters translates opaque keys into the corresponding pre-signed internal JWTs before the request reaches API key enforcement and upstream services. **The chain is skipped on `/functions/v1/`**, which has its own filter. See [Edge Functions](#edge-functions). The chain operates in this order: @@ -166,6 +166,23 @@ The chain operates in this order: For background on opaque vs asymmetric keys, see [New API Keys and Asymmetric Authentication](/docs/guides/self-hosting/self-hosted-auth-keys). +### Edge Functions + +On `/functions/v1/`, a dedicated Lua filter handles API keys without changing the `apikey` or `Authorization` headers. When the new API keys are configured, it works as follows: + +| Request | Result | +| ------------------------------------------------------------------ | -------------------------------------------------------- | +| No `apikey` or `Authorization` | Passed through | +| `apikey` is `sb_publishable_*` or `sb_secret_*` | Passed through with `sb-api-key` set to the internal JWT | +| `Authorization: Bearer sb_...` and no `apikey` | Same as above, using the key from `Authorization` | +| Non-`sb_` value (legacy API key, user session token, any other) | Passed through | +| Unknown `sb_` key | Rejected with `401 Unauthorized` | +| `apikey` and `Authorization: Bearer sb_...` contain different keys | Rejected with `401 Unauthorized` | + +The filter always removes any `sb-api-key` header sent by the client. Without the new API keys configured, all other requests pass through unchanged. The `sb-api-key` value is the raw JWT, without a `Bearer` prefix. The `apikey` query parameter is not read on this route. + +When `FUNCTIONS_VERIFY_JWT` is `true`, the functions service verifies the JWT in `Authorization`. If `Authorization` is missing or contains an `sb_` key, it verifies `sb-api-key` instead. It removes `sb-api-key` before the request reaches your function. Functions can also check API keys themselves, for example with `withSupabase` from `@supabase/server`. See [Self-hosted Edge Functions](/docs/guides/self-hosting/self-hosted-functions). + ## Forwarded headers and CORS ### X-Forwarded headers diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index 00a2e74f641..5ef0d0625b2 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -42,7 +42,7 @@ Add the following code to `index.ts`: import { withSupabase } from '@supabase/server' export default { - fetch: withSupabase({ auth: 'none' }, async (req) => { + fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req) => { const { name } = await req.json() const message = `Hello, ${name}!` @@ -51,7 +51,7 @@ export default { } ``` -The `auth` option controls who can call the function: `'none'` accepts every request, `'user'` requires a valid user JWT, and `'publishable'` / `'secret'` require an API key. See the [Edge Functions auth guide](/docs/guides/functions/auth) for details. +The `auth` option controls who can call the function. This example matches the default `hello` function and requires a publishable or secret API key in the `apikey` header. `'none'` accepts every request, and `'user'` requires a valid user JWT. See the [Edge Functions auth guide](/docs/guides/functions/auth) for details. ### Step 2: Restart the functions service to pick up the new function @@ -63,6 +63,7 @@ sh run.sh restart functions ```sh curl -X POST http:///functions/v1/my-function \ + -H 'apikey: ' \ -H 'Content-Type: application/json' \ -d '{"name": "World"}' ``` @@ -226,7 +227,8 @@ Common causes: syntax errors in your function code, invalid imports, or missing ### 401 "invalid JWT" - Check that `FUNCTIONS_VERIFY_JWT` matches your intent (`true` or `false`) in `.env` -- If verification is enabled, ensure you're passing a valid token: `Authorization: Bearer ` +- If verification is enabled, pass either a valid JWT in `Authorization: Bearer ` (a user session token or a legacy API key), or an `sb_publishable_*` or `sb_secret_*` key in the `apikey` header. The API gateway translates `sb_` keys to an internal JWT, which the functions service then verifies. +- Verifying asymmetric JWTs, including translated `sb_` keys, requires `SUPABASE_JWKS` to be set for the `functions` service in `docker-compose.yml`. See [New API Keys and Asymmetric Authentication](/docs/guides/self-hosting/self-hosted-auth-keys). ### Changes to function code not reflected after editing diff --git a/docker/tests/test-auth-keys.sh b/docker/tests/test-auth-keys.sh index 1f58ae35a69..e92905adffc 100644 --- a/docker/tests/test-auth-keys.sh +++ b/docker/tests/test-auth-keys.sh @@ -59,6 +59,35 @@ http_status() { curl -s -o /dev/null -w "%{http_code}" "$@" "$url" } +# Like http_status, but appends " sdk" when the response carries the +# @supabase/server error header, i.e. the gateway passed the request through +# and the function itself rejected it. +fn_status() { + url="$1" + shift + out=$(curl -s -o /dev/null -D - -w '\n%{http_code}' "$@" "$url") + code=$(printf '%s\n' "$out" | tail -n 1) + if printf '%s\n' "$out" | grep -qi '^x-supabase-server-error:'; then + echo "$code sdk" + else + echo "$code" + fi +} + +# Detect which gateway is running so gateway-specific assertions can be gated. +detect_gateway() { + command -v docker >/dev/null 2>&1 || { echo unknown; return; } + running=$(docker ps --format '{{.Names}}' 2>/dev/null) + if printf '%s\n' "$running" | grep -q '^supabase-envoy$'; then + echo envoy + elif printf '%s\n' "$running" | grep -q '^supabase-kong$'; then + echo kong + else + echo unknown + fi +} +GATEWAY=$(detect_gateway) + echo "" echo "=== Testing against $BASE_URL ===" echo "" @@ -162,6 +191,44 @@ check "/api/tenants blocked -> 403" "403" \ check "/api/openapi blocked -> 403" "403" \ "$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")" +echo "" +echo "--- Edge Functions (/functions/v1/) ---" + +# hello uses withSupabase({ auth: ["publishable", "secret"] }), which requires +# an sb_ key in the apikey header. "401 sdk" means the gateway passed the +# request through and the function rejected it; a bare "401" is the gateway. +check "No auth -> passed to function, rejected by SDK" "401 sdk" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -d '{}')" + +# Non-sb_ values (legacy JWT, typo, third-party JWT) are not validated at the gateway. +check "Legacy ANON_KEY -> passed to function, rejected by SDK" "401 sdk" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $ANON_KEY" -d '{}')" +check "Non-sb_ invalid apikey -> passed to function, rejected by SDK" "401 sdk" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: invalid-key" -d '{}')" + +if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + check "PUBLISHABLE_KEY -> hello reachable" "200" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_PUBLISHABLE_KEY" -d '{}')" + check "SECRET_KEY -> hello reachable" "200" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_SECRET_KEY" -d '{}')" + # sb_ in Authorization only: the gateway translates it and passes it through, + # but the SDK only accepts sb_ keys in the apikey header. + check "sb_ in Authorization only -> passed to function, rejected by SDK" "401 sdk" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY" -d '{}')" + + # Invalid sb_-prefixed key and apikey/bearer sb_ conflict are rejected at the + # gateway - but only by Envoy. Kong is permissive and passes them through. + if [ "$GATEWAY" = "envoy" ]; then + check "Invalid sb_ apikey -> 401 at gateway (Envoy)" "401" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: sb_publishable_0000000000000000000000_00000000" -d '{}')" + check "Conflicting sb_ keys -> 401 at gateway (Envoy)" "401" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_SECRET_KEY" -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY" -d '{}')" + else + check "Invalid sb_ apikey -> passed to function, rejected by SDK (Kong)" "401 sdk" \ + "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: sb_publishable_0000000000000000000000_00000000" -d '{}')" + fi +fi + echo "" echo "--- supabase-js style requests (apikey + Authorization) ---" # supabase-js sends both apikey header AND Authorization: Bearer @@ -319,6 +386,13 @@ if [ -n "$access_token" ]; then "$(http_status "$BASE_URL/auth/v1/user" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $access_token")" + # Functions leaves Authorization (the user JWT) untouched and adds the + # translated sb-api-key alongside it; the request must reach the worker. + check "Opaque apikey + user JWT -> Functions reachable" "200" \ + "$(http_status "$BASE_URL/functions/v1/hello" -X POST \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $access_token" \ + -d '{}')" fi else check "Sign in test user" "true" "false" diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh index d842cc9dcfc..775a853cdf4 100644 --- a/docker/tests/test-self-hosted.sh +++ b/docker/tests/test-self-hosted.sh @@ -457,6 +457,16 @@ fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \ -d '{}') check "Call hello function" '{"message":"Hello from Edge Functions!"}' "$fn_resp" +# A non-sb_ value (typo / legacy / third-party JWT) is not rejected at the +# gateway - it passes to the function, where the Supabase Server SDK rejects it. +# (Detailed sb_-key translation/rejection is covered in test-auth-keys.sh.) +check "Functions reject non-sb_ apikey (Server SDK)" "401" \ + "$(http_status "$BASE_URL/functions/v1/hello" \ + -X POST \ + -H "apikey: invalid-key" \ + -H "Content-Type: application/json" \ + -d '{}')" + # --------------------------------------------- # 8. pg-meta (Studio backend) # --------------------------------------------- diff --git a/docker/volumes/api/envoy/lds.template.yaml b/docker/volumes/api/envoy/lds.template.yaml index 1a3c3451886..b13b7891bd6 100644 --- a/docker/volumes/api/envoy/lds.template.yaml +++ b/docker/volumes/api/envoy/lds.template.yaml @@ -1040,6 +1040,107 @@ resources: end end + # Functions: mirror the platform behavior. Translate opaque sb_ + # keys to the pre-signed internal asymmetric JWT and inject it as a + # raw `sb-api-key` header (no Bearer prefix), leaving Authorization + # untouched. The apikey is read from the header only (+ an + # Authorization `Bearer sb_` fallback) - no query-string source. + # Strips any client-supplied sb-api-key (anti-spoof). Returns 401 only + # for an sb_-prefixed key that is invalid/unregistered, or an + # apikey-vs-bearer sb_ conflict; any non-sb_ value (legacy/user/ + # third-party JWT, or anything else) passes through to the runtime. + - name: envoy.filters.http.lua + typed_config: + '@type': >- + type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + inline_code: | + local FUNCTIONS_ROUTE = "functions-v1-all" + local FUNCTIONS_PREFIX = "/functions/v1/" + local PUBLISHABLE_KEY = "${SUPABASE_PUBLISHABLE_KEY}" + local SECRET_KEY = "${SUPABASE_SECRET_KEY}" + local ANON_JWT = "${ANON_KEY_ASYMMETRIC}" + local SERVICE_ROLE_JWT = "${SERVICE_ROLE_KEY_ASYMMETRIC}" + local TRANSLATION_ENABLED = SECRET_KEY ~= "" and PUBLISHABLE_KEY ~= "" and SERVICE_ROLE_JWT ~= "" and ANON_JWT ~= "" + + local function is_functions_request(request_handle, headers) + if request_handle:streamInfo():routeName() == FUNCTIONS_ROUTE then + return true + end + + local path = headers:get(":path") + if path == nil then + return false + end + + return string.sub(path, 1, string.len(FUNCTIONS_PREFIX)) == FUNCTIONS_PREFIX + end + + local function bearer_token(auth) + if auth == nil then + return nil + end + + return string.match(auth, "^[Bb]earer%s+(.+)$") + end + + local function unauthorized(request_handle, message) + request_handle:respond( + { [":status"] = "401", ["content-type"] = "text/plain" }, + message + ) + end + + function envoy_on_request(request_handle) + local headers = request_handle:headers() + if not is_functions_request(request_handle, headers) then + return + end + + -- Strip any client-supplied sb-api-key (anti-spoof). + headers:remove("sb-api-key") + + -- No opaque keys configured: nothing to translate, pass through. + if not TRANSLATION_ENABLED then + return + end + + local apikey = headers:get("apikey") + local bearer = bearer_token(headers:get("authorization")) + + -- No key at all: pass through (verify_jwt:false / unauthenticated). + if (apikey == nil or apikey == "") and (bearer == nil or bearer == "") then + return + end + + -- Conflict: bearer carries an sb_ key that disagrees with apikey. + if bearer ~= nil and string.sub(bearer, 1, 3) == "sb_" + and apikey ~= nil and apikey ~= "" and apikey ~= bearer then + unauthorized(request_handle, "Conflicting API keys") + return + end + + -- Resolve the key from apikey, else an Authorization `Bearer sb_` fallback. + local key = apikey + if (key == nil or key == "") and bearer ~= nil and string.sub(bearer, 1, 3) == "sb_" then + key = bearer + end + + -- Non-sb_ value (legacy/user/third-party JWT, or anything else): + -- pass through; the runtime verifies it. + if key == nil or key == "" or string.sub(key, 1, 3) ~= "sb_" then + return + end + + -- sb_ key: translate publishable/secret, otherwise reject. + if key == SECRET_KEY then + headers:replace("sb-api-key", SERVICE_ROLE_JWT) + elseif key == PUBLISHABLE_KEY then + headers:replace("sb-api-key", ANON_JWT) + else + unauthorized(request_handle, "Invalid API key") + end + end + - name: envoy.filters.http.rbac typed_config: '@type': >- diff --git a/docker/volumes/api/kong-entrypoint.sh b/docker/volumes/api/kong-entrypoint.sh index daf1d5e9a1a..c5180dbbfae 100755 --- a/docker/volumes/api/kong-entrypoint.sh +++ b/docker/volumes/api/kong-entrypoint.sh @@ -19,10 +19,22 @@ if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey # via query string), outputs to x-api-key header which Realtime checks first. export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" + + # Functions: translate opaque sb_ keys to the pre-signed internal asymmetric + # JWT and emit it as a raw `sb-api-key` header (no Bearer prefix), leaving + # Authorization untouched. The key is read from the apikey header or an + # Authorization `Bearer sb_...` fallback (header only, matching platform + # behavior. On no match the expression yields nil (rendered as an empty + # header) which the route's post-function then strips. + export LUA_FUNCTIONS_EXPR="\$((headers.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or (headers.authorization == 'Bearer $SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.authorization == 'Bearer $SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or nil)" else # Legacy API keys, not sb_ API keys -> pass apikey through unchanged export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" export LUA_RT_WS_EXPR="\$(query_params.apikey)" + + # Functions: no opaque keys configured -> never set sb-api-key (the empty + # value is stripped by the route's post-function). + export LUA_FUNCTIONS_EXPR="\$(nil)" fi # Substitute environment variables in the Kong declarative config. diff --git a/docker/volumes/api/kong.yml b/docker/volumes/api/kong.yml index 84433c13657..d9608f04b8f 100644 --- a/docker/volumes/api/kong.yml +++ b/docker/volumes/api/kong.yml @@ -341,6 +341,21 @@ services: end ## Edge Functions routes + ## + ## Functions header handling: opaque sb_ keys are translated to the pre-signed + ## internal asymmetric JWT and injected as a raw `sb-api-key` header (no + ## `Bearer` prefix), while `Authorization` is left untouched so user-session + ## JWTs and legacy bearers flow through to the runtime. + ## + ## No `key-auth`: Functions is a passthrough that does NOT validate keys. Kong + ## cannot reject an invalid key while still letting unauthenticated requests + ## through (key-auth's `anonymous` fallback accepts missing AND invalid keys + ## alike), so unknown/invalid keys simply pass to the runtime, which handles + ## verify_jwt itself. + ## + ## The request-transformer strips any client-supplied `sb-api-key` (anti-spoof) + ## and re-adds the translated value; on no match the Lua expression yields nil + ## (an empty header) which the post-function then removes. - name: functions-v1 _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' url: http://functions:9000/ @@ -352,6 +367,22 @@ services: - /functions/v1/ plugins: - name: cors + - name: request-transformer + config: + remove: + headers: + - "sb-api-key" + add: + headers: + - "sb-api-key:$LUA_FUNCTIONS_EXPR" + - name: post-function + config: + access: + - | + local v = kong.request.get_header("sb-api-key") + if v == nil or v == "" or v:find("^%s*$") then + kong.service.request.clear_header("sb-api-key") + end ## OAuth 2.0 Authorization Server Metadata (RFC 8414) - name: well-known-oauth diff --git a/docker/volumes/functions/main/index.ts b/docker/volumes/functions/main/index.ts index 6bfad388a8a..3eeb28375db 100644 --- a/docker/volumes/functions/main/index.ts +++ b/docker/volumes/functions/main/index.ts @@ -36,7 +36,7 @@ export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null { } /** - * Extract JWT token from Authorization header + * Extract JWT token from 'Authorization' header or fallback to 'sb-api-key' compatibility * * Parses the Authorization header to extract the Bearer token. * Expects format: "Bearer " @@ -44,22 +44,41 @@ export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null { * @param req - The HTTP request object * @returns The JWT token string or an authentication failure */ +function extractBearerToken(authHeader: string | null): string | null { + const tokenParts = (authHeader ?? '').trim().split(/\s+/) + const [bearer, token] = tokenParts + if (bearer.toLowerCase() !== 'bearer' || tokenParts.length !== 2 || !token) { + return null + } + return token +} + function getAuthToken(req: Request): string | AuthFailure { const authHeader = req.headers.get('authorization') - if (!authHeader) { + const sbApiKeyCompatibilityToken = req.headers.get('sb-api-key') + + if (!authHeader && !sbApiKeyCompatibilityToken) { return { code: RequestErrors.MissingAuthHeader, message: 'Missing authorization header', } } - const tokenParts = authHeader.trim().split(/\s+/) - const [bearer, token] = tokenParts - if (bearer.toLowerCase() !== 'bearer' || tokenParts.length !== 2 || !token) { + + // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: + // - API proxy mints a temp token + // - Original bearer is not present or is ApiKey + const bearerToken = extractBearerToken(authHeader) + const token = !bearerToken || bearerToken.startsWith('sb_') + ? sbApiKeyCompatibilityToken + : bearerToken + + if (!token) { return { code: RequestErrors.InvalidTokenFormat, message: 'Invalid JWT format', } } + return token } @@ -221,7 +240,11 @@ Deno.serve(async (req: Request) => { importMapPath, envVars, }) - return await worker.fetch(req) + // Gateway-minted internal JWT is for this router only; never expose it to user functions. + const userReq = new Request(req) + userReq.headers.delete('sb-api-key') + EdgeRuntime.applySupabaseTag(req, userReq) + return await worker.fetch(userReq) } catch (e) { const error = { msg: e.toString() } return new Response(JSON.stringify(error), {