Authentication¶
Django-Bolt provides built-in authentication backends that run in Rust for high performance. This guide covers how to set up and use authentication in your API.
JWT authentication¶
JWT (JSON Web Token) is the most common authentication method for APIs. Django-Bolt validates JWT tokens in Rust without the Python GIL overhead.
Basic usage¶
from django_bolt import BoltAPI
from django_bolt.auth import JWTAuthentication, IsAuthenticated
api = BoltAPI()
@api.get("/profile", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
async def profile(request):
return {"user_id": request.user.id}
This endpoint:
- Expects a JWT token in the
Authorizationheader:Bearer <token> - Validates the token signature and expiration
- Rejects the request with 401 if the token is invalid
- Populates
request.contextwith token claims
auth attempts, guards enforce
auth=[...] alone does not reject unauthenticated requests — it only attempts to validate credentials. If the token is missing, expired, or has an invalid signature, the handler still runs with request.context = None and request.user = AnonymousUser. This is intentional so that endpoints can support optional authentication (e.g. personalize if logged in, otherwise public).
To require a valid token, pair auth with a guard like guards=[IsAuthenticated()]. Without it, invalid credentials fall through silently.
# ❌ NOT protected — invalid/missing tokens still reach the handler
@api.get("/profile", auth=[JWTAuthentication()])
async def profile(request):
print(request.context) # None when token is invalid/missing
print(request.user) # AnonymousUser
# ✅ Protected — returns 401 when token is invalid/missing
@api.get("/profile", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
async def profile(request):
print(request.context) # {"user_id": ..., "auth_backend": "jwt", ...}
print(request.user) # real User instance (lazy-loaded)
See Permissions for the full list of guards.
Creating tokens for users¶
Use create_jwt_for_user to generate tokens for Django users:
from django.contrib.auth import aauthenticate
from django_bolt.auth import create_jwt_for_user
from django_bolt.exceptions import Unauthorized
import msgspec
class LoginRequest(msgspec.Struct):
username: str
password: str
@api.post("/auth/token")
async def login(credentials: LoginRequest):
user = await aauthenticate(
username=credentials.username,
password=credentials.password
)
if user is None:
raise Unauthorized(detail="Invalid credentials")
token = create_jwt_for_user(user, expires_in=3600) # 1 hour
return {
"access_token": token,
"token_type": "bearer",
"expires_in": 3600
}
The generated token includes:
sub- User's primary keyis_staff- Staff statusis_superuser- Superuser statususername- Usernameexp- Expiration timestamp
Permissions not included by default
Permissions are NOT automatically included in the token. To guard on them with Requires("permissions", ...), pass permissions via extra_claims:
Accessing the authenticated user¶
Django-Bolt provides lazy user loading via request.user:
@api.get("/me", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
def get_me(request):
user = request.user # Lazily loads from database
return {
"id": user.id,
"username": user.username,
"email": user.email
}
The user is only loaded from the database when you access request.user. If you don't need the full user object, use request.context which is available without a database query.
In an async handler or an async middleware, prefer await request.auser():
@api.get("/me", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
async def get_me(request):
user = await request.auser()
return {"id": user.id}
request.auser() does not block the event loop. It awaits an async get_user directly. It runs get_user_sync on the request lane or on the ORM pool. request.user then returns the same user with no second query.
To get the user as a parameter, use CurrentUser. It works in sync and async handlers:
from django_bolt import CurrentUser, OptionalCurrentUser
@api.get("/me", auth=[JWTAuthentication()])
async def get_me(user: CurrentUser):
return {"id": user.id}
@api.get("/home", auth=[JWTAuthentication()])
def home(user: OptionalCurrentUser):
return {"signed_in": user is not None}
CurrentUser answers 401 when the request has no authenticated user. OptionalCurrentUser gives None then. In an async handler, both load the user with await request.auser(). In a sync handler, both read request.user, so the handler keeps its sync dispatch. For the type of your user model, make your own alias: Annotated[User, Depends(require_current_user)].
An async handler can also read request.user. The read loads the user when it occurs, and the worker thread waits for that query. Use await request.auser() when the loop must serve other requests during the query, for example with a networked database. The query runs on that worker thread, or on the lane of a request with Django middleware. On the lane, it sees the thread-local state of the request, for example a tenant schema. With DEBUG = True or runbolt --dev, Bolt logs each such read that blocks the event loop for more than 50 ms, with its file and line.
A thread with no event loop that is not the lane cannot wait for the lane, for example a thread from asyncio.to_thread. A sync read there raises LaneAffinityError, a subclass of RuntimeError. Its message names the route and the handler. With DEBUG = True or runbolt --dev, Bolt also logs the fix one time for each route.
Custom user query¶
By default request.user runs User.objects.get(pk=<sub claim>). To use a
different query — select_related, defer, a custom user model, lookup by
username — subclass the backend and override get_user:
class MyJWT(JWTAuthentication):
async def get_user(self, user_id, auth_context):
return await (
User.objects
.select_related("track", "member")
.defer("track__image", "member__joined_at")
.aget(id=user_id)
)
@api.get("/tasks", auth=[MyJWT()], guards=[IsAuthenticated()])
async def tasks(request):
user = await request.auser() # loaded with your query
...
Overriding the async get_user or the sync get_user_sync both work —
either alone is enough. If you define both, sync handlers use
get_user_sync directly (no event-loop overhead) and it is also preferred
for async handlers via a worker thread.
A backend that overrides only the async get_user serves await request.auser().
A sync read of request.user cannot run a coroutine, so it raises RuntimeError.
Define get_user_sync as well when sync handlers or sync middleware read request.user.
The override is scoped to the routes that use that backend instance —
routes authenticated with a plain JWTAuthentication keep the default
pk-based query, even in the same app.
Using dependency injection¶
Alternatively, use the get_current_user dependency:
from django_bolt import Depends
from django_bolt.auth import get_current_user
@api.get("/me", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
async def get_me(user=Depends(get_current_user)):
return {
"id": user.id,
"username": user.username
}
JWT configuration options¶
Customize JWT validation:
JWTAuthentication(
secret="your-secret-key", # Default: Django's SECRET_KEY
algorithms=["HS256"], # Allowed algorithms
header="authorization", # Header name
cookie=None, # Cookie name to read the token from
audience="your-app", # Required audience claim
issuer="your-issuer", # Required issuer claim
)
Supported algorithms:
HS256,HS384,HS512- HMAC with SHA-2 (secretis the shared secret)RS256,RS384,RS512- RSA with SHA-2 (secretis a PEM public key)PS256,PS384,PS512- RSA-PSS with SHA-2 (secretis a PEM public key)ES256,ES384- ECDSA with SHA-2 (secretis a PEM public key)EdDSA- Ed25519 (secretis a PEM public key)
All algorithms configured on a single backend must use the same kind of
key; you cannot mix HS256 with RS256. Configuration errors — an
unknown algorithm name, algorithms from different key families, or a key
that is not valid PEM — stop the server at startup with a descriptive
error, rather than silently rejecting every token at runtime.
Verifying tokens from an identity provider¶
If your users sign in through an external identity provider such as
Clerk, Auth0, or Okta, the provider signs its tokens with an asymmetric
algorithm and publishes the corresponding public key. Pass the key with
public_key= and name the matching algorithm:
JWTAuthentication(
public_key=CLERK_PEM_PUBLIC_KEY, # from the provider's dashboard
algorithms=["RS256"],
issuer="https://your-app.clerk.accounts.dev",
)
Most providers also publish their keys as a JSON Web Key Set and rotate
them periodically, identifying each key with a kid header on the token.
To use the key set instead of a single key, point jwks_url= at the
provider's JWKS endpoint:
JWTAuthentication(
jwks_url="https://your-tenant.auth0.com/.well-known/jwks.json",
algorithms=["RS256"],
issuer="https://your-tenant.auth0.com/",
audience="https://api.example.com",
)
jwks_url must be an absolute HTTPS URL and must be paired with both
issuer= and audience=. This binds accepted tokens to the intended
provider and API instead of accepting another application's token merely
because the provider signed it. The key set is fetched when the server
starts and parsed into per-kid verification keys. It is refreshed every
jwks_refresh_interval seconds and immediately when a token names an unknown
kid. Refreshes are single-flight within a process, and a provider outage
leaves the last known-good keys active.
OIDC discovery can supply the issuer, JWKS endpoint, and signing algorithms:
The issuer must be HTTPS. Django-Bolt fetches its
/.well-known/openid-configuration document and rejects discovery documents
without an issuer or JWKS URI.
To supply a key set directly rather than fetching it — in tests, or in
deployments without network access — pass the document itself as jwks=,
either as a dict or a JSON string.
Cookie-based tokens¶
For browser clients that store the JWT in a cookie (e.g. an HttpOnly
access_token cookie), pass cookie= with the cookie name:
@api.get(
"/profile",
auth=[JWTAuthentication(cookie="access_token")],
guards=[IsAuthenticated()],
)
async def profile(request):
return {"user_id": request["context"]["user_id"]}
When cookie= is set, the token is read from the named cookie only — the
Authorization header is ignored for that backend. The cookie value is the
raw token, no Bearer prefix needed. Extraction and validation both
happen in Rust, without acquiring the GIL.
A request that sends the named cookie two times is not authenticated by
this backend. A sibling subdomain can set a cookie with the same name, and
the order of the two cookies does not show which one your site set. A
__Host- cookie name prevents such a cookie: the browser accepts it only
from your exact host.
To serve both browser (cookie) and API (bearer header) clients on the same endpoint, register two backends — they're tried in order:
Cross-site request forgery protection¶
Browsers attach cookies to requests automatically, including requests
made from other sites. Django-Bolt endpoints do not run Django's
CsrfViewMiddleware, so cookie authentication provides its own CSRF
protection, enabled by default.
When a backend successfully authenticates from a cookie, any
state-changing request (a method other than GET, HEAD, OPTIONS, or
TRACE) must show it originated from your own site. The check runs in
Rust after selecting the accepted credential. The browser-set
Sec-Fetch-Site header
is used when present; otherwise the origin of the Origin (or
Referer) header — scheme and host — is compared against the request's
effective scheme (honoring X-Forwarded-Proto behind a proxy) and
Host. HTTP and HTTPS are different origins, so an
Origin: http://example.com never authorizes an HTTPS request to
example.com. Requests that fail the check receive a 403 Forbidden
response.
The check applies only when the accepted credential came from the cookie backend. Requests authenticated some other way — for example, through the header backend of a route that registers both backends as above — are not affected, even if the request also carries a stale cookie.
Non-browser clients
Clients other than browsers do not usually send Sec-Fetch-Site,
Origin, or Referer, so their state-changing requests with the
cookie will be rejected. Have such clients send the token in the
Authorization header instead. If that isn't possible, disable the
check with csrf=False and provide equivalent protection yourself.
Access and refresh tokens¶
Issuing a single long-lived token means a stolen token stays valid until
it expires. The usual remedy is the dual-token pattern: a short-lived
access token sent on every request, and a long-lived refresh
token, sent only to a rotation endpoint, that is exchanged for fresh
access tokens as they expire. Django-Bolt implements this pattern with
create_token_pair(), rotate_refresh_token(), and
set_token_cookies().
For the conventional password-and-cookie flow, JWTAuthViews registers all
four routes and documents them in OpenAPI:
from django_bolt.auth import InMemoryRevocation, JWTAuthViews
auth_views = JWTAuthViews(store=InMemoryRevocation())
auth_views.register(api)
This adds POST /auth/login, /auth/refresh, /auth/logout, and
/auth/logout-all. Supply credential_validator= when credentials are not a
username/password pair; lower-level helpers remain available for fully custom
flows.
Issuing a pair¶
Call create_token_pair() once the user has proved who they are — by
password, magic link, OAuth callback, or any other flow:
from django.contrib.auth import aauthenticate
from django_bolt.auth import create_token_pair, set_token_cookies
from django_bolt.exceptions import Unauthorized
from django_bolt.responses import JSON
@api.post("/auth/login")
async def login(credentials: LoginRequest):
user = await aauthenticate(
username=credentials.username,
password=credentials.password,
)
if user is None:
raise Unauthorized(detail="Invalid credentials")
pair = create_token_pair(user, method="pwd")
response = JSON({"status": "ok"})
return set_token_cookies(response, pair, refresh_path="/auth/refresh")
set_token_cookies() attaches both tokens as HttpOnly, Secure,
SameSite=Lax cookies. Pass refresh_path= with the path of your
rotation endpoint: the refresh cookie is then scoped to that path, so
the browser sends it only when refreshing, never on ordinary API
requests.
Access tokens live for 15 minutes by default and refresh tokens for 7
days; change these with access_ttl= and refresh_ttl=. Add extra
claims with claims=. The lifecycle claims the pair is built on —
sub, iat, exp, typ, jti, fam, oat, and ver — are
reserved, and attempting to override one raises ValueError.
The rotation endpoint¶
The rotation endpoint authenticates with token_type="refresh", which
accepts only tokens carrying the claim typ: "refresh". Conversely,
every route configured without token_type= rejects refresh tokens,
so a refresh token can never authenticate a normal endpoint. Both checks
run in Rust before your handler is called.
from django_bolt.auth import (
DjangoCacheRevocation,
IsAuthenticated,
JWTAuthentication,
TokenRotationError,
rotate_refresh_token,
set_token_cookies,
)
from django_bolt.exceptions import Unauthorized
from django_bolt.responses import JSON
store = DjangoCacheRevocation()
@api.post(
"/auth/refresh",
auth=[JWTAuthentication(cookie="refresh_token", token_type="refresh")],
guards=[IsAuthenticated()],
)
async def refresh(request):
claims = request["context"]["auth_claims"]
try:
pair = await rotate_refresh_token(claims, store=store)
except TokenRotationError:
raise Unauthorized(detail="Invalid refresh token")
response = JSON({"status": "ok"})
return set_token_cookies(response, pair, refresh_path="/auth/refresh")
By the time your handler runs, the token's signature, expiry, and type
have already been verified in Rust. rotate_refresh_token() performs
the stateful checks against the revocation store, atomically consumes the
old refresh token, and returns a new pair. A custom store used for full
rotation must implement consume(jti, *, exp=None) as an atomic
check-and-set and support family revocation; rotation fails closed if it
cannot. If the validating JWTAuthentication uses a non-default leeway,
pass the same value to rotate_refresh_token() so consumed tokens remain
blocked throughout the validator's clock-skew window.
Rotating the refresh token on every use provides reuse detection.
Each pair belongs to a rotation family (the fam claim); a refresh
token presented again after it has been rotated out is treated as
stolen, and the whole family is revoked, ending the session for whoever
holds any of its tokens. If you prefer to issue only new access tokens
and leave the refresh token in place, pass rotate=False; this mode
gives up reuse detection.
Custom claims are not copied from the old token during rotation, since
they may have gone stale since the original login. Re-derive any claims
you need and pass them with claims=.
Return a generic error
Catch TokenRotationError and return a uniform 401. The exception
message records why rotation failed — revoked, reused, stale
version, or an expired session — and that detail should not reach
the client.
Logging a user out everywhere¶
Revocation stores keep a version number for each user, and every token
pair records the version it was minted under (the ver claim). Calling
bump_user_version() increments the stored version; refresh tokens
minted before the bump fail rotation from then on:
@api.post("/auth/logout-all", auth=[jwt_auth], guards=[IsAuthenticated()])
async def logout_all(request):
await store.bump_user_version(request["context"]["user_id"])
return {"status": "logged out everywhere"}
This is a single O(1) write; you do not need to find and revoke each outstanding token.
Access tokens run out their lifetime
Access tokens are deliberately not checked against the store on each request — that is what makes them cheap to validate. A user who is "logged out everywhere" can therefore keep using an existing access token until it expires. Keep access lifetimes short; with the default 15 minutes, that is the longest such a token can survive.
Limiting total session length¶
Every pair carries an oat (origin auth time) claim recording when the
user originally authenticated. The value is copied unchanged across
rotations, so it can bound the total length of a session no matter how
many times the session refreshes:
pair = await rotate_refresh_token(
claims,
store=store,
max_session_lifetime=86400 * 30, # require sign-in again after 30 days
)
Once the cap is exceeded, rotation raises TokenRotationError and the
user must sign in again. Two related guarantees hold whenever the cap is
configured: a refresh token without an oat claim (for example, one
minted outside this module) fails closed rather than being re-minted
with a fresh origin time, and the access token issued by a rotation is
clamped to the session's remaining lifetime so it can never outlive the
cap.
API key authentication¶
In Development
API key permissions (key_permissions parameter) are in development. Basic API key validation works, but per-key permissions are not yet finalized.
For service-to-service communication, use API key authentication:
from django_bolt.auth import APIKeyAuthentication, IsAuthenticated
api_keys = {"sk-prod-123abc", "sk-prod-456def"}
@api.get(
"/internal",
auth=[APIKeyAuthentication(api_keys=api_keys)],
guards=[IsAuthenticated()]
)
async def internal_endpoint(request):
return {"status": "authorized"}
API keys are sent in the X-API-Key header by default.
API key permissions (In Development)¶
Assign different permissions to different keys:
key_permissions = {
"sk-admin-key": {"admin.read", "admin.write"},
"sk-reader-key": {"admin.read"},
}
APIKeyAuthentication(
api_keys=set(key_permissions.keys()),
key_permissions=key_permissions
)
Custom header¶
Use a different header for API keys:
Session authentication¶
SessionAuthentication uses Django's session framework. Use it for browser apps that log in with Django.
Setup¶
Put the backend on a route, as any other backend:
from django_bolt import CurrentUser
from django_bolt.auth import IsAuthenticated, Requires, SessionAuthentication
@api.get("/me", auth=[SessionAuthentication()], guards=[IsAuthenticated()])
async def me(user: CurrentUser):
return {"username": user.username}
@api.get("/reports", auth=[SessionAuthentication()], guards=[Requires("is_staff", True)])
async def reports():
return {"reports": []}
Django does the session work:
- Bolt adds Django's
SessionMiddlewareandAuthenticationMiddlewareto each route that uses the backend. When the middleware of the API, router or route already has both, Bolt adds nothing. When it has only one of them, startup fails, because a secondSessionMiddlewarewould load and save the session again. - Rust rejects a request with no session cookie with 401. No Python runs.
- For a request with the cookie, Django loads the session and the user.
- Bolt checks the guards of the route against
request.user, before the handler.
A guard can read is_staff, is_superuser and permissions of the Django user. Requires("permissions", "app.perm") uses the permissions of the user and of its groups. A Requires on another claim rejects a session request with 403. A none_of guard on another claim is an error at startup, because it would let every session user in. Startup also fails when a guard reads a field that your user model does not have.
| Option | Default | Description |
|---|---|---|
login_url |
None |
Redirect an anonymous user to this URL with Django's redirect_to_login. Without it, the request gets 401. |
csrf |
True |
Check the origin of each unsafe request to the route, also with no session cookie. This also protects a login route. See Cross-site request forgery protection. |
With csrf=True, a client that is not a browser must send an Origin header that matches the host. Otherwise, use a token backend for that client, or set csrf=False.
Note
- A route with the session backend runs Django middleware in Python. It does not get the sync fast path. When Bolt adds the middleware to the route, a sync handler runs on the thread pool, not on a request lane.
- For a session request, all Python middleware runs before Bolt checks the guards. For a token request, Rust checks the guards before any Python runs.
- A rate limit with
key="user"counts a session request by its client IP.
Performance
Each session request runs two Django queries: one for the session and one for the user. Set Django's SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" to read the session from the cache. In our measurement, this gave 40% to 60% more requests per second. A sync handler on an API with django_middleware runs on a request lane, which is also faster than the thread pool.
- Session authentication works on HTTP routes only. A WebSocket route or an MCP mount with the backend in auth raises an error at startup. When the backend comes from BOLT_AUTHENTICATION_CLASSES, Bolt leaves it out there.
You can also load the Django middleware on the whole API, with no backend. Then use Django's decorators, because Bolt guards do not see the session user:
Login and logout¶
Use Django's async login/logout functions:
from typing import Annotated
from django.contrib.auth import alogin, alogout
from django.contrib.auth.models import User
from django_bolt import Request
from django_bolt.auth import AllowAny, SessionAuthentication
from django_bolt.params import Form
@api.post("/login", auth=[SessionAuthentication()], guards=[AllowAny()])
async def login(
request: Request,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
user = await User.objects.filter(username=username).afirst()
if user and user.check_password(password):
await alogin(request, user)
return {"status": "logged in", "username": user.username}
return {"status": "invalid credentials"}
@api.post("/logout", auth=[SessionAuthentication()], guards=[AllowAny()])
async def logout(request: Request):
await alogout(request)
return {"status": "logged out"}
Accessing the authenticated user¶
Use request.auser() for async user access:
@api.get("/me")
async def me(request: Request):
user = await request.auser()
if user.is_authenticated:
return {"username": user.username, "email": user.email}
return {"anonymous": True}
Working with session data¶
Django sessions support async operations. Use aget, aset, and other async methods:
@api.post("/preferences")
async def save_preferences(request: Request, theme: str = "light"):
session = request.session
# Async read with default
visits = await session.aget("visit_count", 0)
# Async write
await session.aset("visit_count", visits + 1)
await session.aset("theme", theme)
return {"visits": visits + 1, "theme": theme}
@api.get("/preferences")
async def get_preferences(request: Request):
session = request.session
return {
"theme": await session.aget("theme", "light"),
"visits": await session.aget("visit_count", 0),
}
Async session methods¶
| Method | Description |
|---|---|
await session.aget(key, default) |
Get a value from the session |
await session.aset(key, value) |
Set a value in the session |
await session.apop(key, default) |
Remove and return a value |
await session.akeys() |
Get all session keys |
await session.aitems() |
Get all session items |
await session.aflush() |
Delete session and create new one |
await session.acycle_key() |
Regenerate session key (keeps data) |
session.session_key |
Get session key (sync, no DB access) |
session.clear() |
Clear session data (sync, no DB access) |
Use async methods in async handlers
Always use async methods (aget, aset, etc.) in async handlers. Using sync methods like session["key"] or session.get() raises SynchronousOnlyOperation.
When to use session vs JWT¶
| Feature | Session Auth | JWT Auth |
|---|---|---|
| Storage | Server-side (DB/cache) | Client-side (token) |
| Logout | Immediate (delete session) | Requires revocation store |
| Scalability | Requires shared session store | Stateless, scales easily |
| Use case | Browser apps, traditional web | APIs, mobile apps, SPAs |
Using Django decorators¶
With django_middleware=True, Django's authentication decorators work directly:
from django.contrib.auth.decorators import login_required, permission_required
api = BoltAPI(django_middleware=True)
@api.get("/dashboard")
@login_required
async def dashboard(request: Request):
"""Protected by @login_required - redirects to login if not authenticated."""
user = await request.auser()
return {"welcome": user.username}
@api.get("/admin/users")
@permission_required("auth.view_user", raise_exception=True)
async def admin_users(request: Request):
"""Protected by @permission_required - returns 403 without permission."""
users = await User.objects.all().avalues_list("username", flat=True)
return {"users": list(users)}
@api.get("/reports")
@permission_required("reports.view_report", login_url="/login/")
async def reports(request: Request):
"""Redirects to custom login URL if no permission."""
return {"reports": [...]}
Available Django decorators:
| Decorator | Description |
|---|---|
@login_required |
Redirects to login page if not authenticated |
@login_required(login_url="/custom/") |
Custom login URL |
@permission_required("app.perm") |
Requires specific permission |
@permission_required("app.perm", raise_exception=True) |
Returns 403 instead of redirect |
@user_passes_test(lambda u: u.is_staff) |
Custom test function |
Decorator order
Place Django decorators after the route decorator:
Combining authentication methods¶
Accept multiple authentication methods:
@api.get(
"/data",
auth=[JWTAuthentication(), APIKeyAuthentication(api_keys=api_keys)],
guards=[IsAuthenticated()]
)
async def get_data(request):
backend = request.context.get("auth_backend")
return {"authenticated_via": backend}
Django-Bolt tries each backend in order until one succeeds. SessionAuthentication comes last in any order, because Rust can verify a token but not a session.
Authentication context¶
After successful authentication, request.context contains:
@api.get("/context", auth=[JWTAuthentication()], guards=[IsAuthenticated()])
async def show_context(request):
context = request.context
return {
"user_id": context.get("user_id"),
"is_staff": context.get("is_staff"),
"is_superuser": context.get("is_superuser"),
"auth_backend": context.get("auth_backend"), # "jwt" or "api_key"
"permissions": context.get("permissions", []),
"auth_claims": context.get("auth_claims", {}), # JWT only
}
Token revocation¶
For logout functionality, Django-Bolt supports token revocation stores.
When you pass revocation_store= to JWTAuthentication, every authenticated
request is checked against the store before reaching your handler — revoked
tokens are automatically rejected with 401 Unauthorized. You don't need
to call is_revoked() manually.
JTI is required
Configuring a revocation_store auto-enables require_jti=True on the
backend. Tokens without a jti claim are rejected at auth time. Make
sure your token issuer adds one — create_jwt_for_user(...,
extra_claims={"jti": uuid.uuid4().hex}) is the simplest way.
Always pass the token's exp
revoke() accepts an exp keyword argument. Pass the token's own
exp claim so the revocation entry expires exactly when the token
would have anyway — no security gap, no wasted storage. Without
exp, the entry falls back to the store's default_ttl (7 days
by default).
In-memory revocation (development)¶
from django_bolt.auth import JWTAuthentication, InMemoryRevocation, IsAuthenticated
store = InMemoryRevocation()
jwt_auth = JWTAuthentication(revocation_store=store)
@api.post("/logout", auth=[jwt_auth], guards=[IsAuthenticated()])
async def logout(request):
claims = request["context"]["auth_claims"]
await store.revoke(claims["jti"], exp=claims.get("exp"))
return {"status": "logged out"}
After this handler runs, any further request carrying the same token
will be rejected with 401 Unauthorized — automatically, no per-handler
check needed.
Single process only
InMemoryRevocation keeps state in process memory and does not
survive restarts or share state across workers. Use it for
development and tests only. Pick DjangoCacheRevocation or
DjangoORMRevocation in production.
Django cache revocation (production)¶
from django_bolt.auth import DjangoCacheRevocation
store = DjangoCacheRevocation(
cache_alias="default",
key_prefix="revoked_tokens:",
default_ttl=86400 * 7, # 7 days — fallback when revoke() is called without `exp`
)
Basic revocation works with any real Django cache backend. Refresh
rotation and version bumps require atomic add/incr; use Redis or
Memcached for multi-process production deployments. LocMem is suitable
only for a single process. File-based, database, dummy, and other
non-atomic caches are rejected for these operations. Configure the cache so security entries are not
evicted before their TTL.
Django ORM revocation¶
from django_bolt.auth import DjangoORMRevocation
store = DjangoORMRevocation(
model="myapp.RevokedToken", # 'app_label.ModelName' — exactly two parts
default_ttl=86400 * 7,
)
Requires a model with jti (unique, indexed) and expires_at
(indexed) fields. A periodic cleanup task should delete rows where
expires_at < now(). Slower than cache-based stores; only use when you
don't have a cache layer.
Custom revocation handler¶
Pass revoked_token_handler= to decide revocation with your own code.
Bolt calls it for each authenticated request, before the handler runs.
When it returns True, the request gets 401 Unauthorized.
A handler with one parameter gets the jti of the token. A handler with
two parameters also gets the verified claims. Bolt reads the number of
parameters one time, at registration.
async def is_revoked(jti: str) -> bool: ...
async def is_revoked_by_claims(jti: str, claims: dict) -> bool: ...
A route can have more than one JWT backend, for example one for each issuer. Bolt checks a token only with the handler of the backend that accepted the token.
Session-bound tokens (django-allauth)¶
Some issuers bind an access token to a server-side session. For example,
the JWT token strategy of django-allauth headless puts the session in the
sid claim. When the user logs out, allauth ends that session. Bolt still
accepts the access token until it expires, because the signature stays
valid.
To reject the token at logout, check its session in a revocation handler:
from allauth.headless.tokens.strategies.jwt.internal import get_token_session, validate_token_user
from django_bolt.auth import JWTAuthentication
from django_bolt.concurrency import sync_to_thread
def _session_is_valid(claims: dict) -> bool:
session = get_token_session(claims)
return session is not None and validate_token_user(claims, session) is not None
async def allauth_session_ended(jti: str, claims: dict) -> bool:
return not await sync_to_thread(_session_is_valid, claims)
auth = JWTAuthentication(revoked_token_handler=allauth_session_ended)
This is the same check as HEADLESS_JWT_STATEFUL_VALIDATION_ENABLED in
allauth. It reads the session for each request. The two functions are in
an internal module of allauth, so check them when you upgrade allauth.
Use HS256 with the same key
With HEADLESS_JWT_ALGORITHM = "HS256", allauth signs with SECRET_KEY.
JWTAuthentication() verifies with SECRET_KEY by default. For RS256,
pass algorithms=["RS256"] and the public key of HEADLESS_JWT_PRIVATE_KEY
as public_key=.
Per-call vs per-instance TTL¶
revoke() resolves the entry's lifetime in this order:
expkeyword arg — derived asmax(0, exp - now). Use this whenever you have the token's full claims (i.e. always, in a logout handler).store.default_ttl— set on the constructor. Used whenrevoke()is called withoutexp(e.g. an admin action that revokes by JTI)._DEFAULT_TTL_SECONDS— module-level fallback (7 days) if neither of the above is supplied.
Endpoints without authentication¶
Use AllowAny to explicitly allow unauthenticated access:
from django_bolt.auth import AllowAny
@api.get("/public", guards=[AllowAny()])
async def public_endpoint():
return {"message": "Anyone can access this"}
Global authentication¶
Configure default authentication and guards for all endpoints in settings.py:
# settings.py
from django_bolt.auth import JWTAuthentication, IsAuthenticated
# Default authentication backends for all endpoints
BOLT_AUTHENTICATION_CLASSES = [
JWTAuthentication(),
]
# Default permission guards for all endpoints
BOLT_DEFAULT_PERMISSION_CLASSES = [
IsAuthenticated(),
]
Security Notice
If BOLT_DEFAULT_PERMISSION_CLASSES is not set, it defaults to [AllowAny()] which means all endpoints are publicly accessible. Always configure both settings in production.
When configured, all endpoints require authentication by default:
# Uses global auth + guards - requires valid JWT
@api.get("/profile")
async def profile(request):
return {"user_id": request.user.id}
# Override guards for public endpoint
@api.get("/public", guards=[AllowAny()])
async def public():
return {"message": "Anyone can access"}
# Override auth for specific endpoint
@api.get("/api-only", auth=[APIKeyAuthentication(api_keys={"secret"})])
async def api_only(request):
return {"status": "ok"}