When I built the authentication system for PerSplit, I quickly discovered that most JWT tutorials stop at the wrong place — they show you how to sign and verify a token, but skip the hard parts: refresh token rotation, token blacklisting on logout, and secure storage in mobile clients. This article covers the full production-grade flow I implemented.
Why JWT Over Sessions?
PerSplit is a mobile-first app (Flutter) with a stateless Node.js backend. Session-based auth requires server-side storage and sticky sessions — both add infrastructure complexity. JWT is stateless: the server only needs a secret key to verify any token, making horizontal scaling trivial.
The trade-off: tokens cannot be invalidated server-side before they expire. This is where refresh token rotation and a token blacklist become essential.
Token Architecture: Short-Lived Access + Long-Lived Refresh
I use a two-token strategy:
- Access Token — valid for 15 minutes. Sent in every API request via
Authorization: Bearerheader. - Refresh Token — valid for 7 days. Stored in an
HttpOnlycookie. Used only to obtain new access tokens.
// utils/tokenService.js
const jwt = require('jsonwebtoken');
exports.signAccessToken = (userId) =>
jwt.sign({ id: userId }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' });
exports.signRefreshToken = (userId) =>
jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
exports.verifyAccessToken = (token) =>
jwt.verify(token, process.env.JWT_ACCESS_SECRET);
exports.verifyRefreshToken = (token) =>
jwt.verify(token, process.env.JWT_REFRESH_SECRET);
Secure Refresh Token Storage
The most common JWT mistake is storing the refresh token in localStorage — this is vulnerable to XSS. I store it in an HttpOnly, Secure, SameSite=Strict cookie:
// On login success
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
});
res.json({ accessToken });
JavaScript running on the page cannot read an HttpOnly cookie, so XSS attacks cannot steal the refresh token.
Refresh Token Rotation
Each time a refresh token is used to get a new access token, I issue a new refresh token and invalidate the old one. This limits the window of exploit if a refresh token is stolen:
// POST /api/v1/auth/refresh
exports.refreshTokens = async (req, res, next) => {
const { refreshToken } = req.cookies;
if (!refreshToken) return next(new AppError('No refresh token', 401, 'UNAUTHENTICATED'));
// Verify the token is valid
let decoded;
try {
decoded = tokenService.verifyRefreshToken(refreshToken);
} catch {
return next(new AppError('Invalid refresh token', 401, 'INVALID_TOKEN'));
}
// Check it hasn't been blacklisted (used before)
const isBlacklisted = await redis.get(`blacklist:${refreshToken}`);
if (isBlacklisted) return next(new AppError('Token reuse detected', 401, 'TOKEN_REUSE'));
// Blacklist the used token
await redis.setEx(`blacklist:${refreshToken}`, 7 * 24 * 3600, '1');
// Issue new pair
const newAccessToken = tokenService.signAccessToken(decoded.id);
const newRefreshToken = tokenService.signRefreshToken(decoded.id);
res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 7*24*60*60*1000 });
res.json({ accessToken: newAccessToken });
};
Token Blacklisting on Logout
On logout, I blacklist the current refresh token in Redis with a TTL matching the token's remaining lifetime:
exports.logout = async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Blacklist until it would have expired anyway
await redis.setEx(`blacklist:${refreshToken}`, 7 * 24 * 3600, '1');
}
res.clearCookie('refreshToken');
res.json({ status: 'success', message: 'Logged out' });
};
Auth Middleware
Every protected route uses this middleware, which extracts and verifies the access token:
exports.protect = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new AppError('Authentication required', 401, 'UNAUTHENTICATED'));
}
const token = authHeader.split(' ')[1];
try {
const decoded = tokenService.verifyAccessToken(token);
req.user = await User.findById(decoded.id).select('-password');
next();
} catch (err) {
next(new AppError('Invalid or expired token', 401, 'INVALID_TOKEN'));
}
};
JWT Best Practices Checklist
- Use separate secrets for access and refresh tokens
- Keep access tokens short-lived (15 minutes or less)
- Store refresh tokens in
HttpOnlycookies only - Implement refresh token rotation — never reuse a refresh token
- Blacklist used/logged-out refresh tokens in Redis with TTL
- Never store sensitive data in the JWT payload (it is base64-encoded, not encrypted)
- Use
RS256(asymmetric) instead ofHS256if your resource servers are separate services
Related: Designing Scalable REST APIs — rate limiting auth routes and consistent error formats for auth failures.
Conclusion
Secure JWT authentication is more than jwt.sign() and jwt.verify(). The complete picture involves short-lived access tokens, refresh token rotation, Redis-backed blacklisting, and secure cookie storage. Implementing this properly in PerSplit taught me that security is not a feature you bolt on — it is a constraint you design around from the start.
If you are looking for a backend developer who understands authentication security deeply, let's connect.