Back to blog
Mar 2026·4 min readAI-generated

A Practical Guide to JWT Authentication

#security#backend#authentication

JSON Web Tokens get reached for by default in a lot of new backends, often without anyone stopping to check whether a token is actually the right fit, or what happens the day you need to revoke one. Here's what a JWT actually is, how to use it correctly, and where it genuinely doesn't fit.

What's actually inside a JWT

A JWT is three base64url-encoded segments joined by dots: header.payload.signature. Only the signature is meant to be secret-dependent — the header and payload are just encoded, not encrypted, and anyone can decode and read them.

// header
{ "alg": "HS256", "typ": "JWT" }

// payload (the "claims")
{ "sub": "user_4821", "role": "admin", "exp": 1774300800 }

// signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

That last point is worth repeating because it trips people up constantly: never put anything secret in the payload. A JWT proves the payload wasn't tampered with since it was signed — it does not hide the payload from the holder of the token or anyone it's shown to.

Issuing a token

import jwt from 'jsonwebtoken';

function issueToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      role: user.role,
    },
    process.env.JWT_SECRET,
    { expiresIn: '15m' },
  );
}

expiresIn sets the exp claim — the token becomes structurally invalid to verify once that time passes, without the server needing to track anything. That statelessness is the entire appeal of JWTs: any server holding the shared secret (or public key, for asymmetric signing) can verify a token without a database lookup or a call to a session store.

Verifying a token

function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'missing_token' });
  }

  try {
    const payload = jwt.verify(authHeader.slice(7), process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'invalid_token' });
  }
}

jwt.verify checks the signature and the expiry in one call, throwing if either fails. Always let the library do this — hand-rolling signature comparison is an easy way to introduce a timing-attack or type-confusion bug (a known, real-world JWT vulnerability class involved servers that trusted the alg field in the token itself, letting an attacker switch to alg: none and skip verification entirely; modern libraries require you to specify accepted algorithms explicitly rather than trusting the token's claim about its own algorithm).

The revocation problem

Statelessness is JWT's selling point, and also its sharpest edge: if you never check anything against a database, you also can't invalidate a specific token before it expires. A user changes their password, logs out, or gets banned — and their existing token keeps working until exp arrives, because nothing about "verify the signature" involves asking "should this token still be honored?"

Three common ways to deal with this, in increasing order of how much statelessness you give up:

  1. Short expiry + refresh tokens. Issue a short-lived access token (minutes) and a longer-lived, database-tracked refresh token. Revoking access means deleting the refresh token record; the access token itself still works until it naturally expires, but that window is small.
// on login
const accessToken = jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' });
const refreshToken = crypto.randomUUID();
await db.refreshTokens.insert({ token: refreshToken, userId: user.id, expiresAt: /* 30 days */ });

// on refresh
async function refresh(refreshToken) {
  const record = await db.refreshTokens.findValid(refreshToken);
  if (!record) throw new Error('invalid_refresh_token');
  return jwt.sign({ sub: record.userId }, secret, { expiresIn: '15m' });
}
  1. A denylist for the rare, urgent case. Keep a small, fast-to-check store (Redis, with a TTL matching the token's remaining lifetime) of tokens that must be rejected immediately — a stolen token, an emergency ban — checked on every request in addition to signature verification. This reintroduces a lookup on the hot path, but only for the exceptional case, not every token ever issued.
  1. Just use sessions. If you find yourself building a denylist that covers most tokens most of the time, you've reinvented server-side sessions with extra steps. If instant revocation matters a lot and your traffic doesn't need JWT's cross-service statelessness, a traditional session ID backed by a database or Redis is simpler and does the one thing JWTs are structurally bad at.

Where storage bites you

If the access token lives in localStorage, it's readable by any JavaScript running on the page — including an injected script from an XSS vulnerability anywhere else in the app. An httpOnly cookie can't be read by JavaScript at all, which closes that specific hole, but opens up CSRF instead, since browsers attach cookies to requests automatically regardless of which site triggered them.

res.cookie('access_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
});

sameSite: 'strict' (or at minimum 'lax') is what actually mitigates CSRF here — it stops the browser from attaching the cookie to requests originating from a different site in the first place.

When JWT is the wrong tool

  • A single monolith talking to its own database on every request anyway. You're paying JWT's complexity cost (revocation strategy, signature key management) for a statelessness benefit you're not using, since a session lookup would've hit the database you're already hitting.
  • Anything that needs to log a user out everywhere, right now, as a hard requirement — password reset, account compromise. Plan for a denylist or refresh-token revocation from day one rather than discovering the gap during an incident.

Takeaway

A JWT is a signed, not encrypted, claim about identity that a server can verify without a database round trip — genuinely useful across service boundaries where a shared session store would be awkward. The tradeoff is that revocation isn't free: decide up front whether you need short-lived tokens with refresh, a denylist for emergencies, or whether you don't actually need JWT's statelessness at all.

NewerThe CAP Theorem, Explained with Real ExamplesOlderEvent Sourcing vs Traditional CRUD