Signed Tokens — Stateless Auth at Scale
JSON Web Tokens
JWTs are signed tokens carrying the user identity. Stateless, scalable, easy to get wrong.
What you'll learn
- Issue a JWT on login
- Verify it on every request
- Choose the right algorithm
A JWT (JSON Web Token) is a signed string that carries a JSON payload. The signature proves we issued it; the payload tells us who the user is.
Install
npm install jsonwebtoken Issue on Login
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET; // a 32+ byte random string
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
SECRET,
{ expiresIn: "15m" }
);
}
app.post("/auth/login", validate({ body: LoginSchema }), async (req, res) => {
const { email, password } = req.validBody;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: { code: "invalid_credentials" } });
}
const token = issueToken(user);
res.json({ data: { token, user: { id: user.id, email: user.email } } });
}); Verify on Every Request
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: { code: "missing_token" } });
}
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ error: { code: "invalid_token" } });
}
}
app.get("/me", requireAuth, (req, res) => {
res.json({ data: { id: req.user.sub, email: req.user.email } });
}); Algorithms — Pick Wisely
jwt.sign defaults to HS256 (HMAC + shared secret). For most
apps that’s fine. For microservices where one service issues and
others verify:
- HS256 — symmetric (shared secret). Simple, fast.
- RS256 / ES256 — asymmetric (private key signs, public key verifies). Better when verifiers don’t need the issuing power.
Never accept alg: none — that’s a known JWT exploit. Use jose
(more modern lib) and pin the algorithm explicitly.
Token Lifetime
Short-lived tokens (15 min) plus a refresh token (longer-lived, stored as a session in your DB) is the common pattern. Refresh tokens enable revocation; access tokens stay stateless.
Storing Tokens on the Client
Two options:
| Where | Pros | Cons |
|---|---|---|
localStorage | Simple | Vulnerable to XSS — JS can read it |
| HttpOnly cookie | Safe from XSS | Needs CSRF protection |
HttpOnly cookie is the safer default, even though most JWT
tutorials show localStorage. The latter is fine for very low-risk
apps; not for anything user-data-sensitive.
Where JWT Shines
- Microservices — one issuer, many verifiers
- Mobile + web sharing an API — token works the same everywhere
- Anywhere you can tolerate “logout doesn’t immediately invalidate”
Where Sessions Win
- You need instant revocation
- You’re already running Redis / DB
- Cookies + same-domain is enough
What NOT To Put In A JWT
A JWT is not encrypted by default — it’s signed. Anyone can decode it (the payload is base64). Don’t put secrets, passwords, or sensitive PII in the payload.
Passport.js →