JWT Authentication in Full-Stack Apps

September 17, 2026 · 2 views
JWT Authentication in Full-Stack Apps

Most full-stack developers implement JWT authentication once, copy it into every new project, and never revisit it until a security audit or a production incident forces the question: what happens when a token leaks, or when a user's session needs to be revoked right now? JWT authentication is deceptively easy to get running and surprisingly easy to get wrong, and the gap between "it works in my demo" and "it's safe to ship" is where most of the real engineering lives.

This guide walks through a JWT authentication setup that actually holds up in production: short-lived access tokens, rotating refresh tokens, secure storage on the client, and the revocation strategy that stateless JWTs famously lack.

Why JWT authentication is popular — and where it breaks down

JSON Web Tokens are attractive for full-stack apps because they're stateless: the server doesn't need to look up a session on every request, which makes horizontal scaling trivial. A signed JWT carries the user's identity and claims directly in the token, and any service holding the shared secret (or public key, for asymmetric signing) can verify it without touching a database.

The tradeoff is the same statelessness that makes JWTs fast also makes them hard to revoke. Once a JWT is issued, it's valid until it expires — there's no built-in way to invalidate it early. If you set expiration too long for convenience, a stolen token stays useful to an attacker for that entire window. If you set it too short, users get logged out mid-session, which is a bad experience.

The standard fix is a two-token system: a short-lived access token (5–15 minutes) for actual API calls, and a longer-lived refresh token (days to weeks) used only to get a new access token. This limits the blast radius of a leaked access token while keeping the login experience smooth.

Implementing the access/refresh token flow

Here's a minimal but realistic Node.js/Express implementation of JWT authentication with refresh token rotation:

const jwt = require('jsonwebtoken');

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, tokenVersion: user.tokenVersion },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

app.post('/api/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const user = await User.findById(payload.sub);

    // Reject if the token version doesn't match — this is what
    // makes server-side revocation possible for a "stateless" token.
    if (!user || user.tokenVersion !== payload.tokenVersion) {
      return res.status(401).json({ error: 'Token revoked' });
    }

    const tokens = generateTokens(user);
    res.cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true, secure: true, sameSite: 'strict',
    });
    res.json({ accessToken: tokens.accessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

The tokenVersion field is the trick that solves JWT authentication's revocation problem: store an integer on the user record, embed it in the refresh token, and increment it whenever the user logs out everywhere, changes their password, or you need to force a re-login. Every refresh request checks that the version still matches, so revocation becomes a single database write instead of a token blacklist you have to maintain.

Where to store tokens on the client

This is the part most tutorials get wrong, and it directly affects your app's security posture:

  • Access token — keep it in memory (a JavaScript variable or React state/context), not localStorage. Anything in localStorage is readable by any script running on the page, so a single XSS vulnerability hands an attacker every active token.
  • Refresh token — store it in an httpOnly, secure, sameSite=strict cookie. JavaScript can't read it, which removes it from XSS's reach entirely, and sameSite=strict blocks it from being sent on cross-site requests, which mitigates CSRF.
  • Never put a refresh token in localStorage or sessionStorage — it defeats the entire point of separating the two tokens.

Because the access token lives only in memory, it disappears on a page refresh. Handle that by calling the refresh endpoint once on app load to silently re-establish a session from the httpOnly cookie, before rendering any authenticated UI.

Common mistakes in JWT authentication

  1. No expiration, or an expiration measured in weeks on the access token — this turns a leaked token into a long-term credential.
  2. Storing tokens in localStorage because it's simpler than cookies — convenient until the first XSS bug.
  3. No revocation mechanism — treating JWTs as truly stateless and having no way to force a logout when a device is lost or a password is changed.
  4. Signing with a weak or reused secret, or reusing the same secret for access and refresh tokens, which means compromising one compromises both.
  5. Not validating the alg field, which opens the door to algorithm-confusion attacks where an attacker submits a token signed with none or swaps RS256 for HS256 using the public key as an HMAC secret. Always pass an explicit algorithms array to jwt.verify.

Best practices checklist

  • Use asymmetric signing (RS256) if multiple services need to verify tokens but only one should issue them
  • Rotate the refresh token itself on every use (as shown above), not just the access token
  • Set a hard maximum session lifetime, independent of refresh token renewal, so a session can't extend forever
  • Log and rate-limit the refresh endpoint — it's a high-value target for credential stuffing
  • Include only the claims you actually need in the payload; JWTs are base64-encoded, not encrypted, and are fully readable by anyone who intercepts them

Frequently Asked Questions

Is JWT authentication more secure than session-based authentication? Neither is inherently more secure — they have different tradeoffs. Sessions are easier to revoke instantly because the server holds the state, while JWTs scale better across distributed services but need the token-versioning pattern above to support revocation.

Should I store JWTs in localStorage for a single-page app? No. Even for a SPA, keep the access token in memory and the refresh token in an httpOnly cookie. The convenience of localStorage isn't worth the XSS exposure.

How long should a JWT access token last? 5 to 15 minutes is a reasonable default for most applications. Shorter windows reduce the value of a leaked token; the refresh flow handles renewing it transparently.

Can I use JWT authentication with a mobile app instead of cookies? Yes — mobile apps typically store the refresh token in the platform's secure storage (Keychain on iOS, Keystore on Android) instead of a cookie, since there's no browser cookie jar, but the same short-access/long-refresh pattern and token-versioning revocation still apply.

Key Takeaways

JWT authentication done well is a short-lived access token kept in memory, a rotating refresh token kept in an httpOnly cookie, and a tokenVersion field that turns "stateless" tokens into something you can actually revoke. If your current implementation is missing any of those three pieces, treat it as a production risk rather than a future improvement — add the token-versioning check to your refresh endpoint first, since it's the single change that closes the biggest gap between a demo auth system and one you can trust with real user accounts.

#nodejs #jwt #authentication #refresh-tokens #api-security #full-stack
Share this article: