Skip to content

Verify the Supabase JWT

app/auth.py — the single place FitTrack decides who is calling. Both clients (Flutter and Svelte) sign in through Supabase Auth and receive a JWT. They send that token on every request to FastAPI as an Authorization: Bearer <token> header. This lesson writes the code that opens that token, checks its signature against Supabase’s signing key, and hands the rest of the app a plain user id — or refuses the request with 401.

The deliverable is one reusable FastAPI dependency, get_current_user, plus a CurrentUser type alias so any handler can require a logged-in user by adding a single parameter. To prove it works end to end we wire it into a first protected route, GET /me, that just echoes the caller’s id — The current user → turns that stub into a real profile lookup.

Supabase already authenticated the user — so why does FastAPI check the token again? Because the token is the only thing FastAPI receives. The client did the login (email + password, magic link, OAuth) against Supabase and got back a signed JWT. FastAPI never sees the password and never calls Supabase to ask “is this person real?” on each request. Instead, Supabase signs the token with a key only Supabase holds the private half of. If FastAPI can verify that signature, it knows the token was minted by Supabase and hasn’t been tampered with. That one cryptographic check is the authentication. It’s fast (no database hit, and no per-request network call once the signing key is cached), stateless, and it’s exactly why a JWT is worth using.

Current Supabase projects sign new user sessions with an asymmetric key pair — ES256 (elliptic-curve) — and publish the public half at a well-known JWKS endpoint, {SUPABASE_URL}/auth/v1/.well-known/jwks.json. Anyone can fetch that public key and verify a signature with it; only Supabase holds the private key that creates one. A project can also still carry sessions signed the older way, with a shared secret (HS256, the SUPABASE_JWT_SECRET from supabase status) — the same secret both signs and verifies, so it must stay server-only. Because a token’s own header names which one it used, get_current_user reads that header first and verifies each shape the way it has to: the shared secret for HS256, the JWKS public key for anything else.

jwt.decode does three things in one call, and every one of them matters:

  • algorithms=[...] — the list of algorithms you’ll accept, always passed explicitly. Pinning it is a security requirement, not a formality: without it an attacker could hand you a token that claims alg: none and PyJWT would be within its rights to skip the signature check. You state the algorithms you accept and reject everything else — never derive the allow-list from the token’s own header.
  • the key — either settings.supabase_jwt_secret (for a legacy HS256 token) or the public key PyJWKClient fetched from the JWKS endpoint (for a current ES256 token), matched by the token’s kid header. The shared secret is server-only and must never ship in a client build; the JWKS public key is, by design, safe to publish.
  • audience="authenticated" — Supabase stamps every signed-in user’s token with aud: "authenticated". Verifying the audience means a token minted for some other purpose can’t be replayed against your API. PyJWT raises if it doesn’t match.

If any check fails — bad signature, expired token, wrong audience, garbage input — PyJWT raises, and we translate that into a single 401 Unauthorized. We deliberately don’t leak why it failed; “invalid token” is all a caller needs.

Finally, this lives behind FastAPI’s dependency injection rather than as a helper you call by hand. A dependency is declarative: a route that needs a user writes user_id: CurrentUser in its signature, and FastAPI runs the check before the handler body, injects the result, and — as a bonus — surfaces the security scheme in the generated /docs. Forgetting to protect a route becomes a visible omission in the signature, not a missing function call buried in the body.

Verifying the JWT locally (stateless) vs. calling Supabase to validate every request

  • Pros: zero network round-trips and zero database hits per request — the check is pure CPU against a secret you already hold, so it scales trivially and keeps latency flat; the backend has no runtime dependency on Supabase being reachable to authorize a request.
  • Cons: because it’s stateless, a token stays valid until it expires — you can’t instantly revoke a single token server-side the way a session lookup could. Supabase mitigates this with short-lived access tokens plus refresh tokens; for FitTrack’s scope the tradeoff is clearly worth it, but it’s the thing to understand before relying on it.

A get_current_user dependency vs. decoding the token inline in each handler

  • Pros: the verification logic exists once; every protected route opts in with one typed parameter and automatically gets the same 401 behavior and the same /docs “Authorize” affordance; testing later can override the dependency to inject a fake user without touching any route.
  • Cons: dependencies are a FastAPI-specific concept a newcomer has to learn, and the indirection means the check isn’t visible in the handler body — you have to read the signature to know a route is protected. That’s a small price for not duplicating security-critical code across a dozen endpoints.
Terminal window
uv add "pyjwt[crypto]"

PyJWT is the encode/decode library; we only use decode here. The [crypto] extra pulls in the cryptography package, which PyJWT needs to check an ES256 signature — without it, verifying a current Supabase token raises MissingCryptographyError instead of the clean 401 this lesson builds.

# app/auth.py — turn a Supabase Bearer token into a verified user id.
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from app.config import settings
# auto_error=False so a MISSING header reaches us as `None` instead of
# FastAPI raising its own 403 — we want one consistent 401 for every
# authentication failure, present-but-bad or absent alike.
bearer_scheme = HTTPBearer(auto_error=False)
# Supabase signs new user sessions with an asymmetric key (ES256) and
# publishes the public half at this well-known JWKS endpoint — the same
# path for local and hosted Supabase. PyJWKClient fetches and caches it,
# then picks the right key by the token's `kid` header.
_jwks_client = PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json")
def _unauthorized(detail: str) -> HTTPException:
"""Every auth failure returns the same 401 with a Bearer challenge."""
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
) -> UUID:
"""Verify the Supabase JWT and return the user id (the `sub` claim).
Raises 401 if the header is missing, the signature is wrong, the token
has expired, or the audience isn't `authenticated`.
"""
if credentials is None:
raise _unauthorized("Missing bearer token")
token = credentials.credentials
try:
# A project can carry legacy sessions signed with the shared secret
# (HS256) alongside current ones signed with the asymmetric project
# key (ES256) — the token's own header says which, so branch on
# that instead of guessing or hardcoding one algorithm.
header = jwt.get_unverified_header(token)
if header.get("alg") == "HS256":
signing_key = settings.supabase_jwt_secret
else:
signing_key = _jwks_client.get_signing_key_from_jwt(token).key
payload = jwt.decode(
token,
signing_key,
algorithms=["HS256", "ES256"],
audience="authenticated",
)
except jwt.PyJWTError:
# One catch-all: don't leak *why* verification failed.
raise _unauthorized("Invalid or expired token")
sub = payload.get("sub")
if sub is None:
raise _unauthorized("Token is missing the subject claim")
try:
# Supabase's `sub` is the user's UUID; parse it so it compares
# directly against the uuid FK columns every table keys on.
return UUID(sub)
except ValueError:
raise _unauthorized("Token subject is not a valid user id")
# A ready-made annotation so routes can just write `user_id: CurrentUser`.
CurrentUser = Annotated[UUID, Depends(get_current_user)]

Save this as app/auth.py. It reads settings.supabase_url to build the JWKS URL and settings.supabase_jwt_secret for the legacy fallback, both from the Settings you built in App & config → — make sure both SUPABASE_URL and SUPABASE_JWT_SECRET are set in your .env.

Add a GET /me to app/main.py that requires the dependency. For now it just returns the id — proof the whole chain works before we add a database lookup next lesson:

app/main.py
from uuid import UUID
from fastapi import FastAPI
from app.auth import CurrentUser
app = FastAPI(title="FitTrack API")
@app.get("/health")
def health() -> dict[str, str]:
"""Liveness check — no auth, no database, just proof the app is up."""
return {"status": "ok"}
@app.get("/me")
def read_me(user_id: CurrentUser) -> dict[str, UUID]:
"""Whoami — proves the JWT dependency runs before we add the DB lookup."""
return {"user_id": user_id}

That’s the entire wiring: user_id: CurrentUser is all it takes to make a route require a valid Supabase token. The return type matters here, not just for documentation: FastAPI uses a route’s return annotation as its response model, so -> dict[str, UUID] (not dict[str, str]) is what lets it serialize the UUID get_current_user returns — annotating it as str raises a ResponseValidationError the moment a real request comes in, because the value in hand is a UUID, not a str.

Start the API:

Terminal window
uv run fastapi dev app/main.py

Now get a real token from your local Supabase. This uses the password grant against the local Auth server (port 54321 from supabase start); it assumes you created a user in the Supabase module — if not, add one in Supabase Studio first. jq extracts the access token into a shell variable:

Terminal window
TOKEN=$(curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"lifter@example.com","password":"password123"}' | jq -r .access_token)

Call the protected route with the token — you get your user id back:

Terminal window
curl -s localhost:8000/me -H "Authorization: Bearer $TOKEN"
{"user_id":"3f4a1c2e-9b7d-4e1a-8c6f-2d5b0a1e7c93"}

Now call it without a token, and with a junk token — both must be refused with 401:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/me
curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/me -H "Authorization: Bearer not-a-real-token"
401
401

Finally, open http://localhost:8000/docs: GET /me now shows a padlock, and an Authorize button appears at the top — paste a token there and “Try it out” sends it for you. That padlock is the dependency advertising itself; every route you protect from here gets the same treatment for free.

Check your understanding:

  • FastAPI never sees the user’s password and never calls Supabase per request. What single piece of information lets it trust the token anyway, and where does that value have to be kept?
  • Why do we pass algorithms=["HS256", "ES256"] explicitly instead of letting PyJWT trust whatever alg the token’s own header claims?
  • What does audience="authenticated" protect against, and what does PyJWT do if the token’s aud doesn’t match?
  • We set HTTPBearer(auto_error=False) and handle a missing header ourselves. What would the response code be for a missing Authorization header if we left auto_error=True, and why did we not want that?

app/auth.py is FitTrack’s authentication boundary. A client authenticates against Supabase Auth, receives a JWT, and sends it as a Bearer token; FastAPI re-verifies that token locally with PyJWT — the token’s header says whether it’s a current ES256 session (verified against the public key PyJWKClient fetched from Supabase’s JWKS endpoint) or a legacy HS256 one (verified against the shared SUPABASE_JWT_SECRET), both checked with audience="authenticated" — and pulls the user id out of the sub claim. Any failure (missing, malformed, expired, wrong signature, wrong audience) collapses to a single 401. All of it is packaged as the get_current_user dependency and its CurrentUser alias, so a route requires a logged-in user with one typed parameter, and the padlock shows up in /docs automatically. We proved it by getting a genuine token from local Supabase and watching /me accept it and reject everything else. Next, The current user → turns that /me stub into a real endpoint that looks the profile up in the database and shows the pattern every protected route in FitTrack will follow.