Skip to main content

Stateless Bearer token authentication for Django REST Framework — HMAC-SHA256 access tokens, rotating refresh tokens with replay detection, and cross-subdomain exchange tokens.

Project description

django-tokenforge

Stateless Bearer token authentication for Django REST Framework.

TokenForge provides a complete, production-ready token lifecycle for SPAs and mobile apps: HMAC-SHA256 signed access tokens with zero database queries per request, refresh tokens with automatic rotation and replay detection, and one-time exchange tokens for cross-subdomain authentication handoff via Redis.

Designed as a security-first drop-in replacement for django-rest-knox when you need stateless access tokens and proper refresh token rotation.


Contents


Features

Feature Details
Stateless access tokens HMAC-SHA256 signed, zero DB queries per authenticated request
Refresh token rotation Token family tracking — every rotation issues a new token and revokes the old one
Replay detection Reusing a revoked refresh token immediately revokes the entire token family
Exchange tokens One-time Redis-backed tokens for cross-subdomain SSO handoff
Swappable token model Extend AbstractRefreshToken exactly like Django's AUTH_USER_MODEL
Configurable callbacks Risk event handler, device session validator, device session loader, user serializer
Device fingerprinting SHA-256(IP + User-Agent) binding with configurable soft/strict enforcement
Safe X-Forwarded-For NUM_PROXIES-aware IP extraction — not blindly trusting the leftmost XFF value
Anti-CSRF X-Requested-With: XMLHttpRequest required on the refresh endpoint
Django signals token_rotated, token_revoked, replay_detected
Knox-style settings Single TOKENFORGE = {} dict — no scattered settings
Admin integration Refresh tokens visible in Django admin; token hash never exposed

How It Works

Token Architecture

TokenForge issues two tokens on login:

┌─────────────────────────────────────────────────────────────────┐
│                         LOGIN RESPONSE                          │
│                                                                 │
│  Body:   { "access_token": "...", "expires_in": 900 }          │
│  Cookie: Set-Cookie: refresh_token=...; HttpOnly; Secure;       │
│                       Path=/api/v1/auth/token/refresh/          │
└─────────────────────────────────────────────────────────────────┘

Access Token — stateless, lives in JS memory only

  • Format: base64url(json_payload).base64url(hmac_sha256_signature)
  • Verified with a pure HMAC computation — no database touch
  • 15-minute lifetime by default
  • Sent by the client as Authorization: Bearer <token> on every request

Refresh Token — database-backed, lives in an HttpOnly cookie

  • 384 bits of entropy (secrets.token_urlsafe(48))
  • Only the SHA-256 hash is stored in the database; the raw value is sent once
  • Path-scoped to /api/v1/auth/token/refresh/ — the browser only sends it to that single endpoint
  • 30-day lifetime with sliding rotation

Refresh Token Rotation & Replay Detection

Login          →  RefreshToken A  (family = F1)
First refresh  →  RefreshToken A revoked
                  RefreshToken B created  (family = F1)
Second refresh →  RefreshToken B revoked
                  RefreshToken C created  (family = F1)

Attacker replays A  →  Entire family F1 revoked (A, B, C)
                        replay_detected signal fired
                        RISK_EVENT_HANDLER called (if configured)

Concurrent rotation is protected by SELECT FOR UPDATE on the token row — two simultaneous refresh requests cannot both succeed on the same token.

Exchange Tokens (Cross-Subdomain SSO)

app.example.com                          admin.example.com
      │                                         │
      │  POST /exchange/create/                 │
      │  { "target_origin": "https://admin..." }│
      │◄─ { "exchange_token": "...", "ttl": 60 }│
      │                                         │
      │  redirect ?token=<exchange_token> ─────►│
      │                                         │  POST /exchange/redeem/
      │                                         │  { "exchange_token": "..." }
      │                                         │◄─ { "access_token": "..." }
      │                                         │   Set-Cookie: refresh_token=...

Exchange tokens are single-use, origin-bound, Redis-backed, and expire in 60 seconds.


Requirements

  • Python 3.10+
  • Django 4.2+
  • Django REST Framework 3.14+
  • Redis (for exchange tokens — via Django's cache framework)
  • PostgreSQL recommended (select_for_update(of=("self",)) is used for safe concurrent rotation)

Installation

1. Add to your project

Copy the tokenforge/ directory into your Django project's source root, or install via PyPI when available:

pip install django-tokenforge

2. Add to INSTALLED_APPS

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

3. Run migrations

python manage.py migrate tokenforge

4. Generate a dedicated signing key

openssl rand -base64 64

Add the output to your environment — never reuse SECRET_KEY:

# .env
TOKENFORGE_SIGNING_KEY=your_generated_value_here

Quick Start

Minimal Settings

# settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "tokenforge.authentication.BearerTokenAuthentication",
    ],
}

TOKENFORGE = {
    "ACCESS_TOKEN_SIGNING_KEY": env("TOKENFORGE_SIGNING_KEY"),  # Required
    "REFRESH_TOKEN_COOKIE_SECURE": True,   # False only for local dev
}

Wire Up URLs

# urls.py
from django.urls import path, include

urlpatterns = [
    path("api/v1/auth/", include("tokenforge.urls")),
]

This registers three endpoints — see Endpoints for full details.

Issue Tokens After Login

TokenForge does not include a login view — authentication is your application's responsibility. After verifying credentials and any MFA, issue tokens like this:

from tokenforge.tokens import create_access_token
from tokenforge.services.refresh import create_refresh_token
from tokenforge.cookies import set_refresh_cookie
from tokenforge.fingerprinting import fingerprint_for_request

def my_login_view(request, user):
    fingerprint = fingerprint_for_request(request)

    raw_refresh, refresh_instance = create_refresh_token(
        user=user,
        fingerprint=fingerprint,
        # device_session=device_session_instance,  # optional
    )

    access_token, expires_in = create_access_token(
        user_id=str(user.id),
        fingerprint=fingerprint,
        # device_session_id=str(device_session.id),  # optional
        # tenant_slug="my-tenant",                   # optional
    )

    response = Response({
        "access_token": access_token,
        "expires_in": expires_in,
    })
    set_refresh_cookie(response, raw_refresh)
    return response

Protect Views

from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView

class MyProtectedView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        # request.user  — the authenticated User instance
        # request.auth  — dict: {sub, sid, fp, tnt, iat, exp, v, token_type}
        user_id    = request.auth["sub"]   # UUID string
        session_id = request.auth["sid"]   # device session UUID string
        tenant     = request.auth["tnt"]   # tenant slug (or empty string)
        return Response({"user": str(request.user)})

Revoke Tokens (Logout)

from tokenforge.services.refresh import revoke_all_for_user, revoke_by_device_session
from tokenforge.cookies import expire_refresh_cookie

# Single-device logout — revoke only the current session's tokens
def logout_view(request):
    session_id = request.auth.get("sid")
    if session_id:
        device_session = MyDeviceSession.objects.get(id=session_id)
        revoke_by_device_session(device_session)
    response = Response({"detail": "Logged out."})
    expire_refresh_cookie(response)
    return response

# All-device logout — revoke every refresh token for this user
def logout_all_view(request):
    revoke_all_for_user(request.user)
    response = Response({"detail": "Logged out from all devices."})
    expire_refresh_cookie(response)
    return response

Configuration Reference

All settings live in a single TOKENFORGE dictionary.

TOKENFORGE = {
    # ── Access Token ─────────────────────────────────────────────────────────
    "ACCESS_TOKEN_LIFETIME_SECONDS": 900,
    # Required. Generate with: openssl rand -base64 64
    # Must be distinct from SECRET_KEY — a compromised SECRET_KEY must not
    # also compromise access token signatures.
    "ACCESS_TOKEN_SIGNING_KEY": None,

    # ── Refresh Token ─────────────────────────────────────────────────────────
    "REFRESH_TOKEN_LIFETIME_DAYS": 30,
    "REFRESH_TOKEN_BYTES": 48,                    # 384 bits of entropy
    "REFRESH_TOKEN_COOKIE_NAME": "refresh_token",
    "REFRESH_TOKEN_COOKIE_PATH": "/api/v1/auth/token/refresh/",
    "REFRESH_TOKEN_COOKIE_SECURE": True,          # Secure by default; set False only for local dev
    "REFRESH_TOKEN_COOKIE_SAMESITE": "Lax",
    "REFRESH_TOKEN_COOKIE_DOMAIN": None,          # None = host-only; ".example.com" for cross-subdomain
    "TOKEN_MODEL": "tokenforge.RefreshToken",

    # ── Exchange Token ────────────────────────────────────────────────────────
    "EXCHANGE_TOKEN_TTL_SECONDS": 60,
    "EXCHANGE_TOKEN_BYTES": 48,                   # 384 bits of entropy
    "EXCHANGE_TOKEN_MAX_ACTIVE": 5,               # Max concurrent tokens per user

    # ── Security ──────────────────────────────────────────────────────────────
    "FINGERPRINT_ENABLED": True,
    # False (default) = soft-warn on access token fingerprint drift.
    # True = hard-fail. Only enable if your users have stable IPs
    # (e.g. internal tools behind a fixed VPN). Mobile/SPA users on
    # cellular will hit spurious logouts if this is True.
    # Hard-fail is always enforced at refresh token rotation regardless.
    "FINGERPRINT_STRICT_ACCESS_TOKEN": False,
    "REPLAY_DETECTION_ENABLED": True,
    "RISK_SCORE_THRESHOLD": 60,   # Block refresh if device session risk_score >= threshold
    "BOT_SCORE_THRESHOLD": 90,    # Block refresh if device session bot_score >= threshold
    "USER_CACHE_TTL": 300,        # Seconds to cache user objects per access token verify

    # ── Callbacks ─────────────────────────────────────────────────────────────
    # All values must be dotted paths to module-level functions (not class methods).
    "RISK_EVENT_HANDLER": None,        # fn(event_type, severity, user, request, **kw)
    "DEVICE_SESSION_VALIDATOR": None,  # fn(device_session) -> None or raise ValueError
    "DEVICE_SESSION_LOADER": None,     # fn(session_id, user) -> session or None
    "USER_SERIALIZER": None,           # DRF Serializer class for exchange redeem response
    "FINGERPRINT_FUNCTION": "tokenforge.fingerprinting.fingerprint_for_request",

    # ── Anti-CSRF ─────────────────────────────────────────────────────────────
    "REQUIRE_XHR_HEADER": True,  # Require X-Requested-With: XMLHttpRequest on /token/refresh/
}

Settings Table

Setting Type Default Description
ACCESS_TOKEN_LIFETIME_SECONDS int 900 Access token validity window in seconds
ACCESS_TOKEN_SIGNING_KEY str None Required. Dedicated HMAC-SHA256 signing key. Never share with SECRET_KEY
REFRESH_TOKEN_LIFETIME_DAYS int 30 Refresh token validity in days
REFRESH_TOKEN_BYTES int 48 Entropy bytes for refresh token generation (384 bits)
REFRESH_TOKEN_COOKIE_NAME str "refresh_token" Cookie name for the refresh token
REFRESH_TOKEN_COOKIE_PATH str "/api/v1/auth/token/refresh/" Cookie path scope — browser only sends cookie to this path
REFRESH_TOKEN_COOKIE_SECURE bool True HTTPS-only cookie. Set False only in local dev
REFRESH_TOKEN_COOKIE_SAMESITE str "Lax" Cookie SameSite attribute
REFRESH_TOKEN_COOKIE_DOMAIN str|None None None = host-only. Set ".example.com" for cross-subdomain refresh
TOKEN_MODEL str "tokenforge.RefreshToken" Dotted path to the active refresh token model
EXCHANGE_TOKEN_TTL_SECONDS int 60 Exchange token lifetime in seconds
EXCHANGE_TOKEN_BYTES int 48 Entropy bytes for exchange token generation
EXCHANGE_TOKEN_MAX_ACTIVE int 5 Maximum concurrent active exchange tokens per user
FINGERPRINT_ENABLED bool True Enable device fingerprint computation and binding
FINGERPRINT_STRICT_ACCESS_TOKEN bool False Hard-fail on access token fingerprint drift. Off by default — see Security Notes
REPLAY_DETECTION_ENABLED bool True Revoke entire token family when a revoked token is reused
RISK_SCORE_THRESHOLD int 60 Reject refresh rotation if device_session.risk_score >= this
BOT_SCORE_THRESHOLD int 90 Reject refresh rotation if device_session.bot_score >= this
USER_CACHE_TTL int 300 Seconds to cache User objects in Redis after first DB load
RISK_EVENT_HANDLER str|None None Dotted path to risk event callback function
DEVICE_SESSION_VALIDATOR str|None None Dotted path to device session validation function
DEVICE_SESSION_LOADER str|None None Dotted path to device session loader function
USER_SERIALIZER str|None None Dotted path to DRF Serializer for exchange redeem user payload
FINGERPRINT_FUNCTION str "tokenforge.fingerprinting.fingerprint_for_request" Dotted path to fingerprint computation function
REQUIRE_XHR_HEADER bool True Anti-CSRF: require X-Requested-With: XMLHttpRequest on /token/refresh/

Swappable Token Model

Like Django's AUTH_USER_MODEL, the refresh token model is swappable. This lets you add custom fields — most commonly a foreign key to your own device session model.

Default Model Fields

The built-in tokenforge.RefreshToken provides:

Field Type Description
id UUIDField Primary key
user ForeignKey The owning user
token_hash CharField SHA-256 hex digest of the raw token (unique, indexed)
token_family UUIDField Groups all rotated descendants for replay detection (indexed)
fingerprint CharField SHA-256(IP|UA) computed at issuance time
expires_at DateTimeField Expiry timestamp
revoked BooleanField Revocation flag
revoked_at DateTimeField Revocation timestamp (nullable)
replaced_by ForeignKey(self) Points to the token that replaced this one on rotation
created_at DateTimeField Auto-set on creation

Custom Model

# myapp/models.py
from tokenforge.models import AbstractRefreshToken
from django.db import models

class MyRefreshToken(AbstractRefreshToken):
    device_session = models.ForeignKey(
        "myapp.DeviceSession",
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        related_name="refresh_tokens",
    )

    class Meta(AbstractRefreshToken.Meta):
        db_table = "my_refresh_tokens"
# settings.py
TOKENFORGE = {
    "TOKEN_MODEL": "myapp.MyRefreshToken",
}
# Required: Django's swappable registry needs this as a top-level setting.
# Must match TOKENFORGE["TOKEN_MODEL"] exactly.
TOKENFORGE_TOKEN_MODEL = "myapp.MyRefreshToken"

Set TOKEN_MODEL before your first migration. Changing the model after data exists requires a manual data migration.

TOKENFORGE_TOKEN_MODEL is mandatory when using a custom model. Django resolves swappable model identities via top-level settings (like AUTH_USER_MODEL). The TOKENFORGE dict alone is not sufficient — you must also declare TOKENFORGE_TOKEN_MODEL at the top level of your settings file, set to the same dotted path.

Custom Model app_label Requirement

Always declare an explicit app_label in your custom model's Meta class:

class MyRefreshToken(AbstractRefreshToken):
    device_session = models.ForeignKey(...)

    class Meta(AbstractRefreshToken.Meta):
        app_label = "myapp"          # Required — prevents RuntimeError on app startup
        db_table = "my_refresh_tokens"

Without app_label, Django may raise RuntimeError: Model class myapp.models.MyRefreshToken doesn't declare an explicit app_label when the model module is imported before the app registry is fully populated.

Resolving the Active Model

from tokenforge.models import get_token_model

TokenModel = get_token_model()
active_tokens = TokenModel.objects.filter(user=user, revoked=False)

Endpoints

Include TokenForge's URL patterns in your project:

path("api/v1/auth/", include("tokenforge.urls")),

POST /api/v1/auth/token/refresh/

Exchange the refresh_token cookie for a new access token. The refresh token is rotated on every successful call.

Required headers:

X-Requested-With: XMLHttpRequest

Request: No body. The refresh_token HttpOnly cookie is sent automatically by the browser.

Response 200 OK:

{
    "access_token": "<new_access_token>",
    "expires_in": 900
}

A rotated refresh_token cookie is set in the response.

Response 401 Unauthorized — token absent, expired, or revoked:

{ "detail": "Session expired" }

Response 403 Forbidden — missing X-Requested-With header:

{ "detail": "Authentication failed" }

POST /api/v1/auth/exchange/create/

Create a one-time exchange token for cross-subdomain navigation. Requires a valid Bearer token.

Request body:

{
    "target_origin": "https://admin.example.com"
}

Response 200 OK:

{
    "exchange_token": "<opaque_token>",
    "ttl": 60
}

Response 429 Too Many Requests — more than EXCHANGE_TOKEN_MAX_ACTIVE tokens pending:

{ "detail": "Too many pending exchange tokens" }

POST /api/v1/auth/exchange/redeem/

Redeem a one-time exchange token. Issues a new access token and sets a new refresh_token cookie. No Bearer token required.

Required headers:

Origin: https://admin.example.com

The browser sets the Origin header automatically on cross-origin requests. Do not set it manually.

Request body:

{
    "exchange_token": "<token_from_create>"
}

Response 200 OK:

{
    "access_token": "<new_access_token>",
    "expires_in": 900,
    "user": { ... }
}

The "user" key is only present when USER_SERIALIZER is configured.

Response 401 Unauthorized — invalid token, already used, expired, or origin mismatch:

{ "detail": "Authentication failed" }

Callbacks

All callback settings take a dotted import path to a module-level function. DRF's import_from_string resolves dotted paths as module.attribute and cannot import class methods. Wrap them in module-level functions:

# Wrong — class method
"RISK_EVENT_HANDLER": "myapp.services.RiskService.record_event"

# Correct — module-level function
"RISK_EVENT_HANDLER": "myapp.services.record_risk_event"

RISK_EVENT_HANDLER

Called on security events: replay detection and fingerprint drift on refresh rotation.

# myapp/services.py

def record_risk_event(
    *,
    event_type: str,   # "token_replay_detected" | "fingerprint_drift"
    severity: int,     # 30 = warning, 90 = critical
    user,
    request=None,
    **kwargs,          # device_session, fingerprint, metadata, risk_score, bot_score
):
    RiskEvent.objects.create(
        type=event_type,
        severity=severity,
        user=user,
        ip=get_client_ip(request) if request else "",
    )
TOKENFORGE = {
    "RISK_EVENT_HANDLER": "myapp.services.record_risk_event",
}

DEVICE_SESSION_VALIDATOR

Called during refresh token rotation to validate that the associated device session is still permitted. Raise any exception to block the rotation.

def validate_device_session(device_session) -> None:
    if device_session.revoked:
        raise ValueError("Session revoked")
    if device_session.risk_score >= 80:
        raise ValueError("Session risk score too high")

If not configured, TokenForge applies a built-in default check that validates revoked, risk_score, and bot_score fields if they exist on the session model.

DEVICE_SESSION_LOADER

Called during exchange token redemption to hydrate the device session from the sid claim.

def load_device_session(session_id: str, user) -> object | None:
    try:
        return DeviceSession.objects.get(id=session_id, user=user, revoked=False)
    except DeviceSession.DoesNotExist:
        return None

USER_SERIALIZER

A DRF Serializer class whose output is included in the exchange redeem response under the "user" key.

TOKENFORGE = {
    "USER_SERIALIZER": "myapp.serializers.UserSerializer",
}

FINGERPRINT_FUNCTION

Override the default IP + User-Agent fingerprinting entirely:

def my_fingerprint(request) -> str:
    import hashlib
    parts = [
        get_client_ip(request),
        request.META.get("HTTP_USER_AGENT", ""),
        request.META.get("HTTP_ACCEPT_LANGUAGE", ""),
    ]
    return hashlib.sha256("|".join(parts).encode()).hexdigest()
TOKENFORGE = {
    "FINGERPRINT_FUNCTION": "myapp.auth.my_fingerprint",
}

Django Signals

from tokenforge.signals import token_rotated, token_revoked, replay_detected
from django.dispatch import receiver

@receiver(token_rotated)
def on_rotation(sender, user, request, **kwargs):
    """Fired after a refresh token is successfully rotated."""
    logger.info("Token rotated for user %s", user.id)


@receiver(token_revoked)
def on_revocation(sender, family, count, reason, **kwargs):
    """
    Fired after tokens are revoked.
    reason: "manual" | "replay_detection"
    count:  number of tokens revoked in this operation
    """
    logger.warning("Revoked %d tokens in family %s (reason: %s)", count, family, reason)


@receiver(replay_detected)
def on_replay(sender, user, family, request, **kwargs):
    """
    Fired when a revoked refresh token is reused.
    This is a strong indicator of token theft or session compromise.
    """
    notify_security_team(user, f"Replay attack detected — family {family} fully revoked")

Cache Invalidation

TokenForge caches User objects for USER_CACHE_TTL seconds (default 5 minutes) to eliminate per-request DB queries. When you deactivate a user, change their role, or modify permissions, call invalidate_user_cache so the next request gets a fresh DB lookup immediately:

from tokenforge.authentication import invalidate_user_cache

# After deactivating a user
user.is_active = False
user.save()
invalidate_user_cache(str(user.id))

# After changing roles or permissions
user.role = "viewer"
user.save()
invalidate_user_cache(str(user.id))

Set USER_CACHE_TTL to 0 to disable caching entirely if your application requires instant permission propagation on every request.


Frontend Integration

Token Storage

Token Where to store Why
Access token JavaScript memory only localStorage is readable by any JS on the page (XSS). 15-min window limits exposure
Refresh token HttpOnly cookie (automatic) Set by the server — JS cannot read or modify it
Exchange token URL query param (transient) Single-use, 60s TTL — remove from URL immediately after redemption

In-Memory Token Store

// auth-store.ts — module-level singleton
let accessToken: string | null = null;
let expiresAt: number = 0;

export const setTokens = (token: string, expiresIn: number) => {
    accessToken = token;
    expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
};

export const getAccessToken = (): string | null => accessToken;

export const isExpired = (): boolean =>
    Math.floor(Date.now() / 1000) >= expiresAt - 30; // 30s buffer

export const clearTokens = () => {
    accessToken = null;
    expiresAt = 0;
};

Silent Refresh with Race Condition Protection

Three simultaneous requests with an expired token must not each send a separate refresh call. The second call would replay an already-rotated token, triggering replay detection and killing the entire session.

// auth.ts
let refreshPromise: Promise<string> | null = null;

export async function silentRefresh(): Promise<string> {
    // Deduplicate: queue behind any in-flight refresh
    if (refreshPromise) return refreshPromise;

    refreshPromise = fetch("/api/v1/auth/token/refresh/", {
        method: "POST",
        credentials: "include",  // Sends the HttpOnly refresh_token cookie
        headers: {
            "X-Requested-With": "XMLHttpRequest",  // Required — 403 without this
        },
    })
    .then(async (res) => {
        if (!res.ok) {
            clearTokens();
            window.location.href = "/login";
            throw new Error("Session expired");
        }
        const { access_token, expires_in } = await res.json();
        setTokens(access_token, expires_in);
        scheduleRefresh(expires_in);
        return access_token;
    })
    .finally(() => {
        refreshPromise = null;
    });

    return refreshPromise;
}

Axios Interceptors

// api.ts
import axios from "axios";
import { getAccessToken, isExpired, silentRefresh, clearTokens } from "./auth";

const api = axios.create({
    baseURL: "https://api.example.com/api/v1/",
    withCredentials: true,  // Required for the refresh cookie to be sent cross-origin
});

// Before every request: attach the access token, refreshing proactively if expired
api.interceptors.request.use(async (config) => {
    if (isExpired()) {
        await silentRefresh();
    }
    const token = getAccessToken();
    if (token) {
        config.headers["Authorization"] = `Bearer ${token}`;
    }
    return config;
});

// After every response: backstop handler for unexpected 401s
api.interceptors.response.use(
    (response) => response,
    async (error) => {
        const original = error.config;
        if (error.response?.status === 401 && !original._retried) {
            original._retried = true;
            await silentRefresh();
            return api(original);
        }
        return Promise.reject(error);
    }
);

export default api;

Page Load Recovery

On hard refresh, JS memory is wiped. Attempt a silent refresh before rendering protected routes — users with a valid cookie should never see the login page.

async function bootstrap() {
    showSplashScreen();
    try {
        await silentRefresh();
        await hydrateUserProfile();  // e.g. GET /api/v1/auth/whoami/
        renderApp();
    } catch {
        renderLoginPage();
    }
}

bootstrap();

Background Refresh Timer

Refresh proactively 60 seconds before expiry so users never experience an auth delay mid-session:

let refreshTimer: ReturnType<typeof setTimeout> | null = null;

export function scheduleRefresh(expiresIn: number) {
    if (refreshTimer) clearTimeout(refreshTimer);
    const delay = Math.max((expiresIn - 60) * 1000, 0);
    refreshTimer = setTimeout(silentRefresh, delay);
}

Call scheduleRefresh(expires_in) whenever you receive a new access token (after login, and inside silentRefresh).

Cross-Subdomain Navigation

// Source subdomain — create and hand off the exchange token
async function navigateToAdminPortal() {
    const res = await api.post("/api/v1/auth/exchange/create/", {
        target_origin: "https://admin.example.com",
    });
    const { exchange_token } = res.data;

    // Redirect immediately — token is valid for 60 seconds, single-use
    window.location.href =
        `https://admin.example.com/auth/callback?token=${exchange_token}`;
}

// Target subdomain — /auth/callback page
async function handleCallback() {
    const token = new URLSearchParams(window.location.search).get("token");
    if (!token) { window.location.href = "/login"; return; }

    // Remove the token from the URL before any async work
    window.history.replaceState({}, "", window.location.pathname);

    const res = await fetch("/api/v1/auth/exchange/redeem/", {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        // Origin header is set by the browser automatically
        body: JSON.stringify({ exchange_token: token }),
    });

    if (!res.ok) { window.location.href = "/login"; return; }

    const { access_token, expires_in } = await res.json();
    setTokens(access_token, expires_in);
    scheduleRefresh(expires_in);
    window.location.href = "/dashboard";
}

Periodic Cleanup

Revoked and expired refresh tokens remain in the database for audit purposes. Run a scheduled task to prune old records:

# tasks.py (Celery)
from celery import shared_task
from tokenforge.services.refresh import cleanup_expired_tokens
import logging

logger = logging.getLogger(__name__)

@shared_task
def cleanup_tokenforge_tokens():
    """Delete revoked tokens older than 90 days."""
    count = cleanup_expired_tokens(older_than_days=90)
    logger.info("TokenForge cleanup: removed %d expired tokens", count)
    return count
# Celery Beat schedule
CELERY_BEAT_SCHEDULE = {
    "cleanup-tokenforge-tokens": {
        "task": "myapp.tasks.cleanup_tokenforge_tokens",
        "schedule": crontab(hour=3, minute=0),  # Daily at 3 AM
    },
}

Security Notes

Production Checklist

  • ACCESS_TOKEN_SIGNING_KEY is set to a dedicated key — not SECRET_KEY
  • REFRESH_TOKEN_COOKIE_SECURE is True (HTTPS only)
  • REFRESH_TOKEN_COOKIE_SAMESITE is "Lax" or "Strict" — never "None" without Secure
  • REQUIRE_XHR_HEADER is True
  • REPLAY_DETECTION_ENABLED is True
  • RISK_EVENT_HANDLER is configured for security monitoring
  • Redis is available and reachable (required for exchange tokens and user cache)
  • Periodic cleanup_expired_tokens() task is scheduled
  • NUM_PROXIES is set to the correct number of trusted proxy hops
  • FINGERPRINT_STRICT_ACCESS_TOKEN is False unless all your users have stable, fixed IPs
  • x-requested-with and authorization are in CORS_ALLOW_HEADERS
  • CORS_ALLOW_CREDENTIALS = True

Key Separation

ACCESS_TOKEN_SIGNING_KEY must be a separate secret from SECRET_KEY. Django uses SECRET_KEY to sign sessions, CSRF tokens, and password reset links. Sharing the key means a single compromise collapses all of these simultaneously. A dedicated signing key limits the blast radius to access tokens only.

Generate one with:

openssl rand -base64 64

X-Forwarded-For Trust

TokenForge's built-in fingerprint function trusts X-Forwarded-For only when NUM_PROXIES > 0 is set in Django settings, reading the correct position from the right of the header chain rather than the leftmost value that any client can forge:

# settings.py
NUM_PROXIES = 1  # One nginx / ALB / CloudFront hop in front of the app

With NUM_PROXIES = 0 (default), X-Forwarded-For is ignored and REMOTE_ADDR is used directly.

Fingerprint Drift on Mobile

The default fingerprint is SHA-256(IP | User-Agent). Mobile users switch between WiFi and LTE, changing their IP mid-session. Access token fingerprint mismatches are logged as warnings but never cause a hard auth failure by default — the correct enforcement boundary is refresh token rotation, which always hard-fails on drift. Set FINGERPRINT_STRICT_ACCESS_TOKEN = True only for internal tools where all users have stable, predictable IPs.

Scope of This Package

TokenForge handles token lifecycle only. The following are intentionally out of scope:

Concern Handled by
Login / registration Your application's auth views
Password hashing Django's built-in auth system
Rate limiting DRF DEFAULT_THROTTLE_RATES
CORS django-cors-headers
Email / SMS OTP Your application's notification layer

API Reference

tokenforge.tokens

create_access_token(
    *,
    user_id: str,
    device_session_id: str = "",
    fingerprint: str = "",
    tenant_slug: str | None = None,
) -> tuple[str, int]
# Returns: (token_string, expires_in_seconds)
# Raises:  ImproperlyConfigured if ACCESS_TOKEN_SIGNING_KEY is not set

verify_access_token(
    token_string: str,
    *,
    request_fingerprint: str | None = None,
) -> dict
# Returns: payload dict {sub, sid, fp, tnt, iat, exp, v}
# Raises:  ValueError on signature failure, expiry, or strict fingerprint mismatch

tokenforge.services.refresh

create_refresh_token(
    *,
    user,
    device_session=None,
    fingerprint: str = "",
    token_family: uuid.UUID | None = None,
) -> tuple[str, RefreshToken]
# Returns: (raw_token, RefreshToken instance)

rotate_refresh_token(
    *,
    raw_token: str,
    fingerprint: str = "",
    request=None,
) -> tuple[str, RefreshToken]
# Returns: (new_raw_token, new_RefreshToken instance)
# Raises:  ValueError on validation failure (expired, revoked, replay, device rejected)

revoke_by_family(token_family: uuid.UUID, *, reason: str = "manual") -> int
# Returns: count of tokens revoked

revoke_all_for_user(user) -> int
# Returns: count of tokens revoked

revoke_by_device_session(device_session) -> int
# Returns: count of tokens revoked

get_active_token_for_session(device_session) -> RefreshToken | None

cleanup_expired_tokens(*, older_than_days: int = 90) -> int
# Returns: count of tokens deleted

tokenforge.services.exchange

create_exchange_token(
    *,
    user_id: str,
    device_session_id: str,
    fingerprint: str = "",
    target_origin: str,
) -> str
# Returns: raw exchange token string

redeem_exchange_token(
    *,
    token: str,
    request_origin: str = "",
) -> dict
# Returns: {sub, sid, fp, target_origin}
# Raises:  ValueError on any validation failure

count_active_exchange_tokens(user_id: str) -> int
increment_exchange_counter(user_id: str) -> None
decrement_exchange_counter(user_id: str) -> None

tokenforge.authentication

class BearerTokenAuthentication(BaseAuthentication):
    # DRF authentication class.
    # Sets request.user and request.auth on success.
    # request.auth keys: sub, sid, fp, tnt, iat, exp, v, token_type

invalidate_user_cache(user_id: str) -> None
# Evict a user from the auth cache immediately.
# Call after deactivating a user or changing their permissions.

tokenforge.cookies

set_refresh_cookie(response, raw_token: str) -> None
# Set the refresh_token HttpOnly cookie using TOKENFORGE settings.

expire_refresh_cookie(response) -> None
# Expire the refresh_token cookie (Max-Age=0).

tokenforge.fingerprinting

fingerprint_for_request(request) -> str
# Returns: SHA-256 hex digest of "ip|user_agent"

get_client_ip(request) -> str
# Returns: real client IP using NUM_PROXIES-aware X-Forwarded-For extraction

tokenforge.models

class AbstractRefreshToken(Model):
    # Abstract base — inherit to add custom fields
    is_expired: bool   # property
    is_usable:  bool   # property — not revoked and not expired

class RefreshToken(AbstractRefreshToken):
    # Default concrete model — swappable via TOKEN_MODEL

get_token_model() -> type[Model]
# Resolve the active refresh token model class (respects TOKEN_MODEL setting)

tokenforge.signals

token_rotated    # kwargs: sender, user, request
token_revoked    # kwargs: sender, family, count, reason
replay_detected  # kwargs: sender, user, family, request

License

MIT

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

django_tokenforge-1.0.0.tar.gz (65.2 kB view details)

Uploaded Source

Built Distribution

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

django_tokenforge-1.0.0-py3-none-any.whl (37.2 kB view details)

Uploaded Python 3

File details

Details for the file django_tokenforge-1.0.0.tar.gz.

File metadata

  • Download URL: django_tokenforge-1.0.0.tar.gz
  • Upload date:
  • Size: 65.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.12

File hashes

Hashes for django_tokenforge-1.0.0.tar.gz
Algorithm Hash digest
SHA256 3a9780d696fcc0862dcca3bdcb0653dd3a0c3eda754d5d42416a76ab8f619f1c
MD5 8944db24b36f941ea0be4e195f9cbcdc
BLAKE2b-256 87602cfcdc9574363ab2f462339fd4694b223fc2291847922a34a250f0792e76

See more details on using hashes here.

File details

Details for the file django_tokenforge-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_tokenforge-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0abb31a9eaa9a7ccdadc3f657771716d7147b6a83f5d93666a64b0a3ff342681
MD5 3fb3d964c63f235598be9abeb7c38f22
BLAKE2b-256 db66b87b26f8ea84b27f9371e7a3f459645f48fd4e074244af0d6aaea904dc3b

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