So you did the right thing. You set your JWT tokens to expire after 15 minutes. Good security practice. Proper short-lived tokens.
Then your users started complaining that your app keeps logging them out mid-session.
Yep. That's the problem refresh tokens solve.
Why Short-Lived Tokens Create a Problem
Short expiry times are good for security. If someone steals a token, it only works for a little while. Damage is limited.
But asking users to log in every 15 minutes is terrible. Nobody wants that. It feels broken even if it's technically correct.
The solution is two tokens instead of one. An access token that expires quickly, and a refresh token that lasts much longer and can get new access tokens when needed.
Makes sense, right?
How the Two-Token System Works
Think of it like a hotel key card situation.
Your access token is like a key card that opens your room. It works great but only for a few hours. When it stops working, you don't go all the way home and check in again — you go to the front desk and get a new card.
Your refresh token is your "proof of booking." You show it at the front desk and they give you a fresh key card without making you go through the whole check-in process again.
That's exactly how access and refresh tokens work together.
What You'll Need Before Starting
Make sure you have these packages installed:
npm install express jsonwebtoken dotenv
And your .env file needs two separate secrets. One for access tokens, one for refresh tokens. Never use the same secret for both.
I generate both using jwtsecretkeygenerator.com — two separate clicks, two separate keys. Takes about 20 seconds.
JWT_ACCESS_SECRET=your_access_token_secret_here
JWT_REFRESH_SECRET=your_refresh_token_secret_here
Using different secrets means that even if one gets compromised, the other stays safe. Small habit, big payoff.
Step 1: Issue Both Tokens on Login
When a user logs in successfully, give them both tokens at once:
const jwt = require('jsonwebtoken');
require('dotenv').config();
// Store refresh tokens (use a database in production)
const activeRefreshTokens = [];
function generateTokens(userId) {
// Access token - short lived
const accessToken = jwt.sign(
{ userId },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Refresh token - long lived
const refreshToken = jwt.sign(
{ userId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
app.post('/api/login', async (req, res) => {
// ... check username and password ...
const { accessToken, refreshToken } = generateTokens(user.id);
// Save refresh token
activeRefreshTokens.push(refreshToken);
res.json({
accessToken,
refreshToken,
message: 'Login successful'
});
});
The access token expires in 15 minutes. The refresh token lasts 7 days. Your client app stores both and uses them differently.
Step 2: Build the Refresh Endpoint
This is the endpoint your frontend calls when an access token expires. The user sends their refresh token and gets a brand new access token back:
app.post('/api/refresh', (req, res) => {
const { refreshToken } = req.body;
// No token sent
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
// Token not in our active list
if (!activeRefreshTokens.includes(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Verify the token is legit and not expired
jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Refresh token expired or invalid' });
}
// Issue a fresh access token
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
Three checks happen here. Does the token exist? Is it in our active list? Is it still valid? Only if all three pass does the user get a new access token.
Step 3: Handle Expired Tokens on the Frontend
This is where most tutorials stop. But handling token expiry on the frontend is actually the part that matters most to the user experience.
Here's a basic pattern in JavaScript that catches expired access tokens and tries to refresh automatically:
async function apiRequest(url, options = {}) {
// Add access token to request
const accessToken = localStorage.getItem('accessToken');
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`
}
});
// If token expired, try to refresh
if (response.status === 401) {
const refreshed = await tryRefreshToken();
if (refreshed) {
// Retry the original request with new token
const newAccessToken = localStorage.getItem('accessToken');
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`
}
});
} else {
// Refresh failed - send user to login
window.location.href = '/login';
}
}
return response;
}
async function tryRefreshToken() {
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 (err) {
return false;
}
return false;
}
The user never sees any of this happening. Their request fails silently, the token gets refreshed in the background, and the request goes through. Seamless.
Step 4: Logout Needs to Kill the Refresh Token
This is the step people miss. A lot.
When a user logs out, you need to remove their refresh token from the active list. If you don't, they can still get new access tokens even after they logged out.
app.post('/api/logout', (req, res) => {
const { refreshToken } = req.body;
// Remove from active tokens list
const index = activeRefreshTokens.indexOf(refreshToken);
if (index > -1) {
activeRefreshTokens.splice(index, 1);
}
res.json({ message: 'Logged out successfully' });
});
Now when they log out, their refresh token is gone. Even if someone steals it, it won't work. That's proper logout.
What to Use Instead of an Array in Production
The array approach above is fine for learning and testing. In a real app, you need something better.
When your server restarts, the array is gone. All logged-in users get kicked out. Not ideal.
Use one of these instead:
- Redis: Fast, in-memory storage. Perfect for tokens. Most common choice.
- Database table: A simple
refresh_tokenstable works fine if you don't have Redis. - HttpOnly cookies: Store refresh tokens in secure cookies instead of sending them in request bodies.
For most small to medium apps, a database table is the simplest option that actually works reliably.
How Long Should Each Token Last?
There's no single right answer, but here's what most apps do:
| Token Type | Typical Expiry | Why |
|---|---|---|
| Access Token | 15 minutes | Short window limits damage if stolen |
| Access Token | 1 hour | Balance between security and convenience |
| Refresh Token | 7 days | Keeps users logged in for a week |
| Refresh Token | 30 days | Common for "remember me" features |
For a regular web app, 15-minute access tokens and 7-day refresh tokens is a solid starting point. Adjust based on how sensitive your app is.
A Few Things That Can Go Wrong
Since I learned these the hard way, let me save you the trouble.
Don't reuse the same secret for both tokens. I mentioned this earlier but it's worth repeating. Two different secrets. Always.
Don't forget to store the refresh token server-side. If you just issue refresh tokens without saving them anywhere, you can't revoke them. Logout becomes useless.
Don't ignore the expiry on refresh tokens. They expire too. When they do, the user needs to log in again. That's expected behaviour, not a bug.
Don't store refresh tokens in localStorage if you can avoid it. HttpOnly cookies are safer for refresh tokens specifically, since they can't be read by JavaScript at all.
💡 Good Rule of Thumb: Access token in memory or localStorage. Refresh token in an HttpOnly cookie. This way XSS attacks can steal the access token but it expires in 15 minutes anyway. They can't touch the refresh token.
The Full Picture
Once you have this set up, here's what the full flow looks like from a user's perspective:
- User logs in → gets access token (15 min) + refresh token (7 days)
- User makes requests → access token gets sent with each one
- Access token expires → frontend silently calls /api/refresh
- Refresh endpoint checks the refresh token → issues new access token
- User's request goes through → they never notice anything happened
- After 7 days with no activity → refresh token expires → user logs in again
- User clicks logout → refresh token deleted → can't get new access tokens
From the user's side, they just stay logged in as long as they're active. Clean experience. No random logouts. No confusion.
Worth the Extra Setup?
Honestly, yes.
It's maybe an extra hour of work compared to single-token auth. But the user experience difference is massive. And the security improvement over long-lived access tokens is real.
Short access tokens + refresh tokens is just how production auth should work. Once you've built it once, it becomes pretty routine for every new project.
Set it up right from the start and you won't have to go back and fix annoyed users wondering why your app keeps logging them out.