Skip to main content

A simple and fully-typed auth library for Django Ninja based on PyJWT.

Project description

Fallback image description

A session‑backed, fully‑typed authentication library for Django Ninja, powered by PyJWT.

PyPI CI Status License Python

❤️ Contributions Welcome! Feel free to submit a PR.


Why JWT Ninja

  • Stateful JWTs. Every token is tied to a DB-backed Session row — so you get token-based auth and revocation, device listing, and per-session state.
  • Fully typed. Protected routes receive an AuthedRequest with request.auth.user and request.auth.session typed; OpenAPI schemas are generated with typed error responses.
  • Batteries included. Five auth endpoints, a Django admin page, pluggable payload class for custom claims, pluggable authenticator for non-password login flows.

Table of Contents


Install

JWT Ninja is a standard Django app. Install it with uv or pip:

uv add jwtninja
# or
pip install jwtninja

Requires Python 3.12+ and Django 5.x.

Quick Start

1. Add jwt_ninja to your INSTALLED_APPS:

# settings.py
INSTALLED_APPS = [
    ...,
    "jwt_ninja",
]

2. Run migrations to create the Session table:

python manage.py migrate

3. Mount the router on your Ninja API and wire up the error handler:

from ninja import NinjaAPI
from jwt_ninja import APIError
from jwt_ninja.api import router as auth_router
from jwt_ninja.handlers import error_handler

api = NinjaAPI()
api.add_router("auth/", auth_router)
api.add_exception_handler(APIError, error_handler)

That's it — you now have /auth/login/, /auth/refresh/, /auth/sessions/, /auth/logout/, and /auth/logout/all/.

Protecting Your Views

Decorate any Ninja route with auth=JWTAuth() and annotate the request as AuthedRequest for typed access to the authenticated user and session:

from ninja import Router
from jwt_ninja import AuthedRequest, JWTAuth

router = Router()


@router.get("/profile/", auth=JWTAuth())
def profile(request: AuthedRequest):
    user = request.auth.user        # the Django User
    session = request.auth.session  # the jwt_ninja Session
    return {"username": user.username, "session_id": session.id}

Per-session state

Each Session has a JSONField called data that you can use as scratch space for per-login state (feature flags, device info, onboarding step, etc.):

@router.post("/set-theme/", auth=JWTAuth())
def set_theme(request: AuthedRequest, theme: str):
    request.auth.session.data["theme"] = theme
    request.auth.session.save()
    return {"ok": True}

Endpoints

Method Path Purpose Success Errors
POST /auth/login/ Issue a new access & refresh token pair 200 401
POST /auth/refresh/ Refresh an access token 200 400, 401
GET /auth/sessions/ List the caller's active sessions 200 401
POST /auth/logout/ Expire the caller's current session 200 401
POST /auth/logout/all/ Expire all of the caller's active sessions 200 401

POST /auth/login/

Request

{ "username": "alice", "password": "hunter2" }

Response (200)

{ "access_token": "eyJhbGci…", "refresh_token": "eyJhbGci…" }

POST /auth/refresh/

Request

{ "refresh_token": "eyJhbGci…" }

Response (200)

{ "access_token": "eyJhbGci…" }

Error Codes

All errors are returned as a JSON body {"error_code": "..."} with an appropriate HTTP status. Use these for i18n-friendly UX on the client.

Code Status Meaning
invalid_credentials 401 Username/password did not authenticate a user.
expired_token 401 Token's exp claim is in the past.
invalid_token 401 Token signature invalid, malformed, or wrong secret.
invalid_token_type 400 Sent an access token to /refresh/ or similar.
invalid_user 401 User attached to token no longer exists or is is_active=False.
session_not_found 401 Session referenced by the token has been deleted.
session_expired 401 Session was explicitly logged out or has an expired_at.

Configuration

All settings are Django settings prefixed with JWT_. Defaults shown below:

# settings.py
JWT_SECRET_KEY = SECRET_KEY                    # Defaults to Django's SECRET_KEY
JWT_ALGORITHM = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_SECONDS = 300          # 5 minutes
JWT_REFRESH_TOKEN_EXPIRE_SECONDS = 365 * 3600  # 1 year
JWT_SESSION_EXPIRE_SECONDS = 365 * 3600        # 1 year
JWT_USER_LOGIN_AUTHENTICATOR = "jwt_ninja.authenticators.django_user_authenticator"
JWT_PAYLOAD_CLASS = "jwt_ninja.types.JWTPayload"
Setting Type Description
JWT_SECRET_KEY str Signing key. Defaults to Django's SECRET_KEY.
JWT_ALGORITHM str PyJWT algorithm. Symmetric (HS*) or asymmetric (RS*, ES*, …).
JWT_ACCESS_TOKEN_EXPIRE_SECONDS int Lifetime of the short-lived access token.
JWT_REFRESH_TOKEN_EXPIRE_SECONDS int Lifetime of the refresh token.
JWT_SESSION_EXPIRE_SECONDS int Max age of the DB Session row before it's considered expired by housekeeping.
JWT_USER_LOGIN_AUTHENTICATOR str Dotted path to a callable (request, payload) -> User | None used by /auth/login/.
JWT_PAYLOAD_CLASS str Dotted path to a JWTPayload subclass if you need custom claims.

Custom Claims

Need to embed extra data in the token itself (team id, feature flags, etc.)? Subclass JWTPayload and point JWT_PAYLOAD_CLASS at it — encode and decode sites both use the configured class, so your custom fields round-trip end-to-end.

# myapp/auth.py
from jwt_ninja import JWTPayload


class CustomJWTPayload(JWTPayload):
    team_id: int
    email: str
# settings.py
JWT_PAYLOAD_CLASS = "myapp.auth.CustomJWTPayload"

Note: If you add required fields, you'll also need a custom authenticator (below) or a custom login endpoint that knows how to populate them.

Overriding user_id

If your User model uses a non-integer primary key (UUIDField, CharField, etc.), override user_id on your payload subclass so the declared type matches what user.id actually is at runtime:

from uuid import UUID
from jwt_ninja import JWTPayload


class UUIDJWTPayload(JWTPayload):
    user_id: UUID  # or str, depending on your User PK


class StrPKJWTPayload(JWTPayload):
    user_id: str

Pydantic is strict about this: user_id=user.id at the login site passes the PK through without coercion, so the declared type and the runtime type must agree. The default JWTPayload declares user_id: int, which matches Django's default AutoField primary key.

Custom Authenticator

If you don't use Django's username/password flow (SSO, magic links, OTP, etc.), point JWT_USER_LOGIN_AUTHENTICATOR at a callable that returns a User or None:

# myapp/auth.py
from django.http import HttpRequest
from django.contrib.auth import get_user_model

User = get_user_model()


def magic_link_authenticator(request: HttpRequest, payload) -> User | None:
    try:
        return User.objects.get(magic_token=payload.token, magic_token_expired_at__gt=now())
    except User.DoesNotExist:
        return None
# settings.py
JWT_USER_LOGIN_AUTHENTICATOR = "myapp.auth.magic_link_authenticator"

The callable receives the raw HttpRequest and the parsed LoginSchema payload. Returning None produces a 401 invalid_credentials response.

Session Management

The Session model exposes a small set of helpers for common auth chores.

Invalidate all sessions (e.g., on password change)

from jwt_ninja.models import Session

# On password change, log the user out of every device
Session.invalidate_all_user_sessions(user)

This is a single bulk UPDATE that flips expired_at on every active session for the user.

Purge expired sessions (e.g., nightly cron)

Over time you'll accumulate rows for sessions that are long past their expired_at. Purge them with a scheduled task:

from jwt_ninja.models import Session

# django-crontab, Celery beat, management command, whatever you prefer
Session.purge_expired_sessions()

Inspect sessions in the admin

jwt_ninja registers a read-only SessionAdmin so ops can see who's logged in from where — just visit /admin/jwt_ninja/session/.

Development

# Clone and install
git clone https://github.com/dvf/jwt-ninja
cd jwt-ninja
uv sync

# Run tests
uv run pytest

# Lint + format
uv run ruff check .
uv run ruff format .

# Static type check
uv run pyrefly check

PRs are gated on all four checks. See .github/workflows/check-and-test.yml.


License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

jwtninja-0.2.1.tar.gz (30.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jwtninja-0.2.1-py3-none-any.whl (24.4 kB view details)

Uploaded Python 3

File details

Details for the file jwtninja-0.2.1.tar.gz.

File metadata

  • Download URL: jwtninja-0.2.1.tar.gz
  • Upload date:
  • Size: 30.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for jwtninja-0.2.1.tar.gz
Algorithm Hash digest
SHA256 0547c77c69a529ce70748cafbd40213e558ba81e33b5da41f3453fd8a10db828
MD5 752f645d19fc15d85b08fe1f67d8a378
BLAKE2b-256 f987d8e6f59c20dd9aa0c5b73bb489f88088131f60d37a667d2acf58e72a98b1

See more details on using hashes here.

File details

Details for the file jwtninja-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: jwtninja-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 24.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for jwtninja-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b233e0a01c6365499581d55e3abc6c7ea5bfcbc17d72b9c861eb5c3af5a8dde8
MD5 a224443c6a538096f4b252f535bc9827
BLAKE2b-256 9aea7fcfc8c7d6c5aa111ca0e9ef5921e076ac9a1bfa16bd372854426bffd431

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page