DEV Community

Cover image for 🥷 The Mysterious Django 403: CSRF Token Missing (or So I Thought)
Bharat Solanke
Bharat Solanke

Posted on

🥷 The Mysterious Django 403: CSRF Token Missing (or So I Thought)

Image description

Recently, I ran into a frustrating issue while working on a Django project. Everything was working fine — the forms had csrf_token, the session was active, and I was submitting data via POST.

But out of nowhere, I started getting this error:

📌 403 Forbidden — CSRF token missing or incorrect

Sometimes the form would submit successfully, and other times it would just fail. No clear pattern, no obvious mistake. It felt random.
After hours of digging, I finally figured out what was going wrong — and I’m sharing it here to save you the same headache.

😤 Why Was This Happening?
At first glance, everything was correct:

  • The form used method="POST"
  • csrf_token was present
  • I was logged in
  • No JavaScript fetch, just regular form submission

But then I noticed something: the error usually happened when the form had been open for a while. That’s when it clicked.

🕒 The Problem Was Session Expiry
In my Django settings, I had this:

SESSION_COOKIE_AGE = 10 * 60  # 10 minutes
Enter fullscreen mode Exit fullscreen mode

That means: if a user is inactive for 10 minutes, the session expires — along with the CSRF token. By default, Django only saves session data when it's been modified (SESSION_SAVE_EVERY_REQUEST is False). This means that simply navigating to a page doesn't automatically extend the session's life unless some session data is explicitly changed.

So, if the user opened a form and waited more than 10 minutes before submitting, Django would silently drop the session and reject the request.

Boom: 403 Forbidden.

🔍 Wait… What is CSRF Token Anyway?

Before we dive into the fix, let’s understand what CSRF actually is.
CSRF stands for Cross-Site Request Forgery. It’s a type of attack where a malicious website tricks a user (who is already logged in to another site) into submitting a request — like changing their password or submitting a form — without their knowledge.
To prevent this, Django uses a CSRF token — a random string that:

  • Is generated when the page loads
  • Is tied to the user’s session
  • Must be sent back with any POST, PUT, or DELETE request

If the token is missing or doesn’t match the one Django expects, the request is blocked with a 403 error.

You include it in templates like this:

<form method="POST">
   {% csrf_token %}
  <!-- your fields here -->
</form>

Enter fullscreen mode Exit fullscreen mode

And if you're using JavaScript (AJAX or fetch), you must manually add it to the request header like:

headers: {
  'X-CSRFToken': csrfToken,
}
Enter fullscreen mode Exit fullscreen mode

🧪 The Temporary Fix I Tried (@csrf_exempt)
Like many devs in a hurry, I tried this first:

from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def my_view(request):
    # process POST data

Enter fullscreen mode Exit fullscreen mode

This worked — no more CSRF errors. But it didn’t feel right.

❌ The Problem With This Approach

By skipping CSRF validation entirely, I was also removing a major security layer.
If someone crafted a malicious form on another site, they could trick a logged-in user into submitting data to my app — without any protection.
So @csrf_exempt is okay for internal, background APIs or auto-save endpoints (maybe), but definitely not for actual user-facing forms.

✅ The Right Way: Keep the Session Alive While the User Is Active

Instead of disabling CSRF protection, I decided to keep the session alive automatically — as long as the user is on the page and interacting.
Here’s how I did it.

🔧 Backend: Session Keep-Alive Views

from django.http import JsonResponse
from django.utils.timezone import now, localtime
from django.contrib.auth.decorators import login_required

@login_required
def keep_session_alive(request):
    request.session.modified = True
    expiry = request.session.get_expiry_date()
    remaining = int((expiry - localtime(now())).total_seconds())
    if remaining < 0:
        return JsonResponse({"status": "expired", "expires_in": 0})
    return JsonResponse({"status": "alive", "expires_in": remaining})

@login_required
def refresh_session(request):
    request.session.modified = True
    return JsonResponse({"status": "success", "timestamp": str(now())})

Enter fullscreen mode Exit fullscreen mode

💻 Frontend: JavaScript to Keep Session Alive and Detect Expiry

In the HTML template, I added this:

<script>
// Refresh session every 5 minutes
setInterval(() => {
  fetch('/refresh-session/', {
    method: 'GET',
    credentials: 'same-origin'
  });
}, 5 * 60 * 1000);

// Warn user if session expired
setInterval(() => {
  fetch('/keep-alive/', {
    method: 'GET',
    credentials: 'same-origin'
  }).then(res => res.json())
    .then(data => {
      if (data.status === "expired") {
        alert("⚠️ Your session has expired. Please refresh the page.");
        window.location.reload();
      }
    });
}, 60 * 1000);
</script>

Enter fullscreen mode Exit fullscreen mode

Now, as long as the user keeps the page open and active, their session is refreshed silently in the background. No more unexpected CSRF failures.

🎯 Final Thoughts

Django's CSRF protection is essential — but it’s easy to run into problems if your sessions expire quietly in the background.
In my case, the root cause wasn’t a bug — it was session timeout silently breaking the CSRF token. The fix wasn’t disabling CSRF — it was keeping the session alive automatically.
So if you're seeing 403 CSRF errors randomly, especially after the form is left open for a while — check your session timeout and implement a keep-alive mechanism. It works beautifully and keeps your app secure.

Top comments (0)