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.
The Three Pillars
| Pillar | What it answers | Implementation |
|---|---|---|
| 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
Session vs JWT — Quick Comparison
| Aspect | Session-based | JWT |
|---|---|---|
| State | Stateful (server stores session) | Stateless (server stores nothing) |
| Storage | Server DB / cache | Client (localStorage / cookie) |
| Scaling | Needs shared session store | Works across multiple servers |
| Revocation | Easy — delete session row | Hard — must use a denylist |
| Payload | Only a session ID | Can carry claims (role, email…) |
| Best for | Traditional 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
Base64Url encoded.
Base64Url encoded.
What a real JWT looks like
■ Header · ■ Payload · ■ Signature
Decoded Payload Example
{
"user_id": 42,
"username": "alice",
"role": "editor",
"iat": 1700000000, // issued at (Unix timestamp)
"exp": 1700003600 // expires in 1 hour
}
Access Token vs Refresh Token
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | Short — 5 to 60 minutes | Long — 7 to 30 days |
| Sent to API | Yes — every request | No — only to /token/refresh |
| If stolen | Attacker has minutes | Attacker can get new access tokens |
| Storage | Memory (SPA) or HttpOnly cookie | HttpOnly cookie (recommended) |
Token Refresh Flow
# 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>
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
pip install djangorestframework djangorestframework-simplejwtINSTALLED_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
],
}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',),
}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
]python manage.py migratefrom 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
# 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
Permission Matrix
| Endpoint | Anonymous | Viewer | Editor | Admin |
|---|---|---|---|---|
| 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
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
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 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.
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
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):
...
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.
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
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.
alg: none or RS256 key swapped — server accepts forged tokens.alg: none. Use simplejwt's ALLOWED_ALGORITHMS setting.Production Hardening Checklist
# ── 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']
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
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.
{"role":"admin"}. Is this value trustworthy?ModelViewSet to apply different permissions for different actions (list, create, update)?has_permission() and has_object_permission()?{"alg": "none"} and removes the signature. What setting prevents your server from accepting this?