DEV Community

Stack Horizon
Stack Horizon

Posted on

JWT auth without the confusion

The mental model that fixes everything

JWT is just a token format. It is not authentication, not a session, and not a database. Once you separate those ideas, most of the pain disappears.

A JWT is a JSON object that is signed. That's it. The payload holds claims like sub (subject) and exp (expiration). The signature proves the token wasn't tampered with.

What JWT is not

  • Not a session store: You can't revoke a JWT before it expires. If you need revocation, you need a blocklist or short expiry.
  • Not a database: Don't stuff heavy data in the payload. It gets sent on every request.
  • Not a magic bullet: It's a way to pass claims between parties without a shared server-side state.

The three flows that matter

1. Access token only

Simplest flow: login returns a JWT, client sends it in the Authorization header, server verifies it on every request.

// server middleware (Express example)
const jwt = require('jsonwebtoken');

function auth(req, res, next) {
  const header = req.headers.authorization;
  if (!header) return res.status(401).json({ error: 'No token' });

  const token = header.split(' ')[1]; // Bearer <token>
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Works fine for small apps, but every request hits your auth logic and the token can't be invalidated early.

2. Access + refresh token

Common pattern for SPAs. Access token lives 15 minutes, refresh token lives 7 days. The refresh token is stored securely (httpOnly cookie) and used only to get a new access token.

// issue tokens on login
const accessToken = jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, process.env.REFRESH_SECRET, { expiresIn: '7d' });

res.json({ accessToken });
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
Enter fullscreen mode Exit fullscreen mode

Refresh endpoint:

app.post('/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.sendStatus(401);

  try {
    const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
    const newAccessToken = jwt.sign({ userId: payload.userId }, process.env.JWT_SECRET, { expiresIn: '15m' });
    res.json({ accessToken: newAccessToken });
  } catch {
    res.clearCookie('refreshToken');
    res.sendStatus(403);
  }
});
Enter fullscreen mode Exit fullscreen mode

This gives you short-lived access tokens (less risk if leaked) and long-lived sessions without storing server-side state.

3. Stateless vs stateful

If you need to revoke tokens immediately (like on password change), you have two options:

  • Keep a token version in your user table. Include version in the JWT payload. Bump the version to invalidate all old tokens.
  • Use a blocklist (Redis or DB) for revoked tokens. Check the blocklist before verifying.

Both add state. If you don't need revocation, stay stateless.

Common mistakes I see

  • Storing JWT in localStorage: XSS can steal it. Use httpOnly cookies for refresh tokens, and keep access tokens in memory if possible.
  • Putting sensitive data in payload: It's base64 encoded, not encrypted. Anyone can read it.
  • Using the same secret for access and refresh: Use separate secrets. If one leaks, the other is still safe.
  • Not checking exp: Most libraries do it automatically, but if you hand-roll, don't forget.

The decision checklist

Ask these before adding JWT:

  1. Do you need to revoke tokens? If yes, plan for a blocklist or versioning.
  2. Are you building an API for multiple clients? JWT works well.
  3. Is your server the only consumer? A simple session cookie might be easier.

Final thought

JWT is a tool, not a religion. Use it when it fits: stateless APIs, microservices, or cross-domain auth. For classic server-rendered apps, traditional sessions are often simpler. The confusion comes from mixing the token format with the auth strategy. Keep them separate and you'll be fine.

Top comments (0)