Why Is a Refresh Token Needed?
JWT access tokens have a short lifespan—about 15 minutes or 1 hour. This is good for security. However, it also means that users have to log in again every 15 minutes—which makes for a very poor user experience.
This is where the refresh token comes into play. A refresh token is a long-term token used to generate a new access token once the original access token expires—without requiring the user to log in again.
The process is as follows:
- The user logs in → The server issues an access token (valid for 15 minutes) and a refresh token (valid for 7 days).
- The client includes the access token in every API request.
- When the access token expires, the client uses the refresh token to request a new access token.
- The server verifies the refresh token and issues a new access token.
Common Mistakes to Avoid
Storing the Refresh Token in localStorage
This is the biggest and most common mistake.
// Never do this localStorage.setItem(\'refreshToken\', token);
res.cookie(\'refreshToken\', refreshToken, {
httpOnly: true,
secure: true,
sameSite: \'Strict\',
maxAge: 7 * 24 * 60 * 60 * 1000
});
localStorage can be easily accessed using JavaScript. If your website has a cross-site scripting (XSS) vulnerability, an attacker can steal your refresh token with just a single line of code.
🍪 Correct way: Always store the refresh token in an HTTP-only cookie. HTTP-only cookies are not accessible by JavaScript.
Not Storing the Refresh Token in the Database
Many developers store the refresh token only on the client side and keep no record of it on the server. That is a big mistake.
Wrong approach: If you don\'t store the refresh token in the database, you won\'t be able to revoke a specific user\'s token. Suppose a user\'s token is stolen—how would you invalidate it?
Correct procedure: Store refresh tokens in the database and delete them after use (refresh token rotation).
// Database model example (MongoDB/Mongoose)
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true },
userId: { type: mongoose.Schema.Types.ObjectId, ref: \'User\' },
createdAt: { type: Date, default: Date.now, expires: \'7d\' } // auto-delete after 7 days
});
const RefreshToken = mongoose.model(\'RefreshToken\', refreshTokenSchema);
Failure to Rotate Refresh Tokens
Each refresh token should be deleted after use, and a new one should be issued. This is known as refresh token rotation.
This has two advantages:
- If someone tries to use an old refresh token, you can detect that a token reuse attack is underway.
- In this case, you can revoke all tokens and force the user to log in again.
Using the Same Secret for Access and Refresh Tokens
JWT_SECRET=the_same_secret_for_both
JWT_ACCESS_SECRET=a_strong_secret JWT_REFRESH_SECRET=a_different_strong_secret
Separate secrets are required for both so that if one is compromised, the other remains secure.
Keeping Expired Refresh Tokens in the Database
The accumulation of expired tokens in the database affects performance. There are two solutions:
- MongoDB TTL index — as shown in the Mongoose schema above (
expires: \'7d\') - Regular cleanup job — a cron job that periodically deletes expired tokens
const cron = require(\'node-cron\');
// Clean expired tokens every night at 12 o\'clock
cron.schedule(\'0 0 * * *\', async () => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await RefreshToken.deleteMany({ createdAt: { $lt: sevenDaysAgo } });
console.log(\'Expired refresh tokens cleaned up\');
});
Step-by-Step Secure Implementation
We are now introducing a comprehensive system for secure refresh tokens.
Install Dependencies
npm install express jsonwebtoken bcryptjs dotenv cookie-parser mongoose
npm install express-rate-limit
Set Environment Variables
.env file:
JWT_ACCESS_SECRET=your_strong_access_secret_here
JWT_REFRESH_SECRET=your_strong_refresh_secret_here
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
💡 Note: Both secrets must be unique and secure. With jwtsecretkeygenerator.com, you can generate two unique, secure keys with a single click.
Token Generation Functions
const jwt = require(\'jsonwebtoken\');
function generateAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
expiresIn: process.env.JWT_ACCESS_EXPIRES,
algorithm: \'HS256\'
});
}
function generateRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
expiresIn: process.env.JWT_REFRESH_EXPIRES,
algorithm: \'HS256\'
});
}
Login Route — Issuing Tokens
app.post(\'/login\', async (req, res) => {
try {
const { email, password } = req.body;
// Verify user from database
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: \'Invalid credentials\' });
}
const payload = { id: user._id, email: user.email };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// Save refresh token in database
await RefreshToken.create({ token: refreshToken, userId: user._id });
// Send refresh token in HTTP-only cookie
res.cookie(\'refreshToken\', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === \'production\',
sameSite: \'Strict\',
maxAge: 7 * 24 * 60 * 60 * 1000
});
// Send only the access token in the response body
res.json({ accessToken });
} catch (error) {
res.status(500).json({ message: \'Internal server error\' });
}
});
Refresh Token Route — with Rotation
This is the most important step. Whenever a refresh token is used, delete it and generate a new one—this is known as refresh token rotation.
app.post(\'/refresh\', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: \'Refresh token not found\' });
}
// Check in the database that the token is valid
const storedToken = await RefreshToken.findOne({ token: refreshToken });
if (!storedToken) {
// Token reuse attack may be occurring — clear cookie
res.clearCookie(\'refreshToken\');
return res.status(403).json({ message: \'Invalid refresh token. Please login again.\' });
}
// Verify token
let decoded;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: [\'HS256\']
});
} catch (err) {
await RefreshToken.deleteOne({ token: refreshToken });
res.clearCookie(\'refreshToken\');
return res.status(403).json({ message: \'Expired or invalid refresh token\' });
}
// Delete old token (Rotation)
await RefreshToken.deleteOne({ token: refreshToken });
// Generate new tokens
const payload = { id: decoded.id, email: decoded.email };
const newAccessToken = generateAccessToken(payload);
const newRefreshToken = generateRefreshToken(payload);
// Save the new refresh token in the database
await RefreshToken.create({ token: newRefreshToken, userId: decoded.id });
// Set new refresh token in cookie
res.cookie(\'refreshToken\', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === \'production\',
sameSite: \'Strict\',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(500).json({ message: \'Internal server error\' });
}
});
Logout Route — Revoking Token
app.post(\'/logout\', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Delete token from database
await RefreshToken.deleteOne({ token: refreshToken });
}
// Clear cookie
res.clearCookie(\'refreshToken\', {
httpOnly: true,
secure: process.env.NODE_ENV === \'production\',
sameSite: \'Strict\'
});
res.json({ message: \'Logged out successfully\' });
} catch (error) {
res.status(500).json({ message: \'Internal server error\' });
}
});
Quick Summary — Avoid These Mistakes
| ❌ Wrong Approach | ✅ Correct Approach |
|---|---|
| Store the refresh token in localStorage | Use an HTTP-only cookie |
| Do not store the refresh token in the database | Always store it in the database |
| Do not rotate the token regularly | Issue a new token after each use |
| Same secret for access and refresh tokens | Use separate secrets for each |
| Keep expired tokens in the database | Use a TTL index or cron job |
| Weak secret keys | Generate strong keys from jwtsecretkeygenerator.com |
Frequently Asked Questions
Why should I not store the refresh token in localStorage?
localStorage is accessible via JavaScript. If your website has an XSS vulnerability, an attacker can steal your refresh token with a single line of code. Always store the refresh token in an HTTP-only cookie, which is not accessible by JavaScript.
What is refresh token rotation?
Refresh token rotation means that every time a refresh token is used to get a new access token, the old refresh token is deleted and a brand new refresh token is issued. This helps detect token reuse attacks — if someone tries to use an old token, the server knows it has been compromised.
Why do I need separate secrets for access and refresh tokens?
Using separate secrets ensures that if one secret is ever compromised, the other remains secure. If both tokens share the same secret, compromising one effectively compromises the entire authentication system.
How do I clean up expired refresh tokens from the database?
There are two approaches: use a MongoDB TTL index (expires: \'7d\' in the Mongoose schema) which auto-deletes documents after 7 days, or set up a cron job that runs nightly and deletes tokens older than 7 days using deleteMany().
Conclusion
Even minor mistakes in implementing refresh tokens can compromise the security of your entire application. By following the steps and security measures described above, you can build a system that not only provides a seamless user experience but also protects you from real-world attacks such as token theft, token reuse, and XSS.
🔑 Always remember: Security isn\'t a one-time task, but an ongoing process. Change your secret keys regularly and use trusted tools like jwtsecretkeygenerator.com to ensure your applications remain secure at all times.