Session Token Expired: How to Fix It and Prevent It With Proper Renewal Patterns
Quick answer
💡Session token expired errors happen when either the server-side session TTL elapses or the JWT exp claim timestamp passes. These are separate mechanisms that can expire independently. Fix the immediate error by issuing a new token via a refresh flow, then prevent recurrence by implementing rolling sessions or a silent refresh pattern. Always return 401 for expired tokens so clients know to re-authenticate.
Error symptoms
- ✕
API returns 401 Unauthorized with message 'TokenExpiredError: jwt expired' - ✕
Users are logged out unexpectedly after a period of inactivity - ✕
Session works on first request but fails on subsequent requests minutes later - ✕
Browser console shows 401 responses after the page has been open for a while - ✕
Mobile app loses authentication state when returned to from background - ✕
Refresh token flow returns 401 even though the user just logged in
Common causes
- •JWT exp claim set to a short duration like 15 minutes without a refresh token mechanism in place
- •Server-side session store TTL set lower than the cookie maxAge, causing the session to disappear before the cookie expires
- •Express-session configured without a maxAge, creating a session cookie that expires when the browser closes but the store entry persists
- •Clock skew between the token-issuing server and the token-verifying server exceeding the leeway tolerance
- •Redis session store not calling touch() on activity, so sessions expire even while the user is active
- •Refresh token being sent to the wrong endpoint, or the Authorization header missing the Bearer prefix
When it happens
- •After a user leaves an application tab open and returns after the TTL has elapsed
- •When deploying to a server with a clock that drifts relative to the token issuer
- •After switching session stores from in-memory to Redis without configuring the TTL correctly
- •When testing with short token lifetimes in development that do not match production settings
- •After a backend restart that clears in-memory session state when no persistent store is configured
Examples and fixes
A Node.js middleware that rejects tokens the instant they expire, causing failures for users on devices with slight clock differences.
JWT verification without clock skew tolerance
❌ Wrong
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const token = req.headers.authorization;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (err) {
res.status(401).json({ error: err.message });
}
}✅ Fixed
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
clockTolerance: 60
});
req.user = payload;
next();
} catch (err) {
res.status(401).json({ error: 'Token expired or invalid' });
}
}The broken version does not strip the Bearer prefix, so jwt.verify receives a string like 'Bearer eyJ...' which immediately fails as malformed. It also has zero clock tolerance, causing failures for users whose device clocks differ from the server by even a few seconds. The fix strips the prefix, adds a 60-second clockTolerance to absorb normal clock skew between issuer and verifier, and explicitly specifies the algorithm to prevent algorithm confusion attacks where an attacker substitutes HS256 for RS256. The 401 response is correct for expired tokens — it tells the client to re-authenticate rather than indicating a permission problem.
A session configuration where the store TTL and cookie maxAge are mismatched, causing sessions to expire prematurely.
Express-session with Redis and rolling expiry
❌ Wrong
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const client = createClient();
await client.connect();
app.use(session({
store: new RedisStore({ client }),
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: true,
cookie: { maxAge: 86400000 }
}));✅ Fixed
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const client = createClient();
await client.connect();
app.use(session({
store: new RedisStore({ client, ttl: 86400 }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
maxAge: 86400000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
}
}));The broken version omits ttl in the RedisStore options, so Redis uses its default TTL which may be shorter or longer than the cookie maxAge. Setting resave: true causes every request to resave the session even when nothing changed, creating unnecessary Redis writes and masking the real TTL mismatch. The fix sets ttl: 86400 seconds (24 hours) to match the cookie maxAge of 86400000 milliseconds. Setting rolling: true resets both the cookie expiration and the Redis TTL on every request, implementing a sliding window. The resave: false and saveUninitialized: false settings reduce store writes. The httpOnly, secure, and sameSite cookie attributes are security fundamentals — httpOnly blocks JavaScript access, secure prevents transmission over HTTP, and sameSite: lax protects against CSRF while allowing normal navigation.
A client-side pattern that automatically renews short-lived access tokens without logging the user out.
Silent refresh with a dedicated refresh token endpoint
❌ Wrong
// Client-side: no refresh logic
async function fetchData(url) {
const token = localStorage.getItem('access_token');
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) {
window.location.href = '/login';
}
return res.json();
}✅ Fixed
// Client-side: silent refresh on 401
let refreshPromise = null;
async function fetchWithRefresh(url, options = {}) {
const token = localStorage.getItem('access_token');
const res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` }
});
if (res.status !== 401) return res;
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(async (r) => {
refreshPromise = null;
if (!r.ok) { window.location.href = '/login'; return; }
const data = await r.json();
localStorage.setItem('access_token', data.accessToken);
});
}
await refreshPromise;
return fetchWithRefresh(url, options);
}The broken version immediately redirects to the login page whenever a 401 occurs, which creates a poor user experience when the access token simply expired and a valid refresh token exists. The fix implements a single-flight refresh pattern where multiple concurrent 401 responses trigger only one refresh request. The refreshPromise prevents thundering herd behavior where dozens of parallel API calls all try to refresh simultaneously. The refresh endpoint reads the refresh token from an httpOnly cookie using credentials: 'include', keeping the refresh token out of JavaScript access. After a successful refresh, the retry call passes through the same wrapper so subsequent failures will attempt another refresh or redirect to login if the refresh token has also expired.
How server-side sessions and JWTs expire differently
Session token expiration is not a single mechanism — it is the result of two independent systems that can fail for different reasons. Understanding which system has expired is the first step toward fixing the right thing.
Server-side sessions store state in a persistent store like Redis, a database, or in-memory. The session is identified by a session ID stored in a cookie. The server-side session has a time-to-live set in the store configuration. In Express-session with Redis, this TTL is set in the RedisStore options. The cookie sent to the browser also has a maxAge. If these two values diverge — for example the Redis TTL is shorter than the cookie maxAge — the browser will keep sending a session ID in its cookie, but the server will find no matching session in Redis. The result is a 401 or an empty session object, depending on how the application handles missing sessions.
JSON Web Tokens are different because they are stateless. The exp claim in the JWT payload is a Unix timestamp measured in seconds. The jwt.verify function compares this value to the current server time. If the current time exceeds exp, the library throws a TokenExpiredError. There is no database lookup involved — the token carries its own expiration metadata. This means once a JWT expires, there is no way to extend it without issuing a new one. The original token cannot be renewed; a new token must be generated using a refresh flow.
Clock skew between systems creates a class of expiration bugs that are easy to misdiagnose. If the server that issued the JWT has its clock set 90 seconds ahead of the server that verifies the JWT, tokens will appear expired the instant they arrive at the verifying server. The solution is to configure a clockTolerance of 60 seconds in jwt.verify options, which allows the verifier to accept tokens that appear expired by up to one minute. This tolerance should not be set higher than 60 to 90 seconds or it defeats the security value of short expiration windows.
Express-session's resave and saveUninitialized options interact with TTL in surprising ways. When resave is true, the session is saved to the store on every request, which resets the TTL. This creates a de facto rolling session even when rolling is not explicitly set to true. When resave is false, the session is only saved when it was modified, so the TTL is not reset on read-only requests. Understanding this distinction is essential for implementing the expiration behavior you actually want.
Diagnosing expired token errors step by step
Start by distinguishing between a JWT expiration and a server-side session expiration. Inspect the 401 response body. If it contains TokenExpiredError or a message mentioning exp, the JWT itself has expired. If the response indicates session not found or the session object is empty, the server-side session has been destroyed or was never persisted correctly.
For JWT expiration, decode the token without verification to inspect the exp and iat claims. You can do this in /tools/http-request-builder by pasting the token into the request body and sending it to a debug endpoint, or by using any JWT decoder tool. The iat claim is the issued-at timestamp in Unix seconds and exp is the expiration. The difference between them is the token lifetime. Compare exp to the current Unix timestamp — if exp is in the past, the token is expired. If exp is within 60 seconds in either direction, clock skew is likely the cause.
For server-side session issues, add temporary logging on the server that prints the session ID from the incoming cookie and the result of the store lookup. In Redis, you can check whether the session key exists with the TTL command: TTL session:ABC123 returns the remaining seconds, or -2 if the key does not exist. If the key does not exist but the browser is still sending the session cookie, the mismatch is in your TTL configuration.
Check that your session regeneration logic does not accidentally invalidate active sessions. After login, calling req.session.regenerate() creates a new session ID (which is correct for preventing session fixation attacks) but if the user's session data is not copied to the new session before the callback fires, the regenerated session is empty. This looks exactly like an expired session from the client's perspective.
Use /tools/http-request-builder to replay the exact request that triggers the 401. Set the Authorization header with the token your application is sending and observe whether the server returns the JWT error message or a session-related error. This confirms whether the problem is in the token itself or in the server-side state lookup. Then compare the same request with a freshly issued token to confirm the endpoint works correctly with a valid token.
Fixing expired sessions with renewal patterns
The most reliable fix depends on whether you are using JWTs, server-side sessions, or both. For JWT-based authentication, implement a refresh token pattern. Issue two tokens at login: a short-lived access token with a 15-minute expiration, and a long-lived refresh token stored in an httpOnly cookie with a 7-day or 30-day expiration. The access token is sent as a Bearer header on every API request. When it expires, the client sends its refresh token cookie to a dedicated /api/auth/refresh endpoint that validates the refresh token, checks it against a database record, issues a new access token, and optionally rotates the refresh token.
Refresh token rotation is an important security improvement. After a refresh token is used, mark it as consumed in the database and issue a replacement. If the same refresh token is presented again — which would happen if an attacker stole the token — refuse the request and revoke all refresh tokens for that user. This pattern, known as refresh token rotation with replay detection, provides a way to invalidate stolen tokens without requiring the user to log in again.
For server-side sessions with Express-session and Redis, set rolling: true on the session middleware to reset the cookie expiration and the Redis TTL on every request. Set the ttl option in RedisStore to match the cookie maxAge in seconds, not milliseconds. When the user is active, each request extends both the cookie and the Redis entry by the full TTL, implementing a sliding expiration window. When the user is inactive for longer than the TTL, the session genuinely expires and they must log in again.
For the silent refresh pattern on the client side, intercept 401 responses in a shared fetch wrapper. When a 401 occurs, send a single refresh request before retrying the original call. Use a promise lock to prevent multiple concurrent refresh requests when several API calls fail simultaneously. Store the access token in memory rather than localStorage where possible — memory storage is not accessible to injected scripts and survives page refreshes in single-page applications but is cleared on full page navigation.
After implementing the fix, always test session regeneration after login. Call req.session.regenerate() to get a new session ID after a successful login, copy the user data into req.session before the callback, and confirm that subsequent requests with the new session ID succeed. Use /tools/http-request-builder to make authenticated requests with both the old session ID and the new one to confirm the old session is invalid.
Edge cases that cause persistent expiration bugs
Sliding expiration interacts with long-running operations in unexpected ways. If your application performs a background job that holds a session open but does not make HTTP requests, the session TTL is not reset. When the job completes and the response is sent, the session may have already expired in the store. The solution is to explicitly call req.session.touch() or req.session.save() at key points in long-running operations to reset the TTL without waiting for the next HTTP request to do it automatically.
Multiple browser tabs create a race condition in refresh token flows. If the user has three tabs open and all three detect a 401 simultaneously, all three will attempt to consume the refresh token at the same time. Only the first request succeeds; the other two fail because the token has already been consumed and rotated. The client-side single-flight pattern using a shared refreshPromise resolves this within a single tab, but cross-tab coordination requires either a shared worker or localStorage event listeners to broadcast the new token to all tabs.
Absolute expiration versus sliding expiration is a security tradeoff, not just a usability question. Sliding expiration keeps active users logged in indefinitely, which is convenient but means a stolen session token stays valid as long as someone keeps using it. Absolute expiration — where the session expires at a fixed time regardless of activity — limits the damage from a stolen token to the original TTL window. For high-security applications, implement both: a sliding window for UX with an absolute maximum session duration (such as 8 hours) after which the user must re-authenticate regardless of activity.
Session fixation attacks are a separate concern from expiration but are often introduced during expiration fixes. When a user logs in successfully, always call req.session.regenerate() to assign a new session ID. This prevents an attacker from setting a known session ID before the user logs in and then using that same ID after authentication. Without session regeneration, the attacker's pre-set session becomes an authenticated session after the victim logs in. Copy req.session data to a temporary variable before calling regenerate, then restore it in the callback.
Cross-subdomain cookie sharing creates expiration inconsistencies when the session cookie domain is set too broadly. If the cookie is set with domain: .example.com, it is shared across all subdomains. A session created at app.example.com can be sent to api.example.com, but if both subdomains have different session configurations, the same session ID may be found or not found depending on which store is queried.
Mistakes that make token expiration worse
Storing JWT access tokens in localStorage is the most common security mistake associated with token management. JavaScript running on the page — including third-party scripts, browser extensions, and injected code — can read localStorage freely. A Cross-Site Scripting vulnerability anywhere on the page is enough to extract all stored tokens. Store access tokens in memory and refresh tokens in httpOnly cookies, which are inaccessible to JavaScript entirely.
Returning 403 instead of 401 for expired tokens confuses clients and monitoring systems. HTTP 401 means the request is not authenticated — the client must provide valid credentials. HTTP 403 means the request is authenticated but the user does not have permission. An expired session is an authentication failure, not a permission failure. If you return 403 for expired tokens, clients will not attempt a refresh and users will see a permission denied message when they should see a re-authentication prompt. Always return 401 for expired or missing tokens.
Setting token lifetimes too long defeats the security value of short-lived tokens. Some teams set JWT expiration to 24 hours or even 7 days because it is easier than implementing a refresh flow. A 7-day JWT that gets stolen provides the attacker with 7 days of access before it expires, with no server-side way to invalidate it since JWTs are stateless. Set access tokens to 15 minutes and implement a proper refresh flow. The implementation cost is a one-time effort; the security benefit is permanent.
Not handling token expiration in interceptors means the error handling code gets duplicated in every API call. If you use Axios, configure a response interceptor that catches 401 responses globally, attempts a refresh, and retries the original request. If you use fetch, create a wrapper function. Do not add try-catch blocks around every individual API call — that approach is hard to maintain and produces inconsistent behavior when different calls handle 401 differently.
Ignoring the difference between token expiration and token invalidity leads to confusing error messages. A token can be invalid without being expired: a bad signature, wrong algorithm, or tampered payload all produce errors that look like expiration but are not. Log the specific jwt error type on the server. TokenExpiredError means the exp claim passed. JsonWebTokenError means the token itself is malformed or the signature does not verify. NotBeforeError means the nbf claim is in the future. Each error type requires a different response.
Building expiration-resilient session management
Design your authentication architecture to treat token expiration as a normal, expected event rather than an error condition. Every authenticated client will eventually have an expired token. The system should handle this gracefully without user intervention. This means implementing the refresh flow before you need it, not as a patch after users start complaining about being logged out.
Always pair short-lived access tokens with long-lived refresh tokens stored in httpOnly cookies. The access token lifetime should match the risk tolerance of the application: 15 minutes for high-security contexts, up to 1 hour for typical internal tools. The refresh token lifetime should match the expected session duration: 7 days for consumer applications, 24 hours for enterprise tools that follow corporate access policies.
Implement token revocation for refresh tokens even if you cannot revoke JWTs. Maintain a table of valid refresh tokens in your database, identified by a random token ID stored in the refresh token payload. When the user logs out, delete the database record. When the user changes their password, delete all refresh token records for that user. This gives you server-side control over session termination even with a stateless JWT architecture.
Monitor 401 rates as a health metric in your application. A sudden spike in 401 responses may indicate a deployment that changed the JWT secret, a Redis failure that destroyed active sessions, or clock drift on a server. Set up an alert when the 401 rate exceeds a threshold relative to total API requests. This catches expiration bugs in production before users report them en masse.
Test expiration behavior explicitly in your test suite. Issue a token with a 1-second expiration, wait 2 seconds, and assert that the protected endpoint returns 401. Issue a refresh token, consume it once, attempt to consume it a second time, and assert the second attempt fails. These tests are fast to run and catch regressions in your token lifecycle logic before they reach production. Use /tools/http-request-builder during manual testing to send tokens with custom Authorization headers and inspect the exact 401 response body to confirm the error message is correct.
Quick fix checklist
- ✓Check whether the 401 message says TokenExpiredError (JWT) or session not found (server-side session) to target the right fix
- ✓Inspect the JWT exp and iat claims to calculate the actual lifetime and confirm it matches your intended configuration
- ✓Add clockTolerance: 60 to jwt.verify options to handle clock skew between issuer and verifier servers
- ✓Set RedisStore ttl in seconds to match the cookie maxAge in milliseconds divided by 1000
- ✓Enable rolling: true on express-session to reset the TTL on every request for sliding expiration
- ✓Implement a refresh token stored in an httpOnly cookie with a separate short-lived access token for API calls
- ✓Add a global 401 interceptor in your API client to silently refresh the token before redirecting to login
- ✓Call req.session.regenerate() after every successful login to prevent session fixation attacks
Related guides
Frequently asked questions
What is the difference between a session token and a JWT?
A session token is an opaque identifier that the server uses to look up session data in a store like Redis. A JWT is a self-contained token that encodes its own claims and expiration. Session tokens require a server-side store lookup on every request; JWTs are verified by checking the signature and exp claim without any database query. Both can expire, but only session tokens can be revoked by deleting the store entry.
Why does my token expire even when the user is actively using the app?
Either your session store TTL is not being reset on activity, or you have set an absolute expiration rather than a rolling one. For Express-session with Redis, set rolling: true and ensure the RedisStore ttl matches your cookie maxAge. For JWTs, implement a silent refresh that issues a new access token before the current one expires, typically when less than 20 percent of the lifetime remains.
Should I return 401 or 403 for an expired token?
Always return 401 for expired tokens. HTTP 401 Unauthorized means the client must re-authenticate. HTTP 403 Forbidden means the client is authenticated but lacks permission for the resource. An expired token is an authentication failure, not a permission failure. Returning 403 prevents client-side refresh logic from triggering and causes users to see a confusing permission denied message.
What is clock skew and how does it cause token expiration?
Clock skew is the time difference between two servers' clocks. If the server that issued a JWT has its clock set 90 seconds ahead of the server that verifies it, the verifying server will calculate the token as expired 90 seconds earlier than intended. Fix this by setting clockTolerance: 60 in jwt.verify options to allow a 60-second window of acceptable clock difference between issuer and verifier.
How do I implement a refresh token without exposing it to JavaScript?
Store the refresh token in an httpOnly cookie by setting the Set-Cookie header with HttpOnly and Secure flags. The cookie will be sent automatically by the browser on requests to your refresh endpoint but cannot be read by JavaScript. Your refresh endpoint reads the cookie from req.cookies, validates it against a database record, and responds with a new access token in the JSON body. The access token is stored in memory on the client, never in localStorage.
What is session fixation and how do I prevent it?
Session fixation is an attack where an attacker sets a known session ID before the victim logs in, then uses that ID after authentication to take over the session. Prevent it by calling req.session.regenerate() immediately after a successful login. This assigns a new random session ID and invalidates the old one. Copy any pre-login session data into the new session inside the regenerate callback before the callback fires.
Can I revoke a JWT before it expires?
Not without a server-side check. JWTs are stateless by design — the verifier only checks the signature and the exp claim. To revoke a JWT early, you must maintain a blocklist of invalidated token IDs (stored in the jti claim) in Redis or a database, and check this blocklist on every request. This adds latency and partially defeats the stateless benefit of JWTs. Refresh tokens stored in a database are much easier to revoke than access tokens.
What cookie attributes should a session cookie have?
A session cookie should always have HttpOnly set to prevent JavaScript access, Secure set to restrict transmission to HTTPS only, and SameSite set to Lax to prevent CSRF while allowing normal navigation. Set an explicit maxAge matching your desired session duration. Avoid setting the Domain attribute too broadly — scoping the cookie to the specific hostname prevents it from being sent to all subdomains where it is not needed.
How do I handle token expiration in a React or Vue application?
Create a central fetch wrapper or Axios interceptor that catches 401 responses globally. When a 401 is detected, call your refresh endpoint once using a promise lock to prevent duplicate refresh requests from concurrent API calls. After the refresh succeeds, retry the original request with the new token. If the refresh also returns 401, the refresh token is expired or revoked — redirect the user to the login page and clear any stored token state.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.