Back to Portfolio
Node.jsJWTSecurityAPI

JWT + RBAC Authentication: The Right Way in Node.js

May 2026 9 min readBy Shivaji Tiwari

The Most Common JWT Mistakes

I've reviewed a lot of codebases where JWT was implemented in a way that looked correct but had serious security holes. The three most common mistakes: storing tokens in localStorage (XSS-vulnerable), using long-lived access tokens without refresh rotation, and not validating the alg header (allows algorithm confusion attacks). Let's fix all of these.

Token Storage — HttpOnly Cookies, Not localStorage

Store your access token in an HttpOnly cookie. It's inaccessible to JavaScript, which means XSS attacks can't steal it. Pair with SameSite=Strict and Secure flags:

res.cookie('access_token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000, // 15 minutes
});

Keep access tokens short-lived (15 minutes). Use a longer-lived refresh token (7 days) to issue new access tokens silently.

Refresh Token Rotation

Each time a refresh token is used, issue a new one and invalidate the old one. Store a hash of each refresh token in your database. If an attacker steals a refresh token and uses it after you've already rotated it, the mismatch is detected and all sessions for that user are revoked:

async function rotateRefreshToken(oldToken, userId) {
  const stored = await db.refreshTokens.findOne({ userId });
  if (!stored || !bcrypt.compare(oldToken, stored.hash)) {
    // Token reuse detected — revoke all sessions
    await db.refreshTokens.deleteAll({ userId });
    throw new Error('Token reuse detected');
  }
  const newToken = crypto.randomBytes(64).toString('hex');
  await db.refreshTokens.update({ userId }, { hash: bcrypt.hash(newToken) });
  return newToken;
}

RBAC Middleware — Clean and Composable

Define permissions as data, not logic. A clean RBAC middleware checks the user's role against a permission map:

const permissions = {
  admin:   ['read', 'create', 'update', 'delete'],
  manager: ['read', 'create', 'update'],
  staff:   ['read', 'create'],
  viewer:  ['read'],
};

export const authorize = (...requiredPerms) => (req, res, next) => {
  const userPerms = permissions[req.user.role] ?? [];
  const hasAll = requiredPerms.every(p => userPerms.includes(p));
  if (!hasAll) return res.status(403).json({ error: 'Forbidden' });
  next();
};

// Usage
router.delete('/users/:id', authenticate, authorize('delete'), deleteUser);

Compose with the authenticate middleware that verifies the JWT signature and attaches req.user.

Embed Permissions in the JWT Payload

For APIs that don't need database lookups on every request, embed the user's role (and optionally their permissions) directly in the JWT payload. The token is signed, so it can't be tampered with. The tradeoff: permission changes don't take effect until the token expires. For most systems, 15-minute access tokens make this acceptable.

const token = jwt.sign(
  { sub: user.id, role: user.role, tenantId: user.tenantId },
  process.env.JWT_SECRET,
  { expiresIn: '15m', algorithm: 'HS256' } // Always specify algorithm
);

Always specify the algorithm option. Libraries that accept alg: "none" can be tricked into skipping signature verification.

Key Takeaways

  • Use HttpOnly cookies, not localStorage
  • Keep access tokens short-lived (15 min), rotate refresh tokens on use
  • Always specify the JWT algorithm — never accept alg: none
  • Store RBAC permissions as data, compose middleware cleanly
  • Embed role in JWT payload to avoid a DB hit on every request
Need a Secure API Built? →