I had a user message me once saying the app "just stopped working" mid-session. No warning. No error message they could understand. Just suddenly — nothing worked.
Took me an hour to figure out the access token had expired and my code wasn't handling it properly. The server was throwing an error, the frontend was swallowing it, and the user had no idea what was happening.
Not a great experience for anyone.
What Actually Happens the Moment a Token Expires
When your server receives a JWT, it calls jwt.verify() to check it. Part of that check is comparing the token's exp value against the current time.
If the current time is past the exp value — even by one second — the token is dead. jwt.verify() throws an error and stops right there.
The request goes nowhere. The user gets nothing back. Or worse, they get a generic 500 error that tells them absolutely nothing useful.
That's the default behaviour if you don't handle it yourself.
The Exact Error You'll See
The jsonwebtoken library throws a specific error for this. It's called TokenExpiredError. Here's what it looks like:
TokenExpiredError: jwt expired
at verify (/node_modules/jsonwebtoken/verify.js:89)
expiredAt: 2026-03-05T20:00:00.000Z
It's different from a completely invalid token, which throws JsonWebTokenError. The library gives you separate error types specifically so you can handle them differently.
Expired token? Maybe try to refresh it silently.
Completely invalid token? That's suspicious — log the user out.
Two different situations. Two different responses. Don't treat them the same.
How to Handle It Properly on the Server
Here's what good expiry handling looks like in your auth middleware:
const jwt = require('jsonwebtoken');
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
if (!token) {
return res.status(401).json({
error: 'No token provided',
code: 'NO_TOKEN'
});
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
// Handle expired tokens specifically
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token has expired',
code: 'TOKEN_EXPIRED', // Frontend checks this code
expiredAt: err.expiredAt // Optional: when it expired
});
}
// Handle tampered or invalid tokens
if (err.name === 'JsonWebTokenError') {
return res.status(403).json({
error: 'Invalid token',
code: 'INVALID_TOKEN'
});
}
// Anything else unexpected
return res.status(403).json({
error: 'Token verification failed',
code: 'TOKEN_ERROR'
});
}
// Token is valid - attach user to request
req.user = decoded;
next();
});
}
The code field is the important part. Your frontend reads that code and knows exactly what happened — and exactly what to do next.
How to Handle It on the Frontend
This is where most of the user experience lives. When your API returns TOKEN_EXPIRED, you have two options.
Option one: send them to the login page. Simple, works fine for basic apps.
Option two: silently refresh the token and retry the request. Much better experience, slightly more work.
async function apiRequest(url, options = {}) {
const accessToken = localStorage.getItem('accessToken');
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
// Check if the token specifically expired
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED') {
// Try to get a fresh access token
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry the original request with the new token
const newToken = localStorage.getItem('accessToken');
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`,
'Content-Type': 'application/json'
}
});
}
}
// Either invalid token or refresh failed - log out
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
return response;
}
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return false;
try {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
return true;
}
} catch {
return false;
}
return false;
}
When this works well, the user never sees anything. Their request fails, the token refreshes in the background, the request succeeds. Seamless.
What If You Don't Have Refresh Tokens?
That's fine. Not every app needs them.
For simpler setups, just catch the expired token and redirect to login with a helpful message:
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED') {
// Show a friendly message before redirecting
alert('Your session has expired. Please log in again.');
window.location.href = '/login';
return;
}
}
"Your session has expired" is a message users actually understand. A blank screen or a generic error is not.
Small thing. Huge difference in user experience.
Can You Check if a Token is About to Expire?
Yes. And it's surprisingly easy since the exp value is right there in the payload.
You can check before making a request whether the token will expire within the next, say, 60 seconds. If it will, refresh it proactively instead of waiting for the request to fail:
function isTokenExpiringSoon(token, bufferSeconds = 60) {
try {
// Decode without verifying - we just want the exp value
const payload = JSON.parse(
atob(token.split('.')[1])
);
const currentTime = Math.floor(Date.now() / 1000);
const timeUntilExpiry = payload.exp - currentTime;
return timeUntilExpiry < bufferSeconds;
} catch {
return true; // Treat decode errors as expired
}
}
// Before any important request:
if (isTokenExpiringSoon(accessToken)) {
await refreshAccessToken();
}
This way you're not waiting for a request to fail. You're getting ahead of it. Cleaner, faster, better for the user.
A Few Things Worth Knowing
The jsonwebtoken library has a 60-second built-in clock tolerance. So a token that expired 45 seconds ago might still pass. This handles minor clock differences between servers.
You can adjust this with the clockTolerance option if you need to, but the default is usually fine.
Also worth knowing: once a token expires, there's no way to extend it. You can't "renew" an expired token. You issue a completely new one. That's by design — the old token is gone, the new one starts fresh.
The Root of Most Expiry Problems
Honestly, most expiry headaches come from two things. Either the expiry time is set wrong, or the error handling is missing entirely.
Get those two right and token expiry becomes a non-issue. It just works quietly in the background, doing its job, keeping your app secure without anyone noticing.
And for that to all work properly — the signing, the verification, the refresh flow — you need a solid secret key holding it together. That's the one thing worth spending real attention on at the start of every project.
I generate mine fresh using jwtsecretkeygenerator.com before anything else. Solid key first, then build the rest of the auth system on top of it. That order matters.
💡 Quick Checklist: Always handle TokenExpiredError separately from other JWT errors → return a specific error code the frontend can read → either silently refresh or show a friendly message → never show a blank screen or raw error to the user.