Supabase gives every project a URL and a handful of keys, and mixing them up is the most common way to leak data. This guide covers what each one is, where it belongs, and how a FastAPI backend should use them.
The keys at a glance
| Key | Looks like | Where it goes | Row-level security |
|---|---|---|---|
| Project URL | https://<project-ref>.supabase.co |
Browser and server | — |
| Publishable key | sb_publishable_... |
Browser, mobile apps, and your server | Applies |
| Secret key | sb_secret_... |
Server only, never a browser | Bypassed |
Legacy anon key |
A long JWT starting eyJ
|
Same as publishable | Applies |
Legacy service_role key |
A long JWT starting eyJ
|
Same as secret | Bypassed |
The legacy anon and service_role keys still work, but Supabase is retiring them by the end of 2026. Use the publishable and secret keys in new code.
1. Find your URL and keys
In the Supabase dashboard, open your project and go to Project Settings → API Keys. The Connect button at the top of the project also shows the URL and publishable key.
Put them in your backend's environment:
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
The frontend only ever gets the URL and the publishable key.
2. Know what the secret key can do
The secret key skips row-level security: any query made with it can read and change every row. Supabase refuses it from a browser (it answers 401), but that check isn't protection on its own. Keep it in server environment variables only, and use it only for work no user owns, such as:
- recording a payment after a verified webhook
- looking up an API key's owner
- sending a message on the system's behalf
For everything a user asks for, call Supabase as that user (step 4).
Tip: create a separate secret key for each backend component (API, workers, scripts). If one leaks, you rotate that one: create a new key under API Keys , deploy it, confirm it works, then delete the old one.
3. Verify the user's token in FastAPI
When a user signs in, Supabase Auth gives the browser an access token (a JWT). The browser sends it to your API as Authorization: Bearer <token>. Your API must check it before trusting it.
Supabase signs tokens with JWT signing keys. With asymmetric keys (ES256 or RS256), the public keys are published at a JWKS endpoint, so your API can verify tokens without any secret:
https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
With PyJWT:
import jwt
jwks = jwt.PyJWKClient(f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json", cache_keys=True)
def verify_token(token: str) -> dict:
alg = jwt.get_unverified_header(token).get("alg")
if alg not in ("ES256", "RS256"):
raise jwt.InvalidAlgorithmError(alg)
key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(token, key, algorithms=[alg], audience="authenticated",
options={"require": ["exp", "sub"]})
Older projects may still sign with the legacy shared JWT secret (HS256). If yours does, verify with that secret instead, or migrate to signing keys on the JWT Keys page of your project settings, where Supabase moves keys through standby, current and revoked states so you can rotate in stages. Always pin the algorithm: never accept whatever the token's header claims.
4. Call Supabase as the user
The safest pattern is to pass the user's own token through to Supabase. Postgres then sees who is asking, and your row-level security policies decide what they can read or write:
from postgrest import SyncPostgrestClient
def as_user(token: str) -> SyncPostgrestClient:
return SyncPostgrestClient(
f"{SUPABASE_URL}/rest/v1",
headers={"apikey": SUPABASE_PUBLISHABLE_KEY, "Authorization": f"Bearer {token}"},
)
items = as_user(token).from_("items").select("*").execute().data # only this user's rows
Compare that with connecting to Postgres directly or using the secret key for everything: then every route has to remember to filter by owner, and one forgotten where user_id = ... leaks other people's data. Why we never connect to Postgres directly.
Checklist
- The browser has the URL and publishable key, nothing else.
- The secret key lives in server environment variables and is used only for system work.
- Your API verifies every token with the JWKS endpoint and a pinned algorithm.
- User requests reach Supabase with the user's own token, so RLS applies.
- Every table has RLS enabled, with policies written for
auth.uid().
Using it with FastAPI Supabase Starter
The starter is built this way. Copy the three values above into .env and you're done: verify_token() checks tokens against your project's JWKS (with the legacy secret as a fallback), every route queries through as_user(token), and the secret key is used only for verified webhooks, payments and API-key lookups. API keys and MCP clients get a real Supabase session for their owner, so the same RLS applies to them. Setup steps are in the quick start.
Top comments (0)