TL;DR
API tokens are the modern equivalent of passwords, except they're not meant to be remembered. They're meant to be generated, stored securely, and revoked the moment something goes wrong. If you're building any kind of API that needs authentication, you need a token management system that treats tokens with the same respect you'd give a password.
This post walks through building a complete token management module in Django from scratch. High-entropy token generation, SHA-512 hashing for storage, CRC-32 checksums to avoid pointless database lookups, and the full lifecycle: create, list, view, update, and revoke. No third-party token libraries. No django-rest-framework token models. Just Python, Django, and a few careful decisions about security.
The whole thing lives in about 400 lines of code, and you'll understand every single one of them.
You've probably used API tokens before. Every time you generate a personal access token on GitHub, or set up a service account key for a cloud provider, someone built a system that handles the generation, hashing, storage, and lifecycle of that token. And chances are, if you've ever had to build that system yourself, you've wondered: how hard can it really be?
The answer is: not hard, if you make the right decisions upfront. But deceptively easy to get wrong in ways that don't surface until someone leaks a token or an attacker starts guessing.
Why Build It From Scratch?
Django gives you session-based authentication out of the box. For web apps with cookie-based sessions, that's usually enough. But the moment you need to support third-party integrations, CI/CD pipelines, mobile apps, or machine-to-machine communication, you need bearer tokens. Tokens that are passed in headers, validated on every request, and can be revoked independently.
You could reach for djangorestframework's built-in token model, or a package like
django-rest-framework-simplejwt. But those come with opinions, assumptions, and a layer of
abstraction that hides exactly what's happening with your tokens. When security is involved, I'd
rather understand every line of code between "token received" and "request authenticated."
So let's build it ourselves.
Design Decisions Before Any Code
Before writing a single line, there are a few decisions that shape everything downstream.
Token Entropy
The raw token, the thing the user actually receives, needs high entropy. Low entropy means guessable, and guessable means exploitable. Most security researchers recommend at least 128 bits of entropy, though higher is better.
GitHub uses 40-character hex strings, which gives them 160 bits of entropy. That's a solid benchmark. We'll aim for something similar: a token string that's short enough to paste into a header, but long enough that brute-force attacks are mathematically impractical.
Hashing Strategy
This is where most people make their first mistake. When you store a token, you should never store it in plain text. A leaked database means every token is compromised. But you also can't use a slow hashing algorithm like PBKDF2 or Argon2.
Why not? Because tokens are verified on every single API request. If your authentication middleware takes 500ms to hash and compare, your API is effectively unusable. Password hashing is slow on purpose, because users only authenticate once. Tokens authenticate constantly.
The sweet spot is a fast cryptographic hash. SHA-512 is widely used for this exact purpose. It's fast enough for high-throughput authentication, produces a 512-bit digest, and is considered secure against collision attacks. Many large-scale systems, including GitHub, use SHA-256 or SHA-512 for token storage.
CRC Checksums
Here's a trick that saves you from pointless database hits. When a request comes in with a token, you can compute a CRC-32 checksum of the raw token and compare it against a stored checksum before ever touching the authentication table. If the checksum doesn't match, the token is invalid and you can reject the request immediately.
CRC-32 is not a security primitive. It's a fast checksum. But it's excellent as a first-pass filter. An attacker sending garbage tokens will fail at the checksum layer without your database ever knowing a request happened.
Soft Delete vs Hard Delete
Tokens should be revoked, not deleted. Revocation preserves the audit trail. If a token was used to access sensitive data before it was revoked, you need to know who created it, when it was last used, and when it was revoked. Hard deletion erases that history.
A simple boolean is_revoked field with a revoked_at timestamp handles this cleanly.
The Token Model
With those decisions made, the model takes shape quickly.
import secrets
import hashlib
import zlib
from datetime import timedelta
from django.db import models
from django.utils import timezone
from django.contrib.auth import get_user_model
User =()
class APIToken(models.Model):
user = models.(User, on_delete=models.CASCADE, related_name="api_tokens")
name = models.(max_length=255, help_text="Human-readable label for the token")
# The hashed token and its checksum
token_hash = models.(max_length=128, unique=True, db_index=True)
token_checksum = models.(max_length=8, db_index=True)
# Token metadata
prefix = models.(max_length=8, help_text="First few chars of raw token for identification")
# Lifecycle
created_at = models.(auto_now_add=True)
expires_at = models.(null=True, blank=True)
last_used_at = models.(null=True, blank=True)
is_revoked = models.(default=False)
revoked_at = models.(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
status = "revoked" if self.is_revoked else "active"
return f"{self.name} ({status})"
@staticmethod
def generate_token():
"""Generate a high-entropy raw token string."""
# 32 bytes = 256 bits of entropy
return secrets.(32)
@staticmethod
def hash_token(raw_token):
"""Hash the token using SHA-512."""
return hashlib.(raw_token.("utf-8")).()
@staticmethod
def compute_checksum(raw_token):
"""Compute a CRC-32 checksum for fast invalid-token rejection."""
return format(zlib.(raw_token.("utf-8")) & 0xFFFFFFFF, "08x")
@property
def is_expired(self):
if self.expires_at is None:
return False
return timezone.() > self.expires_at
@property
def is_valid(self):
return not self.is_revoked and not self.is_expired
A few things to note:
secrets.token_hex(32)generates 32 random bytes and encodes them as a 64-character hex string. That's 256 bits of entropy, well above the recommended minimum.- The
prefixfield stores the first few characters of the raw token. This lets users identify which token is which without revealing the full secret. token_checksumis indexed for fast lookup. Invalid tokens get rejected before the hash comparison ever runs.- The
token_hashis also indexed, because valid tokens need efficient lookup too.
The Token Service
The model handles storage. The service handles logic. Keeping them separate makes testing easier and keeps your views thin.
from django.utils import timezone
from datetime import timedelta
from .models import APIToken
class TokenService:
"""Handles token creation, validation, and lifecycle management."""
@staticmethod
def create_token(user, name, expiry_days=90):
"""
Generate a new API token and return the raw value.
The raw token is only returned once, at creation time.
After that, only the hash is stored.
"""
raw_token = APIToken.()
token_hash = APIToken.(raw_token)
checksum = APIToken.(raw_token)
prefix = raw_token[:8]
expires_at = timezone.() +(days=expiry_days) if expiry_days else None
token = APIToken.objects.(
user=user,
name=name,
token_hash=token_hash,
token_checksum=checksum,
prefix=prefix,
expires_at=expires_at,
)
return token, raw_token
@staticmethod
def validate_token(raw_token):
"""
Validate a raw token and return the associated APIToken if valid.
Uses CRC-32 checksum for fast rejection, then hash comparison for
cryptographic verification.
"""
# Step 1: CRC-32 checksum for fast invalid-token rejection
checksum = APIToken.(raw_token)
# Step 2: Look up by checksum (fast, indexed)
# This will return zero results for most invalid tokens
candidates = APIToken.objects.(
token_checksum=checksum,
is_revoked=False,
).("user")
if not candidates.():
return None
# Step 3: Hash comparison for cryptographic verification
token_hash = APIToken.(raw_token)
for token in candidates:
if token.token_hash == token_hash:
# Update last_used_at
APIToken.objects.(pk=token.pk).(
last_used_at=timezone.()
)
return token
return None
@staticmethod
def revoke_token(user, token_id):
"""Revoke a token by ID. Only the owner can revoke their own tokens."""
try:
token = APIToken.objects.(id=token_id, user=user)
except APIToken.DoesNotExist:
return None
token.is_revoked = True
token.revoked_at = timezone.()
token.(update_fields=["is_revoked", "revoked_at"])
return token
@staticmethod
def list_tokens(user, include_revoked=False):
"""List all tokens for a user, optionally including revoked ones."""
queryset = APIToken.objects.(user=user)
if not include_revoked:
queryset = queryset.(is_revoked=False)
return queryset
The validation flow is the core of the security model. It happens in three steps:
- CRC-32 checksum eliminates garbage tokens without a database hit. An attacker brute-forcing tokens will fail here on 99.99% of attempts.
- Checksum lookup narrows the candidate pool to tokens with matching checksums. This is indexed, so it's fast.
- Hash comparison is the final cryptographic verification. SHA-512 is fast enough that this comparison is imperceptible, but it's the only step that actually confirms the token is valid.
This layered approach means your database only does real work for tokens that pass the first two filters. It's the same principle behind password hashing with PBKDF2: make the attacker do work, while the legitimate path stays fast.
The Views
Django views or DRF viewsets, depending on your stack. Here's a Django-native approach:
from django.http import JsonResponse
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.contrib.auth.mixins import LoginRequiredMixin
import json
from .models import APIToken
from .services import TokenService
@method_decorator(, name="dispatch")
class TokenCreateView(LoginRequiredMixin, View):
"""Create a new API token."""
def post(self, request):
try:
data = json.(request.body)
except json.JSONDecodeError:
return({"error": "Invalid JSON"}, status=400)
name = data.("name", "").()
if not name:
return({"error": "Token name is required"}, status=400)
expiry_days = data.("expiry_days", 90)
if expiry_days is not None:
try:
expiry_days = int(expiry_days)
except (ValueError, TypeError):
return({"error": "expiry_days must be an integer"}, status=400)
token, raw_token = TokenService.(
user=request.user,
name=name,
expiry_days=expiry_days,
)
return({
"id": token.id,
"name": token.name,
"token": raw_token, # Only returned once
"prefix": token.prefix,
"expires_at": token.expires_at.() if token.expires_at else None,
"message": "Save this token. It won't be shown again.",
}, status=201)
@method_decorator(, name="dispatch")
class TokenListView(LoginRequiredMixin, View):
"""List all tokens for the authenticated user."""
def get(self, request):
include_revoked = request.GET.("include_revoked", "false").() == "true"
tokens = TokenService.(request.user, include_revoked=include_revoked)
results = []
for token in tokens:
results.({
"id": token.id,
"name": token.name,
"prefix": token.prefix,
"is_revoked": token.is_revoked,
"is_expired": token.is_expired,
"created_at": token.created_at.(),
"expires_at": token.expires_at.() if token.expires_at else None,
"last_used_at": token.last_used_at.() if token.last_used_at else None,
})
return({"tokens": results})
@method_decorator(, name="dispatch")
class TokenDetailView(LoginRequiredMixin, View):
"""View details of a specific token."""
def get(self, request, token_id):
try:
token = APIToken.objects.(id=token_id, user=request.user)
except APIToken.DoesNotExist:
return({"error": "Token not found"}, status=404)
return({
"id": token.id,
"name": token.name,
"prefix": token.prefix,
"is_revoked": token.is_revoked,
"is_expired": token.is_expired,
"created_at": token.created_at.(),
"expires_at": token.expires_at.() if token.expires_at else None,
"last_used_at": token.last_used_at.() if token.last_used_at else None,
"revoked_at": token.revoked_at.() if token.revoked_at else None,
})
@method_decorator(, name="dispatch")
class TokenRevokeView(LoginRequiredMixin, View):
"""Revoke a token. This is a soft delete."""
def post(self, request, token_id):
token = TokenService.(request.user, token_id)
if token is None:
return({"error": "Token not found"}, status=404)
return({
"id": token.id,
"name": token.name,
"message": "Token has been revoked",
})
@method_decorator(, name="dispatch")
class TokenUpdateView(LoginRequiredMixin, View):
"""Update a token's name. Expiry cannot be modified after creation."""
def patch(self, request, token_id):
try:
token = APIToken.objects.(id=token_id, user=request.user)
except APIToken.DoesNotExist:
return({"error": "Token not found"}, status=404)
try:
data = json.(request.body)
except json.JSONDecodeError:
return({"error": "Invalid JSON"}, status=400)
if "name" in data:
token.name = data["name"].()
token.(update_fields=["name"])
return({
"id": token.id,
"name": token.name,
"expires_at": token.expires_at.() if token.expires_at else None,
})
The update view is intentionally simple: you can only update the token's name. The expiry time is set once at creation and cannot be modified. If you need to change when a token expires, revoke it and create a new one. This constraint prevents accidental or malicious modification of security-critical metadata.
The Authentication Middleware
Creating tokens is useless if nothing validates them. Here's a middleware that checks for bearer tokens on every request:
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from .services import TokenService
class APITokenAuthenticationMiddleware(MiddlewareMixin):
"""
Authenticate requests using bearer tokens in the Authorization header.
Expects: Authorization: Bearer <token>
"""
def process_request(self, request):
# Skip authentication for certain paths
if self.(request.path):
return None
auth_header = request.META.("HTTP_AUTHORIZATION", "")
if not auth_header.("Bearer "):
return None # Let other auth methods handle it
raw_token = auth_header[7:].()
if not raw_token:
return(
{"error": "Token is required"},
status=401,
headers={"WWW-Authenticate": "Bearer"},
)
token = TokenService.(raw_token)
if token is None:
return(
{"error": "Invalid or expired token"},
status=401,
headers={"WWW-Authenticate": "Bearer"},
)
# Attach the user and token to the request
request.user = token.user
request.api_token = token
return None
def _should_skip(self, path):
"""Paths that don't require API token authentication."""
skip_paths = [
"/admin/",
"/api/auth/login",
"/api/auth/token/create", # Uses session auth, not token
]
return any(path.(p) for p in skip_paths)
The middleware is intentionally permissive about what it accepts. It checks for the Bearer
scheme, validates the token, and either attaches the user to the request or returns a 401. If no
Authorization header is present, it does nothing, allowing other authentication methods like
session-based auth to handle the request.
The Cost of Getting This Wrong
It's worth pausing to think about what happens if the security model breaks.
Brute Force Attacks
With 256 bits of entropy, the raw token space is 2^256. Even if an attacker could make a billion guesses per second, exhausting the entire keyspace would take approximately 3.7 × 10^60 years. For context, the universe is about 1.4 × 10^10 years old.
The CRC-32 checksum adds another layer. An attacker would need to generate tokens that not only match the SHA-512 hash but also match the CRC-32 checksum. While CRC-32 is not cryptographically secure, it does add a practical barrier that reduces the number of candidates that make it to the hash comparison step.
If you choose 64 bits of entropy, a brute-force attack with the attacker randomly guessing API keys would require 2^63 API calls on average before it would be expected to have a 50% chance of succeeding. If a single API call took one millisecond, this kind of brute force attack would take 292 million years. My hope is that you'd notice that your audit logs had grown in size before then.
But here's the uncomfortable part. If someone has managed to hack into your database to steal the hash, what's the risk of an attacker computing the secret from these two values? Yeah, we're screwed. That's why we never store tokens in plain text — revocation becomes the only option.
Database Breach
If an attacker steals the entire APIToken table, they get token_hash and token_checksum
values. Without the raw tokens, they can't authenticate. SHA-512 is a one-way function;
computing the raw token from the hash would require precomputation attacks (rainbow tables) or
brute force, both of which are computationally infeasible for 256-bit entropy tokens.
The tokens are safe because of the hashing, but if you'd stored them in plain text, every single integration would be compromised instantly. Revoking hundreds of tokens across dozens of services is not how you want to spend your weekend.
What About Leaked Tokens?
Even with perfect storage, tokens can leak. A developer accidentally commits a token to a public repo. A log file captures a request with the token in the header. A man-in-the-middle intercepts a request over HTTP.
This is why the revoke endpoint matters. The ability to immediately invalidate a token is
your last line of defense. The model tracks revoked_at so you can audit when revocations
happen, and the authentication middleware checks is_revoked on every request, so a revoked
token stops working immediately.
Pro tip: Build an automated token rotation strategy into your CI/CD pipeline. Generate new tokens, update your secrets manager, and revoke old ones without manual intervention. The token's expiry is a safety net, not a replacement for intentional rotation.
The Full Lifecycle
Here's the complete flow, from token creation to authentication to revocation:
- User creates a token via the create endpoint. The raw token is generated, hashed, and stored. The raw value is returned exactly once.
- The user stores the raw token in their application, CI/CD pipeline, or secrets manager. After this point, the raw token can never be retrieved from the API.
- An API request arrives with
Authorization: Bearer <token>. The middleware extracts the raw token. - CRC-32 checksum is computed and compared against stored values. Most invalid tokens are rejected here.
- SHA-512 hash is computed and compared against the candidate tokens from step 4.
- If valid, the user is authenticated,
last_used_atis updated, and the request proceeds. - If invalid, a 401 response is returned with
WWW-Authenticate: Bearer. - When the token is no longer needed, the user calls the revoke endpoint. The token is soft deleted, preserving the audit trail.
- If the token expires, the
is_expiredproperty returnsTrueand the middleware rejects it, same as revocation.
URL Configuration
Tie everything together with a clean URL structure:
from django.urls import path
from . import views
urlpatterns = [
("api/auth/token/create/", views.TokenCreateView.(), name="token-create"),
("api/auth/tokens/", views.TokenListView.(), name="token-list"),
("api/auth/token/<int:token_id>/", views.TokenDetailView.(), name="token-detail"),
("api/auth/token/<int:token_id>/update/", views.TokenUpdateView.(), name="token-update"),
("api/auth/token/<int:token_id>/revoke/", views.TokenRevokeView.(), name="token-revoke"),
]
And add the middleware in settings.py:
MIDDLEWARE = [
# ... other middleware
"your_app.middleware.APITokenAuthenticationMiddleware",
]
Place it after SessionMiddleware and AuthenticationMiddleware so that session-based
authentication is still available as a fallback.
Final Thoughts
Building a token management system from scratch isn't glamorous work. There's no flashy UI, no clever algorithm, no novel architecture. It's a model, a service, some views, and a middleware. But the decisions behind each piece, how tokens are generated, how they're stored, how they're validated, determine whether your API stays secure or becomes a liability.
The key takeaways:
- Never store tokens in plain text. Treat them as passwords. If the database leaks, hashed tokens are useless to attackers.
- Use SHA-512, not PBKDF2. Tokens authenticate on every request. You need a fast hash that's still cryptographically strong.
- CRC-32 checksums save database hits. Invalid tokens get rejected before they ever touch your authentication table.
- High entropy is non-negotiable. 256 bits of entropy makes brute-force attacks mathematically infeasible for the foreseeable future.
- Revoke, don't delete. The audit trail matters. You need to know who created a token, when it was last used, and when it was revoked.
- The raw token is returned once. After creation, only the hash exists. This is intentional. If someone loses the raw token, they generate a new one and revoke the old one.
It's one of those things you set up once, understand once, and then never worry about again because the security model is sound enough to handle whatever your API throws at it.
Further Reading
- What Makes a Good API Key System - Ted Spence's deep dive into API key design patterns and security considerations.
- Building API Authentication at ProcedureFlow - ProcedureFlow's engineering blog on their API authentication implementation.