Skip to content
Guide

JWT authentication

This page shows how to verify JWT tokens sent in the Authorization: Bearer header, how to issue tokens, and how to choose which endpoints require one.

Configure the keys

Put the signing keys and the default claims in your settings:

project/settings.py
import os

from joserfc import jwk

JWT_PRIVATE_KEY = jwk.RSAKey.import_key(os.environ["JWT_PRIVATE_KEY"])
JWT_PUBLIC_KEY = jwk.RSAKey.import_key(os.environ["JWT_PUBLIC_KEY"])
JWT_ISSUER = "blog-api"
JWT_AUDIENCE = "blog-clients"
Setting What it does
JWT_PRIVATE_KEY Key that encode_jwt() signs tokens with
JWT_PUBLIC_KEY Key that decode_jwt() and auth classes verify tokens with
JWT_ISSUER Added as iss to every token you encode
JWT_AUDIENCE Added as aud to every token you encode
JWT_ALGORITHM Signature algorithm used when you don't pass one. Default "RS256"

Keys are joserfc keys: jwk.RSAKey, jwk.ECKey or jwk.OctKey. Any other value raises ValueError.

Write an auth class

Subclass AsyncJwtBearer. The token is verified first, then auth_handler runs and returns the user:

blog/auth.py
from django.conf import settings
from django.contrib.auth import get_user_model
from ninja_aio.auth import AsyncJwtBearer

User = get_user_model()


class JWTAuth(AsyncJwtBearer):
    jwt_public = settings.JWT_PUBLIC_KEY
    claims = {
        "iss": {"essential": True, "value": settings.JWT_ISSUER},
        "aud": {"essential": True, "value": settings.JWT_AUDIENCE},
        "sub": {"essential": True},
        "scope": {"values": ["read", "write"]},
    }

    async def auth_handler(self, request):
        user_id = self.dcd.claims["sub"]
        return await User.objects.filter(pk=user_id, is_active=True).afirst()
Attribute Default What it does
jwt_public JWT_PUBLIC_KEY Key that verifies the token signature
claims required Rules for the token claims. {} checks none
algorithms [JWT_ALGORITHM] Signature algorithms you accept

An auth class without claims raises ImproperlyConfigured when you create it.

Each entry of claims is a claim name with its rules:

Rule What it does
"essential": True The claim must be in the token
"value": x The claim must equal x
"values": [x, y] The claim must be one of the listed values

self.dcd.claims holds the claims of the token of the current request.

The value auth_handler returns is available as request.auth in your endpoints and hooks. When it returns None or another falsy value, the request gets 401. An invalid signature or a failed claim rule also returns 401.

Issue tokens

encode_jwt() signs a token with JWT_PRIVATE_KEY:

Python
from ninja_aio.auth import encode_jwt

token = encode_jwt({"sub": str(user.pk), "scope": "write"}, duration=3600)
Parameter Default What it does
claims required Claims to put in the token
duration required Lifetime in seconds
private_key JWT_PRIVATE_KEY Key to sign with
algorithm JWT_ALGORITHM, or "RS256" Signature algorithm

The token also gets iat, nbf and exp. iss and aud come from JWT_ISSUER and JWT_AUDIENCE unless you pass them in claims. When neither is set, encode_jwt() raises ValueError. The claims dict you pass is not changed.

For a complete login endpoint, see Add authentication.

decode_jwt() verifies a token and returns it, for example to read a token outside a request:

Python
from ninja_aio.auth import decode_jwt

token = decode_jwt(raw_token)
user_id = token.claims["sub"]
Parameter Default What it does
token required The token string
public_key JWT_PUBLIC_KEY Key to verify with
algorithms [JWT_ALGORITHM] Signature algorithms you accept

It raises a joserfc JoseError when the token is not valid. decode_jwt() checks only the signature. Check the claims you need yourself.

Use an EC or HMAC key

The default algorithm is RS256. With another key type, set JWT_ALGORITHM in your settings, for example JWT_ALGORITHM = "ES256". encode_jwt(), decode_jwt() and the auth classes then use it. You can still pass algorithm or algorithms to override it in one place.

EC key

Bash
openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem
openssl ec -in ec-private.pem -pubout -out ec-public.pem
project/settings.py
from pathlib import Path

from joserfc import jwk

JWT_PRIVATE_KEY = jwk.ECKey.import_key(Path("ec-private.pem").read_text())
JWT_PUBLIC_KEY = jwk.ECKey.import_key(Path("ec-public.pem").read_text())
Python
token = encode_jwt({"sub": str(user.pk)}, duration=3600, algorithm="ES256")


class JWTAuth(AsyncJwtBearer):
    jwt_public = settings.JWT_PUBLIC_KEY
    algorithms = ["ES256"]
    claims = {"sub": {"essential": True}}

HMAC secret

With HMAC, the same secret signs and verifies:

project/settings.py
JWT_PRIVATE_KEY = JWT_PUBLIC_KEY = jwk.OctKey.import_key(os.environ["JWT_SECRET"])
Python
token = encode_jwt({"sub": str(user.pk)}, duration=3600, algorithm="HS256")


class JWTAuth(AsyncJwtBearer):
    jwt_public = settings.JWT_PUBLIC_KEY
    algorithms = ["HS256"]
    claims = {"sub": {"essential": True}}

Without JWT_ALGORITHM = "HS256", call decode_jwt(token, algorithms=["HS256"]) too, or it rejects the token.

Choose which endpoints need a token

Set auth at the level you need. The most specific setting wins:

blog/api.py
from ninja_aio import NinjaAIO, APIViewSet, action

from .auth import JWTAuth
from .models import Article

api = NinjaAIO(title="Blog API", auth=[JWTAuth()])


@api.viewset(model=Article)
class ArticleViewSet(APIViewSet):
    get_auth = None

    @action(detail=False, url_path="stats", auth=[JWTAuth()])
    async def stats(self, request):
        return {"articles": await Article.objects.acount()}

Here every endpoint of the API needs a token, reading articles is public, and GET /api/articles/stats still needs a token.

Where Applies to
NinjaAIO(auth=[...]) Every endpoint of the API
Viewset auth Every endpoint of the viewset
get_auth, post_auth, patch_auth, delete_auth Endpoints of one HTTP method, including bulk endpoints and custom actions
auth= on @action or @on One custom action
auth= on @api.get, @api.post, ... One plain endpoint

A setting you leave out inherits from the level above. None makes the endpoints public. For example, auth=None on a login endpoint keeps it open when the whole API needs a token.

Note

Many-to-many endpoints use the auth of the M2MRelationSchema first, then the viewset m2m_auth, then get_auth for the list or patch_auth for add and remove, falling back to auth. M2MRelationSchema(auth=None) makes one relation public.

The same auth classes work in sync viewsets (execution_mode = "sync"). You keep auth_handler as async def.

Try it in the interactive docs

Open /api/docs, click Authorize, and paste a token. The docs then send it with every request to a protected endpoint.

See also