Django’s CSRF and CORS protections, while seemingly similar, guard against fundamentally different types of attacks.

Let’s see them in action. Imagine a simple Django API endpoint that accepts POST requests to update a user’s profile:

# api/views.py
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
import json

@require_http_methods(["POST"])
@csrf_exempt # For demonstration, normally you'd handle CSRF properly
def update_profile(request):
    if request.method == "POST":
        try:
            data = json.loads(request.body)
            user_id = data.get('user_id')
            new_bio = data.get('bio')

            if user_id and new_bio:
                # In a real app, fetch user and update their profile
                print(f"Updating profile for user {user_id} with bio: {new_bio}")
                return JsonResponse({"status": "success", "message": "Profile updated"})
            else:
                return JsonResponse({"status": "error", "message": "Missing user_id or bio"}, status=400)
        except json.JSONDecodeError:
            return JsonResponse({"status": "error", "message": "Invalid JSON"}, status=400)
    return JsonResponse({"status": "error", "message": "Method not allowed"}, status=405)

And a corresponding urls.py entry:

# api/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('update-profile/', views.update_profile, name='update_profile'),
]

Now, consider a frontend application served from a different domain (e.g., http://localhost:3000) trying to POST to this API (e.g., at http://localhost:8000/api/update-profile/). Without proper CORS configuration, the browser will block this request.

This is where CORS (Cross-Origin Resource Sharing) comes in. The browser enforces the Same-Origin Policy (SOP) by default. If a script running on http://localhost:3000 tries to make a request to http://localhost:8000, and these are different origins (different scheme, domain, or port), the browser will block it unless the server (http://localhost:8000) explicitly allows it by sending specific CORS headers.

To enable your Django API to be accessed by your frontend on http://localhost:3000, you’d install django-cors-headers:

pip install django-cors-headers

Then, add it to your INSTALLED_APPS in settings.py:

# settings.py
INSTALLED_APPS = [
    # ... other apps
    'corsheaders',
    'api', # your api app
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware', # Place this as high as possible, especially before common middleware like SecurityMiddleware or SessionMiddleware
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Configure CORS
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000",
]

# If you need to allow specific methods or headers:
# CORS_ALLOW_METHODS = [
#     "DELETE",
#     "GET",
#     "OPTIONS",
#     "PATCH",
#     "POST",
#     "PUT",
# ]
# CORS_ALLOW_HEADERS = [
#     "accept",
#     "authorization",
#     "content-type",
#     "dnt",
#     "origin",
#     "user-agent",
#     "x-csrftoken",
#     "x-requested-with",
# ]

With this, your frontend at http://localhost:3000 can now POST to http://localhost:8000/api/update-profile/. The CorsMiddleware intercepts the request, checks the Origin header from the browser, and if it’s in CORS_ALLOWED_ORIGINS, it adds the Access-Control-Allow-Origin: http://localhost:3000 header (or * if * is allowed) to the response.

CSRF (Cross-Site Request Forgery), on the other hand, protects your users from malicious websites tricking their browser into performing unwanted actions on your site while they are logged in.

Consider this: A user is logged into your Django application (http://localhost:8000). They then visit a malicious website (http://malicious.com). This malicious site might contain a hidden form that POSTs to your http://localhost:8000/api/update-profile/ endpoint. If CSRF protection isn’t in place, the browser will automatically include the session cookie for localhost:8000 with the request, and your Django app will think the logged-in user intentionally wants to update their profile, executing the action.

Django’s built-in CSRF middleware (django.middleware.csrf.CsrfViewMiddleware) prevents this. For any POST, PUT, DELETE, or PATCH request to a Django view, it expects a valid CSRF token. This token is typically generated by Django’s {% csrf_token %} template tag and included in forms. When a request comes in without a valid token (or the wrong one), the middleware rejects it with a 403 Forbidden error.

For API-only use cases where your frontend is served from a different origin and you’re managing authentication separately (e.g., using tokens), you might need to disable CSRF for specific API endpoints. You can do this using the @csrf_exempt decorator, as shown in the update_profile view above. However, this should be done with extreme caution. A more secure approach for token-based authentication is to ensure your API clients include the CSRF token in a custom header (e.g., X-CSRFToken) and configure django-cors-headers to allow this header. The CsrfViewMiddleware will then look for this header.

The most surprising truth about CSRF and CORS is that they address entirely separate security domains and often require complementary configurations, especially for modern SPAs and APIs. CORS is about inter-origin communication as permitted by the browser, whereas CSRF is about preventing unauthorized state-changing actions initiated by malicious sites.

When you configure CORS_ALLOWED_ORIGINS to include your frontend’s origin (e.g., http://localhost:3000), you’re essentially telling the browser, "It’s okay for resources loaded from http://localhost:3000 to make requests to my server." The browser then checks if the response from your server includes the Access-Control-Allow-Origin header matching the requesting origin.

The django-cors-headers middleware handles adding these Access-Control-* headers. For POST requests from a different origin, the browser first sends an OPTIONS preflight request. The CorsMiddleware intercepts this, checks CORS_ALLOWED_ORIGINS, CORS_ALLOW_METHODS, and CORS_ALLOW_HEADERS, and if they match, it sends back headers like Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. If these preflight checks pass, the browser then sends the actual POST request.

For CSRF, the CsrfViewMiddleware by default checks for the X-CSRFToken cookie and a matching X-CSRFToken header. If you’re using token authentication and your frontend sends the token in a header, ensure that CORS_ALLOW_HEADERS in your django-cors-headers configuration includes "x-csrftoken". This allows the browser to send the CSRF token header to your Django backend, which the CsrfViewMiddleware can then validate.

The one thing many developers overlook is how the OPTIONS preflight request and its allowed headers interact with CSRF token handling. If your CORS_ALLOW_HEADERS doesn’t explicitly include "x-csrftoken", the browser won’t send the CSRF token in the preflight request, and the actual POST request might also fail CSRF checks, even if the OPTIONS request was allowed by CORS.

Ultimately, understanding the distinct roles of CORS (browser-level cross-origin access control) and CSRF (application-level unauthorized action prevention) is key to securing your Django applications effectively.

Want structured learning?

Take the full Django course →