I spent a good hour confused about JWT expiration times when I first started using them. The docs kept mentioning exp and expiresIn and Unix timestamps, and I had no idea what any of it meant in practice.
Turns out it's actually pretty simple once someone explains it like a normal person.
So that's what I'm going to do.
What is Token Expiration, Really?
A JWT token is basically a note your server writes and hands to the user. The note says "hey, this person is logged in, trust them."
But what if that note gets stolen? Without an expiry time, it works forever. The thief can use it for as long as they want.
Expiration is how you put a timestamp on that note. After a certain time, the note is invalid and nobody has to trust it anymore.
Simple as that.
The exp Claim — What's Actually in the Token
When you decode a JWT, you'll see something like this inside:
{
"userId": 42,
"role": "user",
"iat": 1741200000,
"exp": 1741203600
}
iat means "issued at" — when the token was created.
exp means "expires at" — when it stops working.
Both values are Unix timestamps. That's just a way of counting seconds since January 1st, 1970. Computers love it. Humans find it confusing.
In the example above, the token was created at 1741200000 and expires at 1741203600. The difference is 3600 seconds. That's exactly one hour.
When your server receives a token, it checks: is the current time past the exp value? If yes, the token is rejected. If no, it's accepted.
That's all expiration checking is.
How to Set Expiry When Creating a Token
The jsonwebtoken library makes this easy. You just pass expiresIn as an option:
const jwt = require('jsonwebtoken');
// Using a string (most readable)
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' } // expires in 1 hour
);
// Other valid formats:
// '15m' → 15 minutes
// '7d' → 7 days
// '30d' → 30 days
// 3600 → 3600 seconds (same as 1 hour)
The string format is easier to read. I almost always use strings over raw numbers just so future-me understands what I was thinking.
What Happens When a Token Expires?
Your server will throw a TokenExpiredError when it tries to verify an expired token. If you don't handle that error, your users just get a generic 500 error with no explanation.
Not great.
Here's how to handle it properly:
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
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 expired',
code: 'TOKEN_EXPIRED' // frontend can check this code
});
}
// Handle other invalid tokens
return res.status(403).json({ error: 'Invalid token' });
}
req.user = decoded;
next();
});
}
The code: 'TOKEN_EXPIRED' part is handy. Your frontend can check for that specific code and automatically try to refresh the token instead of just showing an error.
How Long Should Your Token Last?
This is the question everyone asks and there's no single right answer. It depends on what your app does.
Here's a rough guide based on common use cases:
| Use Case | Suggested Expiry | Why |
|---|---|---|
| Banking / Finance app | 5–15 minutes | High risk, short window if stolen |
| Regular web app | 15 minutes – 1 hour | Good balance of security and usability |
| Mobile app | 1–24 hours | Users don't want frequent logins |
| Refresh tokens | 7–30 days | Used to get new access tokens silently |
| API keys / server-to-server | 1 year or no expiry | Machine clients, no user involved |
When in doubt, go shorter. A 15-minute access token with a refresh token system is the sweet spot for most regular web apps.
Never Do This With Expiry Times
I've seen this in codebases and it makes me nervous every time:
// Please don't
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '999d' } // 999 days??
);
A token that lasts nearly 3 years is basically the same as no expiry at all. If it gets stolen, the attacker has years to use it.
Same goes for not setting expiry at all. Without expiresIn, the token never expires. Ever. That's a security hole waiting to cause problems.
Always set an expiry. Always.
What About "Remember Me" Features?
A lot of apps want to keep users logged in for a long time if they check "remember me." The right way to handle this isn't a super long access token.
It's a long-lived refresh token.
const rememberMe = req.body.rememberMe;
const refreshTokenExpiry = rememberMe ? '30d' : '1d';
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: refreshTokenExpiry }
);
Regular users get a 1-day refresh token. Users who ticked "remember me" get 30 days. The access token stays short either way.
Best of both worlds.
One More Thing Worth Knowing
JWT expiration is checked based on your server's clock. If your server's clock is wrong, expiry checks will be wrong too.
This rarely causes issues on modern hosting platforms, but if you're running your own server and tokens are expiring at weird times, check that your server clock is synced. It's usually an NTP issue.
Also worth knowing: the jsonwebtoken library has a built-in 60-second clock tolerance by default. So a token that expired 30 seconds ago will still pass verification. This handles minor clock drift between servers.
You can change this tolerance with the clockTolerance option if needed. But the default is usually fine.
And the Secret Key Part...
None of this expiry stuff matters much if your JWT secret key is weak or stored carelessly. Expiry limits the damage window. A strong key prevents the damage in the first place.
Before worrying too much about fine-tuning your expiry times, make sure your secret is generated properly. I use jwtsecretkeygenerator.com every time I start a new project — two keys for access and refresh, generated fresh, pasted straight into the .env file.
Get that part right first. Then set your expiry times. In that order.
💡 Quick Checklist: Strong secret key ✓ → Set expiresIn on every token ✓ → Handle TokenExpiredError specifically ✓ → Use refresh tokens for long sessions ✓. That's a solid JWT setup right there.