DEV Community

Cover image for Supabase API keys explained: publishable, secret and JWT signing keys for FastAPI
Aravind Govindhasamy
Aravind Govindhasamy

Posted on Originally published at starter.budeglobal.in on

Supabase API keys explained: publishable, secret and JWT signing keys for FastAPI

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_...

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"]})

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)