Auth and API security
How sign-in actually works in a FastAPI plus React app: sessions versus bearer tokens, what a JWT contains and how to validate one properly against Microsoft Entra ID, dependency guards and role checks, and why CORS is not authentication.
12 cards · 6 quiz questions · 12 min read
Your dashboard has a “Sign in with Microsoft” button and it works. An agent wired it up: MSAL in the React app, a get_current_user dependency in FastAPI, a CORSMiddleware line somewhere near the top of main.py. The question you cannot answer from reading it is whether any of that stops a stranger from reading your clients’ survey data. This article gives you the specific checks.
Two different questions
Authentication asks who is calling. Authorisation asks what they may do. They fail differently: an unauthenticated caller getting in is a breach; an authenticated intern deleting a client’s project is also a breach, of the second kind. Agent-written code is usually decent at the first and frequently skips the second entirely — every endpoint requires a user, and no endpoint checks which user.
Sessions or bearer tokens
A session keeps state on the server. The browser holds an opaque cookie that means nothing on its own, sends it automatically with every same-site request, and you revoke access by deleting the server-side record. Because it is sent automatically, you need CSRF protection — a token or a strict SameSite attribute — so another site cannot make the browser act on the user’s behalf.
A bearer token is self-contained. The client attaches it deliberately in an Authorization: Bearer … header, so there is no CSRF exposure, and the server validates it by checking a signature with no database lookup. The price is revocation: a valid token stays valid until it expires. That is the trade you are making when you use Entra ID tokens, and it is why lifetimes are short.
What is actually inside a JWT
A JWT is three base64url segments joined by dots: header, payload, signature. The first two are encoded, not encrypted. Paste one into a decoder and you can read every claim. Nothing in it is secret; the signature is the only thing that makes it trustworthy.
The claims that matter to your API:
| Claim | Meaning | What you do with it |
|---|---|---|
iss | who issued it | must exactly match your expected issuer |
aud | which resource it is for | must identify this API |
exp / nbf | validity window | reject outside it, with a little clock leeway |
sub / oid | the subject / Entra object id | your stable user key |
tid | tenant id | part of the trust boundary, and of your data keys |
scp | delegated scopes | what the app may do for this user |
roles | app roles | your role checks |
kid (header) | signing key id | picks the public key from the JWKS |
Validating an Entra ID token properly
Microsoft’s guidance is explicit: a web API must validate the signature against the keys published in the tenant’s OpenID Connect metadata, must validate the issuer, and must only accept tokens whose aud claim identifies the API itself. Accepting a token minted for another resource is the confused deputy problem — your API acting on authority nobody gave it.
Here is what an agent commonly writes:
# BEFORE — three separate catastrophes
payload = jwt.decode(
token,
options={"verify_signature": False}, # 1. anyone can forge any claim
) # 2. no aud check, no iss check
user = await get_user_by_email(payload["email"]) # 3. trusts an unverified claim
With verify_signature off, the token is just a JSON blob the caller wrote. Someone can claim to be your finance director in about thirty seconds. This is the single most damaging mistake in agent-generated auth code, and it is easy to grep for.
The corrected version fetches the tenant’s public keys, selects one by kid, and asserts the audience and issuer:
import jwt
from jwt import PyJWKClient
TENANT = settings.entra_tenant_id
AUTHORITY = f"https://login.microsoftonline.com/{TENANT}"
ISSUER = f"{AUTHORITY}/v2.0" # the iss value in a v2.0 token
# The jwks_uri published by {AUTHORITY}/v2.0/.well-known/openid-configuration
_jwks = PyJWKClient(f"{AUTHORITY}/discovery/v2.0/keys", cache_keys=True)
def verify_token(token: str) -> dict:
signing_key = _jwks.get_signing_key_from_jwt(token) # matches the kid header
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # never trust the token's own alg header
audience=settings.api_audience, # this API's client id or App ID URI
issuer=ISSUER, # exact match; exp and nbf are checked by default
leeway=30, # tolerate small clock skew between hosts
)
Two details are load-bearing. algorithms=["RS256"] stops the token choosing how it is verified — the classic attacks substitute none, or HS256 with the public key used as an HMAC secret. And the key comes from the network, not from a constant in your settings, because Entra ID rotates signing keys; Microsoft suggests checking for new keys at least daily, which a caching JWKS client handles for you.
Guards as dependencies
In FastAPI, auth is a dependency. The idiom is a function that validates and returns the user, wrapped in Annotated so it reads as a type:
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer = HTTPBearer(auto_error=True)
async def current_user(
cred: Annotated[HTTPAuthorizationCredentials, Depends(bearer)],
) -> User:
try:
claims = verify_token(cred.credentials)
except jwt.InvalidTokenError: # covers expired, bad aud, bad signature
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
return await load_user(oid=claims["oid"], tid=claims["tid"])
CurrentUser = Annotated[User, Depends(current_user)]
Roles then compose on top, as a dependency factory:
def require_role(*allowed: str):
async def guard(user: CurrentUser) -> User:
if not set(allowed) & set(user.roles):
# 403, not 401: we know who you are, you may not do this
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient role")
return user
return guard
@router.delete("/projects/{project_id}", status_code=204)
async def delete_project(project_id: int, _: Annotated[User, Depends(require_role("admin"))]):
...
The most valuable structural change is to put the guard on the router rather than each endpoint, so that “protected” is the default and a new route added next month inherits it:
router = APIRouter(
prefix="/api/projects",
dependencies=[Depends(current_user)], # every route below is authenticated
)
Then check for object-level authorisation inside the handler. A logged-in user asking for /api/projects/812 should get a 404 or 403 unless that project belongs to them. Agents rarely write this line, and it is the most common real-world data leak: valid token, someone else’s id.
project = await session.get(Project, project_id)
if project is None or project.tenant_id != user.tid: # scope every read to the caller
raise HTTPException(status.HTTP_404_NOT_FOUND)
Tokens in the browser
A React SPA is a public client: it cannot keep a secret. Anything in the bundle is published, and a Vite variable named VITE_CLIENT_SECRET is inlined at build time and visible in devtools. The correct flow is authorisation code with PKCE, where the browser exchanges a one-time code plus a locally generated verifier for tokens, with no secret involved. The old implicit flow, which returned tokens in the URL fragment, is deprecated.
Where the token then lives is a genuine trade-off, not a solved problem:
localStorage— survives refresh, readable by every script on the page, so one compromised npm dependency exfiltrates it.httpOnlySecureSameSite=Laxcookie — invisible to script, but sent automatically, so state-changing routes need CSRF protection.- In memory only — safest, lost on refresh, so you re-acquire silently from the identity provider.
MSAL’s default is session storage plus silent re-acquisition, which is a reasonable middle. What matters more than the choice is that the token is short-lived and that you never log it. Grep your handlers for logging that dumps request headers.
CORS is not authentication
This one causes more confusion than anything else on the list. CORS is a rule the browser enforces about which web origins may read a cross-origin response. Your server just sends headers describing its policy. curl, Postman, a Python script, a scheduled job and any server-side attacker ignore it completely. CORS stops a random web page from reading your API’s replies in a signed-in user’s browser. It does not stop a request arriving, and it is not a substitute for a token check.
# BEFORE — forbidden combination; browsers reject the credentialed responses
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
# AFTER — explicit origins, as FastAPI's docs require when credentials are on
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://dashboard.example.com",
"http://localhost:5173", # Vite dev server
],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
The CORS specification forbids a wildcard allow-origin on a credentialed response, so that “before” version produces failures in your own dashboard that look exactly like an auth bug. Worse than the wildcard is hand-rolled middleware that echoes back whatever Origin header arrived: that makes every site on the internet a trusted origin, silently.
Rate limiting
Rate limiting bounds the damage from a stolen token, a retry loop in someone’s script, and password guessing on any sign-in route you still own. Key it per user (the token’s sub or oid) on authenticated routes and per IP on anonymous ones — per-IP alone lumps a whole office behind one NAT address into a single bucket.
Enforce it where all traffic passes. An in-process limiter using a Python dict counts separately in each Uvicorn worker and resets on every deploy, so four workers with a “100 per minute” rule actually allow four hundred. Put the real limit in Azure API Management, a gateway, or a shared store like Redis.
What to check in your own code
- Grep for
verify_signature,verify=Falseanddecode(with noalgorithms=argument. Any hit is a forgeable token. - Confirm
jwt.decodeis passed bothaudienceandissuer, and that the audience is your API’s identifier rather than the front end’s client id. - Check that the signing key is fetched from a
jwks_uriand cached, not hard-coded or read from an environment variable. - Search the front end for
SECRET,CLIENT_SECRETand anyVITE_-prefixed variable holding a credential. Anything found there is already public and needs rotating. - Look at the router definitions: is the auth dependency attached at router level, or repeated per endpoint (and therefore missing somewhere)?
- Pick three endpoints that take an id in the path. Do they check that the record belongs to the caller, or only that a caller exists?
- Check that 401 and 403 are used distinctly — unknown caller versus known caller without permission.
- Read the
CORSMiddlewarecall. Wildcard origins with credentials, or anOrigin-echoing implementation, both want fixing. - Confirm rate limits live in something shared across processes, and test one endpoint with a quick loop to see whether the limit is real.
- Search the logging setup for anything that writes whole headers or request bodies. Tokens in Application Insights are tokens in a searchable database.
An agent wrote a FastAPI dependency that calls jwt.decode with options set so that verify_signature is false, then reads the email claim to look up the user. What can an attacker do?
Sources
- Microsoft identity platform — Access tokens website What a web API must validate: signature via jwks_uri and the kid header, issuer, and an aud claim that matches the API itself.
- FastAPI — OAuth2 with Password (and hashing), Bearer with JWT tokens website The canonical FastAPI security dependency pattern with PyJWT encode and decode.
- FastAPI — CORS (Cross-Origin Resource Sharing) website CORSMiddleware parameters, and that wildcard origins cannot be combined with allow_credentials.
- PyJWT — API Reference website Exact jwt.decode signature including algorithms, audience, issuer and leeway, plus PyJWKClient.