7.1 KiB
API Security Phase 1 — Robust Endpoint Protection
Issue: crisleo-hermes/job-tracker#7
Feature branch: feature/api-security-phase-1
Date: 2026-05-27
Overview
Implement comprehensive API security measures: short-lived JWT with refresh token rotation, token blacklisting, role-based access control, rate limiting tightening, input sanitization, a custom exception handler that suppresses stack traces, and minimal sensitive data exposure in API responses.
Backend Specification
1. Token Lifecycle Changes
Shortened JWT Lifetimes
| Setting | Current | New | Rationale |
|---|---|---|---|
ACCESS_TOKEN_LIFETIME |
24 hours | 15 minutes | Minimises window for stolen tokens |
REFRESH_TOKEN_LIFETIME |
30 days | 7 days | Limits refresh token exposure |
ROTATE_REFRESH_TOKENS |
(not set) | True |
Old refresh token invalidated on each refresh |
BLACKLIST_AFTER_ROTATION |
(not set) | True |
Used refresh tokens cannot be replayed |
Token Blacklist Model
Add a BlacklistedToken model storing the JWT jti (JWT ID claim), the user, and the expiry datetime. This allows explicit logout (invalidate current token) and ensures rotated refresh tokens cannot be reused.
Model: accounts.BlacklistedToken
| Field | Type | Notes |
|---|---|---|
jti |
UUIDField(unique=True) |
JWT ID from the token payload |
user |
ForeignKey(User) |
Owner of the token |
created_at |
DateTimeField(auto_now_add=True) |
When it was blacklisted |
expires_at |
DateTimeField() |
When the token would have naturally expired |
A management command (cleartokens) purges expired entries.
New API Endpoints
POST /api/auth/logout/ — Blacklist the current refresh token.
Request:
{ "refresh": "<refresh_token>" }
Validation:
- Refresh token must be valid (not expired, not already blacklisted)
- Returns 205 Reset Content on success (204 would also be acceptable; 205 signals the client should reset its state)
POST /api/auth/refresh/ — Override of SimpleJWT's built-in refresh with rotation + blacklist.
Same shape as SimpleJWT default — accepts { "refresh": "<token>" }, returns { "access": "...", "refresh": "..." }.
GET /api/auth/me/ — Return the current user's profile.
Requires valid access token. Returns:
{ "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe" }
2. Role-Based Access Control (RBAC)
Two built-in roles via Django's Groups:
| Role | Permissions |
|---|---|
admin |
Full CRUD on all resources |
user (default) |
CRUD on own resources only |
No separate Role model — reuse Django's built-in django.contrib.auth.models.Group.
Permission Logic
IsAdminOrReadOnly— admin can do anything; authenticated users can read; anonymous deniedIsOwnerOrAdmin— object-level permission: user can modify their own resources; admin can modify anything
Model Ownership
Add user = ForeignKey(User, on_delete=CASCADE, related_name="job_applications") to JobApplication. This is a schema change — existing records get a default of the first superuser (migration handles this).
Filter queries by request.user for non-admin users. Admins see all records.
3. Custom Exception Handler
Create accounts.exceptions module with a DRF custom exception handler that:
- Returns
{"error": "message", "code": "error_code"}for all API errors - NEVER includes stack traces, file paths, or Python internals
- Maps DRF's built-in exceptions to user-safe messages
- Logs the full traceback to
django.log(sensitive details go to logs, not to the client) - Returns 500 with
{"error": "Internal server error.", "code": "internal_error"}for unhandled exceptions
4. Input Validation & Sanitization
Current state: RegisterSerializer already validates email format, password length, and password match.
Additions:
- Strip leading/trailing whitespace from all string fields across all serializers (
JobApplicationSerializer,JobUpdateSerializer) - Reject null bytes (
\x00) in string inputs (null-byte injection protection) - Add a reusable
SanitizedCharFieldthat strips control characters (ASCII 0x00–0x1F except\t,\n,\r) on deserialization - Validate
company_nameandposition_titlemax lengths (already on model, enforce in serializer)
5. Rate Limiting Enhancements
| Scope | Rate | Applied to |
|---|---|---|
anon |
10/hour |
All anonymous endpoints |
auth |
5/minute |
Login, Register (already implemented) |
user (new) |
60/minute |
All authenticated endpoints |
Implement a custom UserRateThrottle that scopes by user ID for authenticated requests.
6. Security Middleware
accounts.middleware.SecurityHeadersMiddleware
Adds response headers:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: geolocation=(), microphone=(), camera=()Strict-Transport-Security: max-age=31536000; includeSubDomains(only when not DEBUG)
7. Sensitive Data Exposure
UserSerializeralready only exposesid,email,first_name,last_name❌- Remove
emailfromUserSerializer— useid,first_name,last_nameonly (email is PII) - In
JobApplicationSerializer, excludenotesfrom list responses (only include in detail) - Add
user_idtoJobApplicationSerializer(read-only, set automatically on create)
Frontend Specification
Token Refresh Interceptor
Add an Axios response interceptor that:
- Detects 401 responses
- Tries
POST /api/auth/refresh/with the stored refresh token - On success: replaces
access_tokenin localStorage, retries the original request - On failure: clears tokens, redirects to
/login
Protected Route Component
Create <ProtectedRoute> component that:
- Reads
isAuthenticatedfromAuthContext - If not authenticated: redirects to
/loginwith a?redirect=param - If loading: shows a loading spinner
- If authenticated: renders children
Logout in AuthContext
Add a logout() call that:
- Calls
POST /api/auth/logout/with the stored refresh token - Clears tokens from localStorage
- Dispatches
LOGOUTaction - Redirects to
/login
Auth Flow Updates
LoginPage/RegisterPage: redirect authenticated users to/viaNavigate(already handled viaAuthContext)- HomePage: no change needed — it already uses
useAuthand the API client attaches the token
Migration Notes
accountsapp: addBlacklistedTokenmodel →0002_blacklistedtokenjobsapp: adduserFK toJobApplication→0002_jobapplication_user- Existing rows: assign to the first superuser via
RunPythonmigration - New
unique_together = ["user", "company_name", "position_title"]
- Existing rows: assign to the first superuser via
Security Considerations (Phase 1 Gaps)
- No API key for machine-to-machine access (deferred to Phase 2)
- No rate limiting on
/api/auth/refresh/(low risk — refresh tokens are single-use with rotation) - No CSRF protection for cookie-based auth (not applicable — JWT Bearer auth only)
- Logging configuration deferred to deployment (Phase 2)