DEV Community

Cover image for Building a Secure Django REST API from Scratch - DjangGuard
Adeniyi Olanrewaju
Adeniyi Olanrewaju

Posted on

Building a Secure Django REST API from Scratch - DjangGuard

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    ...
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And we add the axes middleware and backend:

MIDDLEWARE = [
    ...
    "axes.middleware.AxesMiddleware",
    ...
]

AUTHENTICATION_BACKENDS = [
    "axes.backends.AxesBackend",
    "django.contrib.auth.backends.ModelBackend",
]
Enter fullscreen mode Exit fullscreen mode

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()
        ...
Enter fullscreen mode Exit fullscreen mode

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):
    ...
Enter fullscreen mode Exit fullscreen mode

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.",
    )
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

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"},
]
Enter fullscreen mode Exit fullscreen mode

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>"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3. Run migrations and start the server

python manage.py migrate
python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

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

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-simplejwt with 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)