Understanding JWTs
A JSON Web Token (JWT) is a compact, URL-safe token format that encodes claims as a JSON object. JWTs are widely used for authentication and information exchange. A JWT consists of three parts separated by dots: the header (algorithm and token type), the payload (claims), and the signature (verification). The header and payload are Base64URL-encoded JSON objects — they are not encrypted by default, just encoded.
This is a critical point that many developers miss: the payload of a JWT is readable by anyone. If you Base64-decode the payload portion of a JWT, you can read all the claims inside it. JWTs provide integrity (you can verify the token has not been tampered with) but not confidentiality (anyone can read the contents). Never put sensitive information like passwords, credit card numbers, or secrets in a JWT payload.
Security Warning: JWT Misconceptions
- JWTs are not encrypted: The payload is Base64-encoded, not encrypted. Anyone can decode and read it. Use JWE (JSON Web Encryption) if you need encrypted tokens.
- JWTs cannot be revoked easily: Unlike sessions, JWTs are valid until they expire. You need a separate revocation mechanism (blocklist) to invalidate them early.
- JWTs are not always the right choice: For simple server-side applications, server-side sessions are often simpler and more secure than JWTs.
Creating Secure JWTs
When creating JWTs, use strong algorithms, set appropriate expiration times, and include only the claims you need. Always prefer asymmetric algorithms (RS256, ES256) over symmetric ones (HS256) for systems where multiple services need to verify tokens but only one should create them.
import jwt from "jsonwebtoken";
import crypto from "crypto";
import fs from "fs";
// RECOMMENDED: Asymmetric signing with RS256
// Only the auth server has the private key
// All services can verify with the public key
const privateKey = fs.readFileSync("./keys/private.pem");
const publicKey = fs.readFileSync("./keys/public.pem");
interface TokenPayload {
userId: string;
role: string;
permissions: string[];
}
function createAccessToken(payload: TokenPayload): string {
return jwt.sign(
{
sub: payload.userId,
role: payload.role,
permissions: payload.permissions,
// Token ID for revocation tracking
jti: crypto.randomUUID(),
},
privateKey,
{
algorithm: "RS256",
expiresIn: "15m", // Short-lived access tokens
issuer: "https://auth.myapp.com",
audience: "https://api.myapp.com",
notBefore: 0, // Valid immediately
}
);
}
function createRefreshToken(userId: string): string {
const tokenId = crypto.randomUUID();
// Store refresh token metadata in database for revocation
db.query(
"INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)",
[tokenId, userId, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)]
);
return jwt.sign(
{ sub: userId, jti: tokenId, type: "refresh" },
privateKey,
{
algorithm: "RS256",
expiresIn: "7d",
issuer: "https://auth.myapp.com",
}
);
}
Validating JWTs Securely
Token validation is where most JWT vulnerabilities are exploited. You must verify the signature, check the expiration, validate the issuer and audience, and explicitly specify the allowed algorithms. The infamous "alg: none" attack works because some libraries accept tokens with no signature if the algorithm header says "none."
// Secure JWT verification
function verifyAccessToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, publicKey, {
// CRITICAL: Always specify allowed algorithms
// This prevents the "alg: none" attack and algorithm confusion attacks
algorithms: ["RS256"],
// Verify issuer matches your auth server
issuer: "https://auth.myapp.com",
// Verify audience matches this service
audience: "https://api.myapp.com",
// Clock tolerance for slight server time differences
clockTolerance: 10, // 10 seconds
// Require these claims to be present
complete: false,
});
return decoded as TokenPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthError("Token expired", 401);
}
if (error instanceof jwt.JsonWebTokenError) {
throw new AuthError("Invalid token", 401);
}
throw new AuthError("Token verification failed", 401);
}
}
// Middleware for Express
function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing authorization header" });
}
const token = authHeader.slice(7); // Remove "Bearer "
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch (error) {
return res.status(401).json({ error: error.message });
}
}
JWT Revocation Strategies
One of the biggest challenges with JWTs is revocation. Since JWTs are stateless and self-contained, there is no built-in way to invalidate a token before it expires. This is a problem when a user logs out, changes their password, or when you detect suspicious activity and need to immediately revoke access. There are several strategies to handle this.
// Strategy 1: Token Blocklist (for critical revocations)
class TokenBlocklist {
private redis: RedisClient;
async revoke(jti: string, expiresAt: number): Promise<void> {
// Store in blocklist until the token would have expired anyway
const ttl = expiresAt - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.setEx(`blocklist:${jti}`, ttl, "revoked");
}
}
async isRevoked(jti: string): Promise<boolean> {
const result = await this.redis.get(`blocklist:${jti}`);
return result !== null;
}
}
// Strategy 2: Token Version (per-user revocation)
// Store a token version number in the database for each user
// Increment it when you need to invalidate all their tokens
async function verifyTokenVersion(userId: string, tokenVersion: number) {
const user = await db.query(
"SELECT token_version FROM users WHERE id = $1",
[userId]
);
if (user.rows[0].token_version !== tokenVersion) {
throw new AuthError("Token has been revoked");
}
}
// Strategy 3: Short-lived tokens + refresh token rotation
// Access tokens expire in 15 minutes
// Refresh tokens are single-use and stored in database
async function rotateRefreshToken(oldRefreshToken: string) {
const decoded = jwt.verify(oldRefreshToken, publicKey, {
algorithms: ["RS256"],
});
// Check if refresh token exists and is not used
const stored = await db.query(
"SELECT * FROM refresh_tokens WHERE id = $1 AND used = false",
[decoded.jti]
);
if (!stored.rows.length) {
// Token reuse detected! Possible token theft
// Revoke ALL refresh tokens for this user
await db.query(
"DELETE FROM refresh_tokens WHERE user_id = $1",
[decoded.sub]
);
throw new SecurityError("Refresh token reuse detected - all sessions revoked");
}
// Mark old token as used
await db.query("UPDATE refresh_tokens SET used = true WHERE id = $1", [decoded.jti]);
// Issue new tokens
const accessToken = createAccessToken({ userId: decoded.sub, role: decoded.role, permissions: decoded.permissions });
const refreshToken = createRefreshToken(decoded.sub);
return { accessToken, refreshToken };
}
JWT Security Checklist
- Always specify algorithms: Use the
algorithmsoption during verification to prevent algorithm confusion attacks. - Use asymmetric keys: RS256 or ES256 so that only the auth server can create tokens.
- Keep access tokens short-lived: 15 minutes or less for access tokens.
- Validate all standard claims: Check exp, iss, aud, nbf, and iat.
- Include a token ID (jti): For revocation and replay prevention.
- Minimize payload size: Only include necessary claims. Fetch additional data from the database.
- Rotate signing keys: Periodically rotate your signing keys and support multiple keys during transitions.
- Never store in localStorage: Use HttpOnly cookies or in-memory storage for JWTs in browsers.
Common JWT Attacks and Defenses
Attack Vectors
| Attack | Description | Defense |
|---|---|---|
| Algorithm None | Set alg to "none" to bypass verification | Always specify allowed algorithms |
| Key Confusion | Use public key as HMAC secret | Specify algorithm explicitly |
| Token Theft | XSS steals token from storage | HttpOnly cookies, short expiry |
| Replay Attack | Reuse a valid token | Include jti, use nonces for sensitive ops |
| Claim Injection | Modify claims in unverified token | Always verify signature before trusting claims |