JWT Middleware¶
The JWTAuthMiddleware is a Starlette middleware that validates JWT access tokens on every incoming request and makes the authenticated user available to your route handlers via request.state.user.
Setup¶
Using a PEM public key¶
from pathlib import Path
from fastapi import FastAPI
from sentinel_auth.middleware import JWTAuthMiddleware
app = FastAPI()
public_key = Path("keys/public.pem").read_text()
app.add_middleware(
JWTAuthMiddleware,
public_key=public_key,
exclude_paths=["/health", "/docs", "/openapi.json"],
)
Using JWKS auto-discovery (recommended)¶
from fastapi import FastAPI
from sentinel_auth.middleware import JWTAuthMiddleware
app = FastAPI()
app.add_middleware(
JWTAuthMiddleware,
jwks_url="https://identity.example.com/.well-known/jwks.json",
exclude_paths=["/health", "/docs", "/openapi.json"],
)
The JWKS URL is fetched lazily on the first request and cached. This avoids the need to distribute PEM files and supports automatic key rotation.
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
public_key |
str \| None |
None |
RSA public key in PEM format. Used to verify RS256 JWT signatures. Obtain this from the identity service's keys/public.pem. Either public_key or jwks_url must be provided. |
jwks_url |
str \| None |
None |
URL to a JWKS endpoint for auto-discovery of the signing key. The key is fetched lazily on first request and cached. Either public_key or jwks_url must be provided. |
audience |
str |
"sentinel:access" |
Expected aud claim in the JWT. Tokens with a different audience are rejected. |
algorithm |
str |
"RS256" |
JWT signing algorithm. Must match the identity service's signing configuration. |
exclude_paths |
list[str] \| None |
["/health", "/docs", "/openapi.json"] |
List of path prefixes that bypass authentication. Any request whose path starts with one of these strings is passed through without token validation. |
allowed_workspaces |
set[str] \| None |
None |
Optional set of workspace IDs permitted to access this service. None allows all workspaces. If set, requests with a workspace ID not in the set receive a 403 response. |
How It Works¶
For every incoming request, the middleware performs the following steps:
1. Path Exclusion Check¶
If the request path starts with any prefix in exclude_paths, the middleware skips all authentication and passes the request through to the next handler.
# These requests skip authentication entirely:
# GET /health
# GET /docs
# GET /openapi.json
# GET /docs/oauth2-redirect
2. Token Extraction¶
The middleware looks for the Authorization header with a Bearer prefix:
If the header is missing or does not start with Bearer, the middleware returns a 401 response immediately.
3. JWT Validation¶
The token is decoded and validated using PyJWT with the provided public key and algorithm. The middleware checks:
- Signature -- the token was signed by the identity service's private key
- Expiration -- the
expclaim has not passed - Audience -- the
audclaim matches the expected value (default:"sentinel:access") - Structure -- the token is a well-formed JWT
Audience prevents token misuse
Sentinel issues three token types with distinct audiences: "sentinel:access", "sentinel:refresh", and "sentinel:admin". The middleware rejects any token whose aud doesn't match, preventing refresh or admin tokens from being used as access tokens. See JWT Tokens — Access Token Claims for the full claims reference.
4. User Context Population¶
On successful validation, the middleware extracts claims from the JWT payload and creates an AuthenticatedUser instance:
| JWT Claim | AuthenticatedUser Field | Type |
|---|---|---|
sub |
user_id |
UUID |
email |
email |
str |
name |
name |
str |
wid |
workspace_id |
UUID |
wslug |
workspace_slug |
str |
wrole |
workspace_role |
str |
groups |
groups |
list[UUID] |
The AuthenticatedUser is set on request.state.user and is available to all downstream handlers and dependencies.
Error Responses¶
The middleware returns JSON error responses for authentication failures:
Missing or Malformed Header¶
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"detail": "Missing or invalid Authorization header"}
Returned when:
- The Authorization header is absent
- The header value does not start with Bearer
Expired Token¶
Returned when the JWT's exp claim is in the past. The client should use their refresh token to obtain a new access token from the identity service.
Invalid Token¶
Returned when: - The signature does not match the public key - The token is malformed or cannot be decoded - Required claims are missing
Workspace Not Permitted¶
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"detail": "Workspace not permitted for this service"}
Returned when allowed_workspaces is configured and the JWT's workspace ID is not in the permitted set. The token itself is valid, but the service does not serve this workspace.
Middleware Order¶
When combining JWTAuthMiddleware with other middleware, add it last (so it runs first in the request pipeline). FastAPI/Starlette middleware is executed in reverse order of addition:
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
# Added last = runs first
app.add_middleware(
JWTAuthMiddleware,
public_key=public_key,
exclude_paths=["/health", "/docs", "/openapi.json"],
)
# Added second = runs second
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"],
allow_headers=["*"],
)
# Added first = runs last
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["api.example.com"],
)
Request flow: TrustedHostMiddleware -> CORSMiddleware -> JWTAuthMiddleware -> route handler.
Customizing Excluded Paths¶
Override the defaults to include additional paths or restrict the exclusion list:
app.add_middleware(
JWTAuthMiddleware,
public_key=public_key,
exclude_paths=[
"/health",
"/docs",
"/openapi.json",
"/webhooks", # Allow unauthenticated webhook callbacks
"/public/", # Public asset routes
],
)
Path matching uses str.startswith(), so "/public/" matches /public/logo.png, /public/styles.css, etc.
Restricting by Workspace¶
If your service should only be accessible to members of specific workspaces, pass the allowed_workspaces parameter:
app.add_middleware(
JWTAuthMiddleware,
public_key=public_key,
allowed_workspaces={"a1b2c3d4-...", "e5f6g7h8-..."},
)
When set, any request with a valid JWT but a workspace ID not in the set receives a 403 Forbidden response:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"detail": "Workspace not permitted for this service"}
Pass None (the default) to allow all workspaces. This is useful for multi-tenant deployments where each service instance is locked to a specific tenant:
# Read from environment (comma-separated UUIDs)
allowed = settings.allowed_workspaces # e.g. ["uuid1", "uuid2"]
app.add_middleware(
JWTAuthMiddleware,
public_key=public_key,
allowed_workspaces=set(allowed) or None,
)
Accessing the User in Route Handlers¶
After the middleware runs, you can access the user directly from request.state or use the SDK's dependency helpers (recommended):
from fastapi import Depends, Request
from sentinel_auth.dependencies import get_current_user
from sentinel_auth.types import AuthenticatedUser
# Option 1: Direct access (not recommended)
@app.get("/me")
async def me(request: Request):
user = request.state.user
return {"email": user.email}
# Option 2: Dependency injection (recommended)
@app.get("/me")
async def me(user: AuthenticatedUser = Depends(get_current_user)):
return {"email": user.email}
The dependency approach is preferred because it provides type safety, automatic 401 responses if the user is missing, and cleaner function signatures.
Next Steps¶
- Dependencies -- use
get_current_user,require_role, and other helpers - Permission Client -- add entity-level access control