A dev submitted a PR with CPF and password hash inside the JWT payload. He thought Base64 was encryption. The reviewer rejected it, opened an urgent card, and spent the afternoon explaining the problem to the team.
That specific mistake isn't rare — and it's not even the most dangerous one you'll find in JWT implementations out there.
If you use JWT in production or are about to, this post covers what really matters: the structure, the correct flow, the errors that compromise everything, and how to implement refresh token rotation for real.
JWT vs Session: the choice that defines your architecture
Before any code, the right question is: do you actually need JWT or server-side sessions?
| Criteria | JWT (stateless) | Session + Redis (stateful) |
|---|---|---|
| Immediate revocation | ❌ Not native | ✓ Simple |
| Horizontal scalability | ✓ No shared state | ⚠️ Requires shared Redis |
| Microservices / multiple services | ✓ Token carries context | ❌ Session must be accessible |
| Instant logout | ❌ Only with blocklist | ✓ Destroys the session |
| Implementation complexity | Medium | Low |
JWT works well for service-to-service communication, mobile-consumed APIs, and architectures where you don't want to depend on shared state. Session with Redis is simpler when you have a traditional web app that needs granular control — logout across all devices, immediate bans, permission changes that take effect on the next request.
Choosing JWT because "it's modern" without considering revocation needs is the start of many problems.
The real token structure
JWT is not encryption. It's a signing mechanism. The content is Base64URL-encoded — anyone with the token can read the payload. Security comes from signature integrity, not data secrecy.
A token has three parts separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzAxIn0.SflKxwRJSMeKKF2Qt4fwpM
HEADER PAYLOAD SIGNATURE
Header — declares the signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
Payload — the claims: data you want to transmit:
{
"sub": "user_01HXYZ",
"role": "admin",
"plan": "pro",
"iat": 1747267200,
"exp": 1747270800
}
Signature — what guarantees nothing was altered:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
JWT_SECRET
)
💡 Want to inspect a token in real time? Use the JWT Decoder — paste any token and see the decoded header, payload, and expiration instantly, no install needed.
iat (issued at), exp (expiration), sub (subject), and jti (JWT ID) are registered claims from RFC 7519. Use them instead of creating custom fields with the same purpose — every JWT library knows how to interpret them.
The complete flow with code
The basic authentication cycle:
[client] POST /auth/login → [server validates credentials]
← access_token (15min) + refresh_token (7d)
[client] GET /api/data → Authorization: Bearer <access_token>
← 200 OK (server verified signature locally)
[expired] POST /auth/refresh → refresh_token in body or cookie
← new access_token + new refresh_token
Authentication middleware in TypeScript:
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
function generateAccessToken(userId: string, role: string): string {
return jwt.sign(
{ sub: userId, role },
process.env.JWT_SECRET!,
{ expiresIn: "15m", algorithm: "HS256" }
);
}
function authenticate(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Token not provided" });
}
try {
const token = auth.split(" ")[1];
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"],
});
req.user = payload as TokenPayload;
next();
} catch {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
Note the algorithms: ["HS256"] in the verification. That's not a detail — it's what prevents a real attack vector.
5 errors that compromise your API
1. Sensitive data in the payload
// ❌ Wrong
const token = jwt.sign(
{
sub: user.id,
password_hash: user.password,
ssn: user.ssn,
credit_card: user.cardNumber,
},
SECRET
);
Anyone with that token reads all of it. No cracking needed. Base64 decodes in milliseconds. The payload should carry only what's necessary for authorization — sub, role, plan. If you need more user data, query the database using sub.
2. Accepting the none algorithm
The most well-known vulnerability in the JWT ecosystem: some older libraries accept alg: "none" in the header, eliminating signature verification entirely. An attacker edits the payload, declares none, and the server accepts it.
// ❌ Vulnerable
jwt.verify(token, secret);
// ✅ Safe
jwt.verify(token, secret, { algorithms: ["HS256"] });
3. Weak secret or secret committed to the repository
# ❌ All of these are dangerous
JWT_SECRET=secret
JWT_SECRET=my-api-2024
JWT_SECRET=abc123
A short secret can be broken by offline brute force. A secret in the repository means anyone with access to the Git history has permanent access, even after rotation.
# ✅ Generate a proper secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Use a secrets service in production: AWS Secrets Manager, HashiCorp Vault, Doppler.
4. Token without expiration
// ❌ Valid forever
jwt.sign({ sub: userId }, SECRET);
// ✅ Short-lived
jwt.sign({ sub: userId }, SECRET, { expiresIn: "15m" });
A token without exp is valid forever. If it appears in an error log, a debug response, or a Slack screenshot — it works tomorrow, in six months, two years from now. Short expiration limits the damage window of any leak.
5. Not validating aud and iss in multi-service systems
If you have more than one service issuing or consuming JWTs, a token generated for service A may be accepted by service B if neither validates aud (audience) and iss (issuer).
// Signing
jwt.sign(
{ sub: userId, role: "user" },
SECRET,
{ expiresIn: "15m", issuer: "auth-service", audience: "api-service" }
);
// Verifying
jwt.verify(token, SECRET, {
algorithms: ["HS256"],
issuer: "auth-service",
audience: "api-service",
});
⚠️ Important: JWT has no native revocation. A valid token issued at 2pm is still valid at 2:14pm even if the user changed their password at 2:01pm. If your system needs immediate invalidation, you'll need a blocklist — which breaks part of the stateless advantage. Consider server-side sessions for those cases.
Secure storage: localStorage or HttpOnly cookie?
Where the client stores the token defines which attack surface you need to mitigate.
| Strategy | XSS | CSRF | Complexity |
|---|---|---|---|
localStorage |
Vulnerable | Immune | Low |
| HttpOnly Cookie | Immune | Vulnerable | Medium |
HttpOnly + SameSite=Strict + CSRF token |
Immune | Immune | High |
HttpOnly + SameSite=Lax (modern default) |
Immune | Protected for most cases | Medium |
Cookie with HttpOnly is the safer option because JavaScript can't access the value. Full configuration:
res.cookie("access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 15 * 60 * 1000,
path: "/",
});
There's no universally correct choice. Assess your real attack surface: if you have a lot of third-party content on the page, HttpOnly protects more. If your API is consumed by many different clients, localStorage may be more practical with proper XSS mitigations.
Refresh token with secure rotation
A 15-minute access token is secure but unworkable without a refresh token. The rotation flow:
async function refreshAccessToken(refreshToken: string) {
const stored = await db.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true },
});
if (!stored || stored.expiresAt < new Date() || stored.revoked) {
throw new Error("Invalid or revoked refresh token");
}
// Revoke the used token
await db.refreshToken.update({
where: { id: stored.id },
data: { revoked: true },
});
// Issue a new one in the same family
const newRefresh = await db.refreshToken.create({
data: {
token: crypto.randomUUID(),
userId: stored.userId,
familyId: stored.familyId, // 👈 key for detecting reuse
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
const accessToken = generateAccessToken(stored.userId, stored.user.role);
return { accessToken, refreshToken: newRefresh.token };
}
The familyId field is what allows detecting reuse. When a refresh token is used, it's revoked and a new one is issued in the same family. If the revoked token appears again (which only happens if there was a leak), you identify the family and immediately revoke all tokens for that user.
💡 Refresh tokens are always stateful — they need to exist in the database. It's precisely because they allow issuing new access tokens that they need to be tracked and revocable.
Security checklist before going to production
Run this before any deploy:
- [ ]
JWT_SECRETis at least 64 randomly generated bytes - [ ]
JWT_SECRETis not in the repository (not even in Git history) - [ ] Access token has short expiration (
15mto1h) - [ ]
algorithmsis fixed in verification (["HS256"]or["RS256"]) - [ ] Payload contains no sensitive data beyond what's needed for authorization
- [ ] Refresh tokens are stored in the database with a
revokedfield - [ ] Refresh token rotation is implemented with reuse detection
- [ ] Logout revokes the refresh token in the database
- [ ] User tokens are all revoked on password change or compromised account
- [ ] In multi-service systems,
issandaudare validated - [ ] Cookie configured with
HttpOnly,Secure, andSameSiteappropriate for context - [ ] Error logs don't expose token values
FAQ
JWT or session for a traditional web app?
If you need immediate revocation (logout, bans, permission changes), session with Redis is simpler. JWT is better when you have multiple services or mobile clients. For traditional monoliths, sessions usually solve the problem with less complexity.
HS256 or RS256?
HS256 uses a symmetric key — the same secret signs and verifies. RS256 uses an asymmetric pair — private key signs, public key verifies. If only one service issues tokens, HS256 is sufficient. If multiple services need to verify without issuing, distribute the public key and use RS256.
What to do if JWT_SECRET leaks?
Rotate the secret immediately — this invalidates all existing tokens. Investigate the source of the leak before generating the new one. If you're on AWS, use Secrets Manager for automatic rotation and access auditing.
Can I invalidate a JWT before it expires?
Not natively. You need a blocklist: store the jti of revoked tokens in Redis with TTL equal to the token's exp. Every verification queries Redis. Simple, but adds a network dependency per request.
Next steps
- Run the checklist now on any JWT project you have in production — especially the algorithm and payload items
-
Implement
familyIdon refresh tokens if you haven't yet — that's what separates a functional implementation from a secure one - Explore JWKS if you work with multiple services — the auth server exposes public keys via endpoint and each service fetches them dynamically
-
Read the OWASP JWT Security Cheat Sheet — covers vectors like
kidinjection andjkuspoofing that are real in more complex environments
Originally published on Dev Code Software.
Top comments (0)