Originally published on djangix.com
JWT authentication in Django usually breaks in a very specific way: login succeeds, you copy the access token, and the very next request comes back with 401 Unauthorized. The response body is the whole diagnosis — if you read it before changing code, this is a ten-minute fix instead of an afternoon of guessing.
The short answer is this: a Simple JWT 401 almost always means one of four things. The access token has expired, the wrong token (a refresh token) was sent to an API endpoint, the Authorization header never reached Django, or the token was signed with a different secret than the server now uses. Below is how to tell which one you have, and the correct fix for each.
What the JWT authentication error actually says
Django REST Framework with Simple JWT returns a JSON body, not just a status code. Read the detail, code, and the per-message fields:
{
"detail": "Given token not valid for any token type",
"code": "token_not_valid",
"messages": [
{
"token_class": "AccessToken",
"token_type": "access",
"message": "Token is invalid or expired"
}
]
}
That one body already narrows the problem down:
- code token_not_valid, message Token is invalid or expired: the token failed validation — it is expired, malformed, blacklisted, or signed with a different key. Check the steps below in order.
- detail Authentication credentials were not provided: this is a different bug. No usable Authorization header reached the view at all, so Django never even saw a token. Skip to the header check below.
- detail Given token not valid for any token type, with a valid-looking token: you very often sent the refresh token where an access token belongs. The two token types are not interchangeable.
- Token is blacklisted: the refresh token was already rotated or logged out. The client must log in again — no server-side setting will resurrect that token.
JWT authentication settings that cause most 401 errors
Most production 401s are configuration, not code. This is a known-good baseline for Simple JWT 5.5.1, the current release on PyPI at the time of writing:
# settings.py
from datetime import timedelta
INSTALLED_APPS = [
# ...
"rest_framework",
"rest_framework_simplejwt.token_blacklist",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
Three settings in that block explain most surprises:
- Short default access lifetime: out of the box, Simple JWT access tokens live for five minutes and refresh tokens for one day. If your frontend never calls the refresh endpoint, every user appears to be logged out after five minutes. That is expiry working as designed, not a bug.
- Signing key follows SECRET_KEY: by default tokens are signed with your Django SECRET_KEY. Regenerate or change SECRET_KEY in production and every existing token instantly becomes invalid. Users must log in again, and no code fix changes that.
- The blacklist app must be installed: if you enable blacklist-after-rotation without adding the token_blacklist app and running migrations, rotation breaks. Install the app, migrate, then enable the setting.
Wire the endpoints once, in your root URL configuration:
# 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"),
]
Fix JWT token expired errors with a real refresh flow
Expired access tokens are normal. The bug is a client that treats expiry as a logout instead of refreshing once and retrying the original request:
async function apiFetch(url, options = {}) {
const makeRequest = (access) => fetch(url, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${access}`,
},
});
let response = await makeRequest(localStorage.getItem("access"));
if (response.status !== 401) return response;
const refreshResponse = await fetch("/api/token/refresh/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh: localStorage.getItem("refresh") }),
});
if (!refreshResponse.ok) return response; // refresh is dead: log the user out
const data = await refreshResponse.json();
localStorage.setItem("access", data.access);
if (data.refresh) localStorage.setItem("refresh", data.refresh);
return makeRequest(data.access);
}
Note the two details people miss. First, with rotation enabled the refresh response can contain a new refresh token — store it, or the next refresh will present a blacklisted token. Second, only retry on 401, and only once. Retrying in a loop turns a dead session into a request storm.
Still not sure whether the token itself is the problem? Inspect it server-side before blaming the frontend:
from rest_framework_simplejwt.tokens import AccessToken
token = AccessToken("paste-the-access-token-here")
print(token["token_type"]) # must be "access"
print(token["user_id"])
print(token["exp"]) # UTC epoch seconds: compare with your server clock
If constructing the token raises an error, the token is malformed or signed with another key. If it parses but the exp value is in the past, it is simply expired and the refresh flow above is the fix. Also check server time: a container or VM with a skewed clock will reject fresh tokens as not yet valid or already expired.
One related trap: a browser can drop the Authorization header before Django ever sees it. If your API and frontend are on different origins and preflight requests fail, treat it as a CORS problem first — see our guide to being blocked by the CORS policy. And do not confuse this with cookie-session failures: header-based JWT auth does not need a CSRF token, which is a separate mechanism covered in our post on Django CSRF verification failed. Verifying a credential before trusting it is the same discipline we apply to webhooks in our Stripe webhook verification guide.
The wrong fixes for invalid token errors
These appear in almost every forum thread, and each one trades a small bug for a bigger one:
- Stretching the access token lifetime to 30 days: the 401s stop, but a stolen access token now works for a month and cannot be revoked. Keep access tokens short and fix the refresh flow instead.
- Turning off expiry checks or adding a huge leeway: a large LEEWAY setting hides clock problems for a while and quietly accepts tokens that should be dead.
- Commenting out IsAuthenticated to make the error go away: the endpoint now works for everyone, including people with no token at all. You have removed the lock, not fixed the key.
- Regenerating SECRET_KEY every deploy: every deploy logs out every user and invalidates every token, because the signing key changed. Keep SECRET_KEY stable in your environment, and rotate it only as a deliberate, communicated event.
- Sending whichever token is at hand: access and refresh tokens have different token_type claims and different jobs. An endpoint that expects an access token will correctly reject a refresh token every time.
Final checklist for JWT authentication in Django
Work through these in order and the 401 will name itself:
- Read the response body first: token_not_valid, credentials not provided, and blacklisted tokens are three different bugs with three different fixes.
- Confirm the token type: decode the token and check that token_type is access for API calls.
- Check expiry and server time: compare the exp claim with a correct UTC clock on the server.
- Verify the header arrives: confirm the Authorization Bearer header reaches Django, through your proxy and CORS configuration.
- Keep SECRET_KEY stable: if it changed, old tokens are invalid by design and users must log in again.
- Implement refresh properly: refresh once on 401, store any rotated refresh token, retry the original request once, and log out cleanly if the refresh token is rejected.
Do that, and JWT authentication stops being a mystery error and becomes what it should be: a short-lived access token, a working refresh flow, and a 401 you can explain in one sentence.

Top comments (0)