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:

// 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

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.