Auth & Security

Implementing JWT authentication, Role-Based Access Control, and hardening your API endpoints against real-world attacks.

Modern APIs need more than a username and password check. You need stateless authentication that scales, fine-grained permissions based on who the user is, and hardened endpoints that resist common attack vectors.

🧭 What we'll cover This guide uses Django REST Framework (DRF) with djangorestframework-simplejwt. The concepts — JWT anatomy, RBAC patterns, token rotation, and threat mitigation — apply to any framework.

The Three Pillars

PillarWhat it answersImplementation
Authentication"Who are you?"JWT — signed token proves identity
Authorization"What can you do?"RBAC — roles map to allowed actions
Protection"Are you safe?"Throttling, HTTPS, CSRF, validation

Authentication Flow

🖥
Client
POST /login
credentials
🔐
Auth View
verify user
sign token
🎟
JWT Pair
access + refresh
stored client
🖥
Client
Bearer token
Authorization
API
verifies & responds

Session vs JWT — Quick Comparison

AspectSession-basedJWT
StateStateful (server stores session)Stateless (server stores nothing)
StorageServer DB / cacheClient (localStorage / cookie)
ScalingNeeds shared session storeWorks across multiple servers
RevocationEasy — delete session rowHard — must use a denylist
PayloadOnly a session IDCan carry claims (role, email…)
Best forTraditional web apps (Django views)REST APIs, SPAs, mobile apps

How JWT Works

A JSON Web Token is a compact, self-contained string that proves a claim — without querying a database on every request.

Anatomy of a Token

Part 1
Header
Algorithm & token type.
Base64Url encoded.
.
Part 2
Payload
Claims: user_id, role, exp, iat…
Base64Url encoded.
.
Part 3
Signature
HMAC-SHA256 of header + payload using SECRET_KEY.

What a real JWT looks like

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0Miwicm9sZSI6ImVkaXRvciIsImV4cCI6MTcwMDAwMDAwMH0.Xv9Z4kQmK2jLpR8T1nWfOqMdYeI3HsBuCvAzE6oP

Header  ·  Payload  ·  Signature

Decoded Payload Example

payload (decoded)JSON
{
  "user_id":  42,
  "username": "alice",
  "role":     "editor",
  "iat":      1700000000,    // issued at (Unix timestamp)
  "exp":      1700003600     // expires in 1 hour
}
⚠️ The Payload is NOT encrypted Anyone can base64-decode the payload. Never store passwords, card numbers, or truly sensitive data in a JWT. The signature only proves the token was issued by your server — it doesn't hide the contents.

Access Token vs Refresh Token

PropertyAccess TokenRefresh Token
LifetimeShort — 5 to 60 minutesLong — 7 to 30 days
Sent to APIYes — every requestNo — only to /token/refresh
If stolenAttacker has minutesAttacker can get new access tokens
StorageMemory (SPA) or HttpOnly cookieHttpOnly cookie (recommended)

Token Refresh Flow

HTTP exchangeREST
# 1. Client sends expired access token → 401
GET /api/posts/ HTTP/1.1
Authorization: Bearer <expired-access-token>

# 2. Client refreshes using the long-lived refresh token
POST /api/token/refresh/
{ "refresh": "<refresh-token>" }

# 3. Server responds with a new access token
{ "access": "<new-access-token>" }

# 4. Client retries with new access token → 200
GET /api/posts/
Authorization: Bearer <new-access-token>
💡 Token Rotation Enable ROTATE_REFRESH_TOKENS = True in Simple JWT. Every refresh call issues a new refresh token and blacklists the old one — an attacker cannot reuse a stolen refresh token after it has been rotated.

Django + JWT

Setting up djangorestframework-simplejwt from zero to protected endpoints in six steps.

Installation & Configuration

1
Install the packages
terminalbash
pip install djangorestframework djangorestframework-simplejwt
2
Configure settings.py
settings.pyPython
INSTALLED_APPS = [
    ...
    'rest_framework',
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',  # for revocation
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',  # global default
    ],
}
3
Tune token lifetimes in settings.py
settings.pyPython
from datetime import timedelta

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME':  timedelta(minutes=15),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS':  True,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM':              'HS256',
    'SIGNING_KEY':            SECRET_KEY,
    'AUTH_HEADER_TYPES':       ('Bearer',),
}
4
Wire up auth URLs
urls.pyPython
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenBlacklistView,
)

urlpatterns = [
    path('api/token/',          TokenObtainPairView.as_view()),
    path('api/token/refresh/',   TokenRefreshView.as_view()),
    path('api/token/blacklist/',  TokenBlacklistView.as_view()),  # logout
]
5
Run migrations for the blacklist table
terminalbash
python manage.py migrate
6
Embed extra claims in the token (optional but powerful)
views.py — custom token serializerPython
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views      import TokenObtainPairView

class MyTokenSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        # ↓ custom claims written into the payload
        token['username'] = user.username
        token['role']     = user.profile.role  # your custom field
        return token

class MyTokenView(TokenObtainPairView):
    serializer_class = MyTokenSerializer

Testing the Token Endpoint

HTTP exchangebash
# 1. Obtain token pair
curl -X POST http://localhost:8000/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret"}'

# Response:
{
  "access":  "eyJ...",   // valid 15 min
  "refresh": "eyJ..."    // valid 7 days
}

# 2. Call a protected endpoint
curl http://localhost:8000/api/posts/ \
  -H "Authorization: Bearer eyJ..."

Role-Based Access

RBAC grants permissions to roles, not to individual users — assign a user to a role and they inherit exactly the right access level.

Designing Roles

👑 Admin
Create any resource
Edit any resource
Delete any resource
Manage users
View audit logs
📝 Editor
Create own resources
Edit own resources
Delete others' resources
Manage users
View audit logs
👁 Viewer
Create resources
Edit resources
Delete resources
Read all resources
Manage users
🚫 Anonymous
Create resources
Edit resources
Delete resources
Read public endpoints
Access private data

Permission Matrix

EndpointAnonymousViewerEditorAdmin
GET /api/posts/
GET /api/posts/:id/
POST /api/posts/
PUT /api/posts/:id/own
DELETE /api/posts/:id/
GET /api/admin/users/

✓ allowed  ·  ✗ denied  ·  own = only their own objects

Implementing RBAC in DRF

1 — Add role to the User model

models.pyPython
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    class Role(models.TextChoices):
        ADMIN  = 'admin',  'Admin'
        EDITOR = 'editor', 'Editor'
        VIEWER = 'viewer', 'Viewer'

    role = models.CharField(
        max_length=10,
        choices=Role.choices,
        default=Role.VIEWER,
    )

    @property
    def is_admin(self):  return self.role == self.Role.ADMIN
    @property
    def is_editor(self): return self.role in (self.Role.ADMIN, self.Role.EDITOR)

2 — Write custom permission classes

permissions.pyPython
from rest_framework.permissions import BasePermission, SAFE_METHODS

class IsAdminRole(BasePermission):
    """Allow access only to users with role='admin'."""
    def has_permission(self, request, view):
        return request.user.is_authenticated and request.user.is_admin

class IsEditorOrAdmin(BasePermission):
    """Editors and admins can write; viewers can only read."""
    def has_permission(self, request, view):
        if request.method in SAFE_METHODS:   # GET, HEAD, OPTIONS
            return request.user.is_authenticated
        return request.user.is_authenticated and request.user.is_editor

class IsOwnerOrAdmin(BasePermission):
    """Object-level: owner or admin may modify, others may read."""
    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        return obj.author == request.user or request.user.is_admin
⚠️ has_permission vs has_object_permission has_permission runs on every request (view-level). has_object_permission only runs when you call self.get_object() in your view — it does NOT auto-apply to list views or create views.

Protecting Endpoints

Three levels of protection — global defaults, per-view overrides, and object-level checks — letting you lock down exactly what needs locking.

Level 1 — Global Defaults

Set in REST_FRAMEWORK in settings.py. Every view inherits these unless overridden.

settings.pyPython
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_THROTTLE_CLASSES': [
        'rest_framework.throttling.AnonRateThrottle',
        'rest_framework.throttling.UserRateThrottle',
    ],
    'DEFAULT_THROTTLE_RATES': {
        'anon': '30/minute',   # unauthenticated users
        'user': '200/minute',  # authenticated users
    },
}

Level 2 — Per-View Overrides

views.py — APIViewPython
from rest_framework.views        import APIView
from rest_framework.permissions  import IsAuthenticated, AllowAny
from .permissions                import IsAdminRole, IsEditorOrAdmin

class PublicPostListView(APIView):
    permission_classes = [AllowAny]          # public read

    def get(self, request):
        posts = Post.objects.all()
        ...

class PostCreateView(APIView):
    permission_classes = [IsEditorOrAdmin]   # editors + admins only

    def post(self, request):
        ...

class AdminDashboard(APIView):
    permission_classes = [IsAdminRole]       # admin-only

    def get(self, request):
        ...
views.py — ModelViewSetPython
from rest_framework.viewsets    import ModelViewSet
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators  import action
from .permissions               import IsOwnerOrAdmin, IsEditorOrAdmin

class PostViewSet(ModelViewSet):
    queryset         = Post.objects.all()
    serializer_class = PostSerializer

    def get_permissions(self):
        if self.action in ('list', 'retrieve'):
            return [AllowAny()]             # public reads
        if self.action == 'create':
            return [IsEditorOrAdmin()]      # auth + editor role
        return [IsOwnerOrAdmin()]          # update/delete: owner or admin

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

Level 3 — Object-Level Check

Object-level permissions are checked when a specific instance is being accessed. Always call self.check_object_permissions() explicitly or use self.get_object() which calls it for you.

views.pyPython
class PostUpdateView(APIView):
    permission_classes = [IsOwnerOrAdmin]

    def put(self, request, pk):
        post = get_object_or_404(Post, pk=pk)
        self.check_object_permissions(request, post)  # ← triggers IsOwnerOrAdmin
        serializer = PostSerializer(post, data=request.data, partial=True)
        if serializer.is_valid():
            serializer.save()
        return Response(serializer.data)

Your API Endpoint Map

POST/api/token/AllowAny
POST/api/token/refresh/AllowAny
POST/api/token/blacklist/IsAuthenticated
GET/api/posts/AllowAny
GET/api/posts/:id/AllowAny
POST/api/posts/IsAuthenticated + IsEditorOrAdmin
PUT/api/posts/:id/IsAuthenticated + IsOwnerOrAdmin
DELETE/api/posts/:id/IsAuthenticated + IsAdminRole
GET/api/admin/users/IsAuthenticated + IsAdminRole

Common Threats

Knowing the attacks is the first step to defeating them. Here are the most common vulnerabilities in JWT-based APIs and how to neutralise each one.

Token Theft (XSS)
Attacker injects script that reads localStorage and steals the access token.
Store tokens in HttpOnly cookies, not localStorage. Enable Content-Security-Policy headers.
Algorithm Confusion
JWT header changed to alg: none or RS256 key swapped — server accepts forged tokens.
Whitelist allowed algorithms explicitly. Never accept alg: none. Use simplejwt's ALLOWED_ALGORITHMS setting.
Brute-Force Login
Attacker hammers /api/token/ with password lists until one works.
Apply aggressive throttle (e.g. 5/minute) on the token endpoint. Add account lockout after N failures.
CSRF on Cookie Storage
If tokens are stored in cookies, CSRF attacks can forge authenticated requests.
Use SameSite=Strict cookies. DRF's SessionAuthentication adds CSRF enforcement automatically.
Sensitive Data in Payload
Passwords, PII, or card numbers embedded in unencrypted JWT payload leak on decode.
Only store non-sensitive identifiers (user_id, role) in the payload. Use JWE if encryption is needed.
Long-Lived Tokens
An access token valid for days gives attackers a wide window if stolen.
Keep access tokens short (5–15 min). Use refresh tokens for persistence. Enable rotation + blacklisting.

Production Hardening Checklist

settings.py — production hardeningPython
# ── HTTPS ──────────────────────────────────────────
SECURE_SSL_REDIRECT         = True
SECURE_HSTS_SECONDS         = 31536000     # 1 year HSTS
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD         = True
SESSION_COOKIE_SECURE       = True
CSRF_COOKIE_SECURE          = True

# ── Security headers ──────────────────────────────
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER   = True
X_FRAME_OPTIONS             = 'DENY'

# ── CORS (if using a separate frontend) ───────────
# pip install django-cors-headers
CORS_ALLOWED_ORIGINS = ['https://yourdomain.com']
CORS_ALLOW_CREDENTIALS = True  # only if using cookie auth

# ── Debug must be OFF in production ───────────────
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com']
🚨 Never commit SECRET_KEY Your SECRET_KEY is the signing key for every JWT. If it leaks, all tokens can be forged. Use environment variables: SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] and rotate it immediately if exposed.

Logging & Monitoring

views.py — audit logging examplePython
import logging
logger = logging.getLogger('security')

class MyTokenView(TokenObtainPairView):
    def post(self, request, *args, **kwargs):
        response = super().post(request, *args, **kwargs)
        if response.status_code == 200:
            logger.info("LOGIN_SUCCESS user=%s ip=%s",
                request.data.get('username'),
                request.META.get('REMOTE_ADDR'))
        else:
            logger.warning("LOGIN_FAILED user=%s ip=%s",
                request.data.get('username'),
                request.META.get('REMOTE_ADDR'))
        return response

Knowledge Quiz

Six questions across JWT, RBAC, and endpoint security. Click to reveal the answer and explanation.

1. A JWT's payload contains {"role":"admin"}. Is this value trustworthy?
2. Why should access tokens be short-lived (5–15 minutes) while refresh tokens can last days?
3. In DRF, which method do you override in a ModelViewSet to apply different permissions for different actions (list, create, update)?
4. What is the difference between has_permission() and has_object_permission()?
5. An attacker modifies the JWT header to {"alg": "none"} and removes the signature. What setting prevents your server from accepting this?
6. You want to "log out" a user by invalidating their JWT. What is the correct approach with simplejwt?