Skip to content

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:

  1. Expects a JWT token in the Authorization header: Bearer <token>
  2. Validates the token signature and expiration
  3. Rejects the request with 401 if the token is invalid
  4. Populates request.context with 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 key
  • is_staff - Staff status
  • is_superuser - Superuser status
  • username - Username
  • exp - 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:

token = create_jwt_for_user(
    user,
    extra_claims={"permissions": list(user.get_all_permissions())}
)

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 (secret is the shared secret)
  • RS256, RS384, RS512 - RSA with SHA-2 (secret is a PEM public key)
  • PS256, PS384, PS512 - RSA-PSS with SHA-2 (secret is a PEM public key)
  • ES256, ES384 - ECDSA with SHA-2 (secret is a PEM public key)
  • EdDSA - Ed25519 (secret is 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:

JWTAuthentication(
    oidc_issuer="https://issuer.example",
    audience="my-api",
)

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.

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:

auth=[
    JWTAuthentication(cookie="access_token"),
    JWTAuthentication(),
]

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:

APIKeyAuthentication(
    api_keys=api_keys,
    header="Authorization"  # Or any custom header
)

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:

  1. Bolt adds Django's SessionMiddleware and AuthenticationMiddleware to 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 second SessionMiddleware would load and save the session again.
  2. Rust rejects a request with no session cookie with 401. No Python runs.
  3. For a request with the cookie, Django loads the session and the user.
  4. 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:

# Load session and auth middleware from Django settings
api = BoltAPI(django_middleware=True)

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:

@api.get("/path")      # First: route decorator
@login_required        # Second: auth decorator
async def view():
    ...

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:

  1. exp keyword arg — derived as max(0, exp - now). Use this whenever you have the token's full claims (i.e. always, in a logout handler).
  2. store.default_ttl — set on the constructor. Used when revoke() is called without exp (e.g. an admin action that revokes by JTI).
  3. _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"}