Most Django tutorials teach you how to build an API. Very few teach you how to build a secure one.
In this article, I'll walk you through DjangGuard - a Django REST API boilerplate I built that comes with security built in from the start. Not as an afterthought.
By the end, you'll understand how to implement:
- JWT authentication with device binding
- Redis-backed token blacklisting
- Brute-force login protection
- Global and endpoint-level rate limiting
- Argon2 password hashing
- User agent validation middleware
Let's get into it.
Why Security Needs to Come First
When you build an API, it's easy to focus on features. But the moment your API goes live, it becomes a target.
People will try to:
- Guess passwords over and over
- Steal tokens and use them from different devices
- Flood your endpoints with requests
- Reuse tokens after a user has logged out
DjangGuard addresses all of these - and it's designed to be a starting point you can build any project on top of.
The Tech Stack
Before we dive in, here's what we're working with:
| What | Technology |
|---|---|
| Framework | Django 5.2 + Django REST Framework |
| Auth | SimpleJWT + PyJWT |
| Brute-force protection | django-axes |
| Rate limiting | django-ratelimit |
| Caching & token storage | Redis |
| Password hashing | Argon2 |
| Config management | python-dotenv |
Project Structure
backend/
├── api_services/ # Shared utilities (response helper, Redis, logger, etc.)
├── authentication/ # Login, register, logout
├── exception_handler/ # Custom DRF error responses
├── guard/ # Django settings and root URLs
├── middleWares/
│ ├── authenticate/ # User agent validation
│ └── rateLimit/ # Global rate limiting
├── users/ # User profile
└── requirements.txt
Clean and modular. Each responsibility lives in its own place.
1. Custom User Model
The first thing we do is replace Django's default user model. We don't need a username - we use email instead.
class User(AbstractUser, BaseModel):
username = None
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
password = models.TextField()
is_deleted = models.BooleanField(default=False)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
objects = CustomUserManager()
We also have a BaseModel that gives every model a hex UUID primary key and created_at / updated_at timestamps:
class BaseModel(models.Model):
id = models.CharField(primary_key=True, max_length=32, default=hex_uuid, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
Using hex UUIDs instead of auto-incrementing integers means your IDs don't leak information about how many users you have.
2. JWT with User Agent Binding
Standard JWT authentication has a problem: if someone steals a token, they can use it from anywhere.
DjangGuard solves this by embedding the user's User-Agent header as a custom claim inside the token at login time.
def get_tokens_for_user(user, extra_claims=None):
token = AccessToken.for_user(user)
if extra_claims:
for key, value in extra_claims.items():
token[key] = value
...
When the user logs in, we capture their User-Agent and bake it into the token:
user_agent = serializer.validated_data.get("user_agent")
token_dict = get_tokens_for_user(user, {"user_agent": user_agent})
Now every token is tied to the device that created it.
3. User Agent Validation Middleware
On every request, our middleware checks if the token's user_agent claim matches the current request's User-Agent.
class UserAgentValidationMiddleware:
def __call__(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION")
if auth_header:
token = self.extract_token(auth_header)
validation_result = self.validate_user_agent(token, request)
if validation_result is not True:
return validation_result
return self.get_response(request)
If there's a mismatch, the token is immediately blacklisted and the request is rejected:
if token_user_agent != current_user_agent:
redis_service.set(unverified_jti, "blacklisted", expire=timedelta(days=1))
return JsonResponse(
{"detail": "User agent mismatch", "message": "Token was issued for a different device/browser"},
status=401,
)
This means even if a token gets stolen, it's useless on any device other than the original one.
4. Redis Token Blacklisting
When a user logs out, we blacklist their token's jti (JWT ID) in Redis:
class LogoutView(APIView):
def post(self, request):
...
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
jti = payload.get("jti")
redis_service.set(jti, "blacklisted", expire=timedelta(days=1))
return returned_response("success", "Logout successful", status.HTTP_200_OK)
Before any authenticated request is processed, the middleware checks Redis for that jti:
has_been_blacklisted = redis_service.get(unverified_jti)
if has_been_blacklisted and has_been_blacklisted.decode("utf-8") == "blacklisted":
return JsonResponse({"message": "Token has been blacklisted"}, status=401)
The token expires from Redis automatically after 1 day - matching the token's own lifetime. No cleanup needed.
5. Single Active Session
When a user logs in, we store a mapping of user_id → jti in Redis:
redis_service.set(user.id, token_dict[1], expire=timedelta(days=1))
On each request, the middleware checks if the jti in the token matches what Redis has stored for that user:
is_accessible = redis_service.get(user_id)
if is_accessible and is_accessible.decode("utf-8") != jti:
return JsonResponse({"message": "Invalid/Revoked Token"}, status=401)
If the user logs in from a new device, the old token becomes invalid. This gives you single active session behavior out of the box.
6. Brute-Force Protection with django-axes
Django-axes tracks failed login attempts and locks accounts automatically.
In settings.py:
AXES_FAILURE_LIMIT = 5 # Lock after 5 failed attempts
AXES_COOLOFF_TIME = 1 # Lockout lasts 1 hour
AXES_RESET_ON_SUCCESS = True # Reset counter on successful login
And we add the axes middleware and backend:
MIDDLEWARE = [
...
"axes.middleware.AxesMiddleware",
...
]
AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend",
]
Axes handles the rest. If someone tries the wrong password 5 times, they're locked out for an hour.
7. Rate Limiting
We have two layers of rate limiting.
Global middleware limits all traffic - 100 requests/hour per IP (unauthenticated) or per user ID (authenticated):
class GlobalRateLimitMiddleware:
def __call__(self, request):
if request.user.is_authenticated:
rate = "100/h"
key_func = self.get_user_id_key
else:
rate = "100/h"
key_func = self.get_ip_key
limited = is_ratelimited(request=request, group="global_rate_limit",
rate=rate, key=key_func, method="ALL", increment=True)
if limited:
raise Ratelimited()
...
Endpoint-level limits on login and register to stop credential stuffing:
@method_decorator(
[
ratelimit(key="post:email", rate="5/m", method="POST", block=True),
ratelimit(key="ip", rate="10/m", method="POST", block=True),
],
name="post",
)
class LoginView(APIView):
...
When a limit is hit, the custom exception handler returns a clean 429 response:
if isinstance(exc, Ratelimited):
return returned_response(
"failed",
status=status.HTTP_429_TOO_MANY_REQUESTS,
message="Slow down a bit!, You are making too many requests.",
)
8. Argon2 Password Hashing
Argon2 is the strongest password hashing algorithm available today. It won the Password Hashing Competition and is recommended by OWASP.
In settings.py, we set it as the default:
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]
Django tries each hasher in order. All new passwords use Argon2.
We also enforce strong passwords:
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "...UserAttributeSimilarityValidator"},
{"NAME": "...MinimumLengthValidator", "OPTIONS": {"min_length": 12}},
{"NAME": "...CommonPasswordValidator"},
{"NAME": "...NumericPasswordValidator"},
]
Minimum 12 characters. No common passwords. No all-numeric passwords.
9. Consistent API Responses
Every response in DjangGuard follows the same structure:
{
"status": "success",
"message": "Login successful",
"data": {
"access": "<token>"
}
}
This is handled by a single returned_response helper used everywhere. No more inconsistent error formats across different views. Your frontend always knows what shape to expect.
10. Redis Caching for Profile Data
The user profile endpoint caches the response in Redis for 1 hour to avoid hitting the database on every request:
def get(self, request):
user = request.user
key = f"user_profile:{user.id}"
user_dict = cache.get(key)
if user_dict:
return returned_response("success", "User profile retrieved successfully",
status.HTTP_200_OK, user_dict)
user_dict = {
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"date_joined": user.created_at,
}
cache.set(key, user_dict, timeout=60 * 60)
return returned_response("success", "User profile retrieved successfully",
status.HTTP_200_OK, user_dict)
Simple but effective. As your user base grows, this matters a lot.
The API Endpoints
Here's a summary of all available endpoints:
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
POST |
/api/auth/register |
Create a new account | No |
POST |
/api/auth/login |
Login and get a JWT token | No |
POST |
/api/auth/logout |
Invalidate your token | Yes |
GET |
/api/users/me |
Get your profile | Yes |
Getting Started
1. Clone and install
git clone https://github.com/your-username/DjangGuard.git
cd DjangGuard/backend
pip install -r requirements.txt
2. Create your .env file
SECRET_KEY=your-secret-key-here
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=0
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
3. Run migrations and start the server
python manage.py migrate
python manage.py runserver
That's it. Your hardened API is running.
What You Can Add Next
DjangGuard is a starting point, not a finished product. Here are some things you can layer on top:
- Email verification on registration
- Password reset via email
- Refresh token rotation
- Swap SQLite for PostgreSQL before going to production
- Docker + docker-compose for easier deployment
- Two-factor authentication (2FA)
Final Thoughts
Security isn't something you add at the end. It's something you design from day one.
DjangGuard brings together JWT binding, Redis blacklisting, brute-force protection, rate limiting, and strong hashing - all in one clean codebase you can start a real project with.
If this was helpful, give the repo a ⭐ on GitHub.
DjangGuard
A security-focused Django REST API boilerplate with built-in authentication, rate limiting, JWT token management, and Redis-backed caching. Designed to serve as a hardened starting point for building production-ready APIs.
Table of Contents
- Overview
- Features
- Tech Stack
- Project Structure
- Getting Started
- API Reference
- Security Architecture
- Rate Limiting
- Middleware
- Contributing
- License
Overview
DjangGuard is a batteries-included Django REST Framework (DRF) backend template built with security at its core. It combines JWT-based authentication, brute-force protection, user-agent binding, Redis-backed token blacklisting, and global rate limiting — all wired together and ready to extend.
Features
-
JWT Authentication via
djangorestframework-simplejwtwith custom claims (user agent binding) - Token Blacklisting using Redis — tokens are invalidated on logout and revoked on device mismatch
-
Brute-Force Protection via
django-axes— accounts are locked after 5 failed login attempts (1-hour cooldown) - Global Rate Limiting — 100 requests/hour per IP (unauthenticated) or…
and share it with someone learning Django.
Have questions or suggestions? Drop them in the comments - I read every one.
Top comments (0)