If you have built a login system, called a third-party API, or worked with OAuth, you have encountered JSON Web Tokens. JWTs are everywhere in modern web development, yet many developers use them without fully understanding what they are, how they work, or what can go wrong.
This guide covers JWT from the ground up: structure, signing algorithms, practical Node.js examples, when to choose JWT over sessions, and the security mistakes that actually matter in production.
What Is a JWT?
A JSON Web Token is a compact, URL-safe string that carries a set of claims between two parties. It is defined in RFC 7519. The token is self-contained, meaning the server can verify it without looking anything up in a database.
A typical JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzA5MTIzNDU2fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts separated by dots. That is the entire token. You can paste it into a JWT decoder to see what is inside without any special software.
JWT Structure: Header, Payload, Signature
Every JWT has exactly three segments, each Base64URL-encoded.
Header
The header declares the token type and the signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
The alg field is critical. It tells the verifier which algorithm was used to create the signature. Common values include HS256, RS256, ES256, and none (which you should never accept in production, more on that later).
Payload
The payload carries the claims, which are statements about the user and additional metadata:
{
"sub": "user-8842",
"name": "Alice Chen",
"email": "alice@example.com",
"role": "admin",
"iat": 1709123456,
"exp": 1709127056
}
Claims fall into three categories:
Registered claims are standardized names defined in the spec:
| Claim | Full Name | Purpose |
|---|---|---|
sub |
Subject | Identifies the principal (usually a user ID) |
iss |
Issuer | Who created the token |
aud |
Audience | Who the token is intended for |
exp |
Expiration | Unix timestamp after which the token is invalid |
iat |
Issued At | When the token was created |
nbf |
Not Before | Token is not valid before this time |
jti |
JWT ID | Unique identifier to prevent replay attacks |
Public claims are custom names registered in the IANA JWT Claims Registry or namespaced to avoid collisions.
Private claims are whatever you and your application agree on, like role, team_id, or permissions.
Signature
The signature prevents tampering. For HMAC-SHA256, it works like this:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
If anyone modifies a single character in the header or payload, the signature will not match and the token is rejected. The signature does not encrypt the payload. Anyone can decode it. The signature only guarantees integrity and authenticity.
Signing Algorithms: HS256 vs RS256
The two most common algorithms serve different architectural needs.
HS256 (HMAC with SHA-256)
Symmetric signing. The same secret key creates and verifies the token.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // at least 256 bits
// Sign
const token = jwt.sign(
{ sub: 'user-8842', role: 'admin' },
SECRET,
{ expiresIn: '1h' }
);
// Verify
try {
const decoded = jwt.verify(token, SECRET);
console.log(decoded);
// { sub: 'user-8842', role: 'admin', iat: 1709123456, exp: 1709127056 }
} catch (err) {
console.error('Invalid token:', err.message);
}
Use HS256 when: a single service both issues and verifies tokens. It is simpler and faster.
Risk: every service that needs to verify the token must have the secret. If you share the secret across multiple microservices, a compromise in any one of them leaks the signing key.
RS256 (RSA with SHA-256)
Asymmetric signing. A private key signs the token, and a public key verifies it.
const fs = require('fs');
const jwt = require('jsonwebtoken');
const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');
// Sign (only the auth service has the private key)
const token = jwt.sign(
{ sub: 'user-8842', role: 'admin' },
privateKey,
{ algorithm: 'RS256', expiresIn: '1h' }
);
// Verify (any service can have the public key)
try {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
console.log(decoded);
} catch (err) {
console.error('Invalid token:', err.message);
}
Use RS256 when: multiple services need to verify tokens but only one service should issue them. This is the standard approach for microservices and when publishing a JWKS endpoint.
Quick Comparison
| HS256 | RS256 | |
|---|---|---|
| Key type | Shared secret | Private/public key pair |
| Speed | Faster | Slower (RSA math) |
| Key distribution | Secret must be shared | Only public key is shared |
| Best for | Single service | Microservices, third-party verification |
Decoding a JWT Without a Library
Since the payload is just Base64URL-encoded JSON, you can decode it anywhere. Here is a plain JavaScript function:
function decodeJwtPayload(token) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(base64, 'base64').toString('utf-8');
return JSON.parse(json);
}
const payload = decodeJwtPayload(
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTg4NDIiLCJyb2xlIjoiYWRtaW4ifQ.abc123'
);
console.log(payload);
// { sub: 'user-8842', role: 'admin' }
This only decodes the payload; it does not verify the signature. For quick inspection during development, an online JWT decoder tool is even faster: paste the token, see the header, payload, and signature status instantly.
JWT vs Sessions: When to Use Which
This is one of the most debated topics in web authentication. Here is a practical breakdown.
Server-side sessions
The server stores session data (in memory, Redis, or a database) and gives the client a session ID cookie.
Advantages: immediate revocation (delete the session), small cookie size, no sensitive data on the client.
Disadvantages: requires server-side storage that scales with active users, sticky sessions or shared stores needed in load-balanced setups.
JWT-based authentication
The server issues a signed token. The client stores it (usually in an httpOnly cookie) and sends it with each request. The server verifies the signature and reads the claims without any database lookup.
Advantages: stateless verification, works naturally across microservices, no shared session store needed.
Disadvantages: cannot be revoked instantly (the token is valid until it expires), larger payload than a session ID, requires careful security practices.
The Practical Answer
Use server-side sessions when you need instant revocation (banking, admin panels) or your app runs on a single server. Use JWTs when you have a microservices architecture, need cross-domain authentication, or are building a public API. Many production systems use both: short-lived JWTs for API access, plus a revocable refresh token stored server-side.
Security Best Practices
JWT-related vulnerabilities are well-documented and mostly preventable. Here are the ones that matter.
1. Always Validate the Algorithm
The infamous alg: none attack works because some libraries accept whatever algorithm the token header declares. Always specify the expected algorithm on the verification side:
// WRONG - accepts whatever the token says
jwt.verify(token, secret);
// RIGHT - explicitly allow only HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });
This single line prevents an entire class of attacks.
2. Set Short Expiration Times
Access tokens should be short-lived. Fifteen minutes is a reasonable default for access tokens. Use refresh tokens (stored securely, server-side revocable) to issue new access tokens.
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ sub: userId }, refreshSecret, { expiresIn: '7d' });
3. Use Strong Secrets
For HS256, the secret must be at least 256 bits (32 bytes) of cryptographic randomness. A short password is not enough.
# Generate a proper secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
4. Store Tokens Properly
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
localStorage |
High | None | Avoid for auth tokens |
httpOnly cookie |
None | Moderate | Preferred, add CSRF protection |
| Memory (JS variable) | None | None | Good, but lost on refresh |
The safest approach for browser-based apps: store the access token in an httpOnly, Secure, SameSite=Strict cookie.
5. Never Put Secrets in the Payload
The JWT payload is encoded, not encrypted. Anyone with the token can decode and read it. Never include passwords, API keys, credit card numbers, or other sensitive data in JWT claims.
6. Validate All Relevant Claims
Beyond signature verification, check exp, iss, aud, and any custom claims your application depends on:
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
clockTolerance: 30 // allow 30 seconds of clock skew
});
7. Plan for Token Revocation
Pure JWTs cannot be revoked before expiration. If you need revocation (user logs out, permissions change, account compromised), consider:
- Short-lived tokens + refresh rotation: most common pattern
- Token blocklist: check a fast store (Redis) for revoked JTIs on each request
- Token versioning: store a version counter per user; reject tokens with an old version
Debugging JWTs in Practice
When something goes wrong with JWT authentication, here is a systematic approach:
Decode the token to inspect the header and payload. Use a JWT decoder or the
decodeJwtPayloadfunction above.Check expiration. The
expclaim is a Unix timestamp. Compare it to the current time. Clock skew between servers is a common cause of mysterious failures.Verify the algorithm matches. If the server expects RS256 but the token was signed with HS256, verification will fail even if the payload looks correct.
Inspect the issuer and audience. Mismatched
issoraudclaims cause silent rejections in well-configured libraries.Check for token size issues. JWTs in
Authorizationheaders count toward HTTP header size limits. If you have stuffed too many claims into the payload, proxies or load balancers may truncate or reject the request.
A full walkthrough of JWT internals, common pitfalls, and debugging workflows is available in this in-depth JWT guide.
Complete Example: Auth Middleware in Express
Here is a minimal but production-ready pattern for JWT authentication in an Express app:
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
const SECRET = process.env.JWT_SECRET;
// Middleware
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET, {
algorithms: ['HS256'],
issuer: 'my-app'
});
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// Login route
app.post('/login', (req, res) => {
// ... validate credentials ...
const token = jwt.sign(
{ sub: user.id, role: user.role, iss: 'my-app' },
SECRET,
{ expiresIn: '15m' }
);
res.json({ token });
});
// Protected route
app.get('/dashboard', authenticate, (req, res) => {
res.json({ message: `Welcome, user ${req.user.sub}` });
});
app.listen(3000);
Key Takeaways
- A JWT is three Base64URL-encoded segments: header, payload, and signature. The payload is readable by anyone; the signature prevents tampering.
- Use HS256 for single-service setups and RS256 when multiple services need to verify tokens independently.
- Always pin the expected algorithm in your verification code to prevent algorithm confusion attacks.
- Store tokens in
httpOnlycookies, keep access tokens short-lived, and plan for revocation from the start. - When debugging, decode the token first and check
exp,alg,iss, andaudbefore digging into application logic.
JWTs are a powerful primitive, but they are not magic. Treat them as you would any security-critical component: understand the spec, validate rigorously, and keep the attack surface small.
Top comments (0)