Fixing Django REST Framework JWT Auth Tokens That Expire Mid-Session

June 02, 2026 8 min read 58 views
Flat illustration of a padlock with circular refresh arrows on a soft blue gradient, representing JWT token rotation and renewal

Your users are halfway through filling out a form, or right in the middle of a multi-step workflow, and suddenly they hit a 401. The frontend throws them back to the login screen, they lose their progress, and you get a bug report. JWT token expiry mid-session is one of the most common and most frustrating auth problems in Django REST Framework apps.

The good news is that the fix is well-understood. The bad news is there are a few layers to it, and skipping any one of them means the problem keeps coming back.

What you'll learn

  • Why JWT access tokens expire and what the right token lifetime strategy looks like
  • How to configure djangorestframework-simplejwt for sliding refresh tokens
  • How to build a silent token renewal flow on your frontend
  • How to handle token rotation securely without locking users out
  • Common pitfalls that cause tokens to expire even when your config looks correct

Prerequisites

This article assumes you have a working Django project using Django REST Framework and djangorestframework-simplejwt for authentication. The examples use Python 3.10+ and Django 4.x, but the concepts apply to older versions too. You should be comfortable with DRF views, serializers, and basic JWT concepts.

Why JWT Tokens Expire Mid-Session

A JWT access token has a fixed expiry baked into its payload. Unlike a session cookie that a server can invalidate or extend at will, a JWT is stateless. Once issued, the server has no record of it β€” it just trusts any token that arrives with a valid signature and a non-expired timestamp.

The typical default access token lifetime in simplejwt is five minutes. If your user opens a tab, gets distracted, comes back ten minutes later, and hits submit β€” they get a 401. The server is not being rude; it is being correct. The token really is expired.

The real fix is not to make access tokens live forever (that defeats the point of short-lived tokens). The fix is to silently refresh the access token in the background, using a longer-lived refresh token, before the access token expires.

Understanding the Two-Token Pattern

The standard approach with simplejwt uses two tokens:

  • Access token β€” short-lived (5–30 minutes), sent with every API request in the Authorization header.
  • Refresh token β€” longer-lived (days or weeks), stored securely, used only to obtain a new access token.

The access token being short-lived limits the damage if it leaks. The refresh token being longer-lived means the user stays logged in as long as they are active. The key insight is that your frontend needs to refresh the access token proactively, before it actually expires.

Configuring simplejwt for Sane Token Lifetimes

First, get your settings.py configuration right. The defaults are usually too short for access tokens and too permissive about rotation.

from datetime import timedelta

SIMPLE_JWT = {
    # How long an access token stays valid
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),

    # How long a refresh token stays valid
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),

    # Issue a new refresh token every time one is used (rotation)
    "ROTATE_REFRESH_TOKENS": True,

    # Blacklist the old refresh token after rotation
    # Requires 'rest_framework_simplejwt.token_blacklist' in INSTALLED_APPS
    "BLACKLIST_AFTER_ROTATION": True,

    # Algorithm β€” stick with HS256 unless you need asymmetric keys
    "ALGORITHM": "HS256",

    # Where to look for the token in the request
    "AUTH_HEADER_TYPES": ("Bearer",),

    # Update last_login on token refresh (optional, has a DB write cost)
    "UPDATE_LAST_LOGIN": False,
}

Set ROTATE_REFRESH_TOKENS to True. Every time the client uses a refresh token to get a new access token, it also receives a new refresh token. The old one gets blacklisted. This means a stolen refresh token that has already been used is dead, and it also means the session stays alive as long as the user keeps making requests.

If you enable BLACKLIST_AFTER_ROTATION, add the blacklist app to your installed apps and run migrations:

INSTALLED_APPS = [
    # ... your other apps
    "rest_framework_simplejwt.token_blacklist",
]
python manage.py migrate

Exposing the Refresh Endpoint

Make sure your URL config includes the token refresh endpoint. This is the endpoint your frontend will call silently to get a new access token.

# urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

urlpatterns = [
    path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
]

The TokenRefreshView accepts a POST with a refresh body field and returns a new access token (and a new refresh token if rotation is enabled).

Silent Token Renewal on the Frontend

This is where most implementations fall short. The backend config alone solves nothing if the frontend keeps sending expired access tokens and only reacts to 401 errors after the fact.

The correct approach is to refresh proactively. Decode the access token's payload (it is just base64), read the exp timestamp, and schedule a refresh a minute or two before it expires.

Here is a pattern that works well with a plain JavaScript fetch-based API client. The same logic applies in React, Vue, or any other frontend framework.

// tokenManager.js

let accessToken = null;
let refreshTimer = null;

function parseTokenExpiry(token) {
  try {
    const payload = JSON.parse(atob(token.split(".")[1]));
    return payload.exp * 1000; // convert to milliseconds
  } catch {
    return null;
  }
}

export function setAccessToken(token) {
  accessToken = token;
  scheduleRefresh();
}

export function getAccessToken() {
  return accessToken;
}

function scheduleRefresh() {
  if (refreshTimer) clearTimeout(refreshTimer);

  const expiry = parseTokenExpiry(accessToken);
  if (!expiry) return;

  // Refresh 60 seconds before the token actually expires
  const delay = expiry - Date.now() - 60_000;

  if (delay <= 0) {
    // Already expired or about to β€” refresh immediately
    refreshAccessToken();
    return;
  }

  refreshTimer = setTimeout(refreshAccessToken, delay);
}

async function refreshAccessToken() {
  const refreshToken = localStorage.getItem("refreshToken");
  if (!refreshToken) return;

  try {
    const response = await fetch("/api/token/refresh/", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refresh: refreshToken }),
    });

    if (!response.ok) {
      // Refresh token itself has expired β€” force logout
      handleLogout();
      return;
    }

    const data = await response.json();
    setAccessToken(data.access);

    // If rotation is enabled, save the new refresh token too
    if (data.refresh) {
      localStorage.setItem("refreshToken", data.refresh);
    }
  } catch (err) {
    console.error("Token refresh failed:", err);
  }
}

function handleLogout() {
  accessToken = null;
  localStorage.removeItem("refreshToken");
  window.location.href = "/login";
}

Call setAccessToken(data.access) right after login, and the timer takes care of itself from there. Every API request reads from getAccessToken(), which always returns the latest valid token.

Intercepting Failed Requests as a Fallback

The proactive refresh handles the normal case. But network issues, browser suspension, or clock drift can still cause a 401 to slip through. Add a response interceptor as a safety net.

// apiClient.js
import { getAccessToken, refreshAccessToken } from "./tokenManager";

let isRefreshing = false;
let failedQueue = [];

function processQueue(error, token = null) {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
}

export async function apiFetch(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${getAccessToken()}`,
      "Content-Type": "application/json",
    },
  });

  if (response.status !== 401) return response;

  // Got a 401 β€” try to refresh once
  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      failedQueue.push({ resolve, reject });
    }).then((token) => {
      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${token}`,
        },
      });
    });
  }

  isRefreshing = true;

  try {
    const newToken = await refreshAccessToken();
    processQueue(null, newToken);
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${newToken}`,
      },
    });
  } catch (err) {
    processQueue(err, null);
    throw err;
  } finally {
    isRefreshing = false;
  }
}

The queue logic prevents multiple concurrent requests from all triggering separate refresh attempts when the token expires. Only one refresh call goes out; the rest wait and then retry with the new token.

Common Pitfalls

Server clock drift

JWT expiry is compared against the current UTC time on the server. If your server clock drifts even a few minutes, tokens appear expired before they should be. Run NTP on your server. In Docker environments, time sync issues are more common than you would expect.

simplejwt has a LEEWAY setting that adds a small grace period for clock skew:

from datetime import timedelta

SIMPLE_JWT = {
    # ... other settings
    "LEEWAY": timedelta(seconds=10),
}

Storing tokens in localStorage vs httpOnly cookies

Storing the refresh token in localStorage is convenient but exposes it to XSS attacks. If your threat model warrants it, store the refresh token in an httpOnly cookie instead. Django can set this on login and the browser sends it automatically. The tradeoff is added complexity in your Django views to read the cookie and call the refresh logic server-side.

Forgetting to handle tab suspension

Mobile browsers and some desktop browsers suspend background tabs. When the tab becomes active again, the setTimeout timer may have already fired (or been delayed significantly). Listen for the visibilitychange event and re-check the token expiry when the tab regains focus:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") {
    scheduleRefresh(); // re-evaluate and refresh if needed
  }
});

Not blacklisting tokens on logout

If the user logs out, you need to blacklist the refresh token on the server β€” not just delete it from the client. Otherwise anyone who captured the refresh token can keep using it until it naturally expires.

# views.py
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError

class LogoutView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        refresh_token = request.data.get("refresh")
        if not refresh_token:
            return Response(
                {"detail": "Refresh token required."},
                status=status.HTTP_400_BAD_REQUEST
            )
        try:
            token = RefreshToken(refresh_token)
            token.blacklist()
        except TokenError as e:
            return Response(
                {"detail": str(e)},
                status=status.HTTP_400_BAD_REQUEST
            )
        return Response(status=status.HTTP_205_RESET_CONTENT)

Wrapping Up

JWT mid-session expiry is almost always a frontend timing problem, not a backend configuration problem. The backend is doing what it should. The gap is the missing proactive refresh logic.

Here are the concrete steps to take right now:

  • Set a sensible access token lifetime β€” 15 minutes is a reasonable starting point. Not too short to cause constant churn, not so long that a leaked token is a major problem.
  • Enable refresh token rotation and blacklisting β€” add ROTATE_REFRESH_TOKENS = True and BLACKLIST_AFTER_ROTATION = True to your SIMPLE_JWT config, run migrations.
  • Implement proactive token refresh on the frontend β€” parse the exp claim from the access token, set a timer to refresh 60 seconds before expiry, and reset the timer on every new access token.
  • Add a 401 interceptor as a fallback β€” queue up concurrent failed requests and retry them after a single refresh attempt succeeds.
  • Blacklist refresh tokens on logout β€” add a logout endpoint that calls token.blacklist() server-side, so deleted sessions stay deleted.

Get these five pieces in place and your users will stop seeing unexpected 401s mid-session. The auth layer will quietly handle token renewal in the background, exactly as they expect it to.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.