Dev.to · 3 min read

JWT auth without the confusion

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 try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { res.status(401).json({ error: 'Invalid token' }); } } 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' }); 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); } }); 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: Do you need to revoke tokens? If yes, plan for a blocklist or versioning. Are you building an API for multiple clients? JWT works well. 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News