What is JWT Authentication?
JSON Web Token (JWT, pronounced "jot") is a compact, self-contained standard for transmitting authentication and authorization information between parties as a digitally signed token. Once a user logs in, the server issues a JWT containing claims about that user. The client stores the token and sends it with every subsequent request. The server verifies the token's signature to confirm authenticity — no database lookup required.
JWTs are defined by RFC 7519 and are the dominant authentication mechanism for REST APIs, single-page applications, and microservices architectures. They are stateless by design: the server does not need to store session data, making horizontal scaling straightforward.
How JWT Works
The full JWT authentication flow works like this:
- Login: The user submits their credentials (username + password) to the server's login endpoint.
- Token issuance: The server validates the credentials against the database. On success, it signs a JWT containing the user's ID, roles, and an expiry timestamp, then returns the token to the client.
- Token storage: The client stores the token (in memory, localStorage, or an httpOnly cookie — more on this later).
- Authenticated requests: For every subsequent API request, the client includes the JWT in the
Authorizationheader:Authorization: Bearer <token>. - Verification: The server extracts the token from the header, verifies the signature using its secret key, checks the expiry, and reads the claims — all without a database query.
- Response: If the token is valid, the server processes the request. If not (expired, tampered, missing), it returns a
401 Unauthorized.
JWT Structure Explained
A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTYiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDkzMDAwMDAsImV4cCI6MTcwOTMwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The three parts are:
1. Header
{
"alg": "HS256", // Signing algorithm (HS256, RS256, ES256)
"typ": "JWT" // Token type
}
2. Payload (Claims)
{
"userId": "123456",
"email": "user@example.com",
"role": "admin",
"iat": 1709300000, // Issued at (Unix timestamp)
"exp": 1709303600 // Expiry (1 hour from issuance)
}
Standard claim names: iss (issuer), sub (subject), aud (audience), exp (expiry), iat (issued at), jti (JWT ID for revocation). The payload is Base64URL-encoded, not encrypted — anyone can decode it. Never store passwords, credit card numbers, or other sensitive data in the payload.
3. Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
The signature proves the token was issued by your server and has not been tampered with. If any character in the header or payload changes, the signature verification fails.
Implementing JWT in Node.js
Install the jsonwebtoken package:
npm install jsonwebtoken bcryptjs express
Create a complete login and protected route example:
// auth.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const express = require('express');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET; // Must be a long, random string
const JWT_EXPIRES_IN = '1h'; // Keep access tokens short-lived
// Login route — issue a JWT on successful authentication
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// 1. Look up the user in your database
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 2. Compare password with stored hash
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 3. Sign the JWT — include only non-sensitive claims
const token = jwt.sign(
{ userId: user._id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN, algorithm: 'HS256' }
);
res.json({ token, expiresIn: 3600 });
});
// Middleware to verify JWT on protected routes
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
// Explicitly specify the algorithm to prevent algorithm confusion attacks
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
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' });
}
}
// Protected route example
router.get('/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.userId, email: req.user.email });
});
module.exports = { router, authenticateToken };
Storing JWT in React
Where you store the JWT in the browser significantly affects your application's security posture. The two main options are localStorage and httpOnly cookies:
| Property | localStorage | httpOnly Cookie |
|---|---|---|
| XSS vulnerability | High — any script can read it | Low — inaccessible to JavaScript |
| CSRF vulnerability | None | Yes — mitigate with SameSite + CSRF token |
| Sent automatically | No — must add to header manually | Yes — browser sends with every request |
| Accessible from JS | Yes | No (httpOnly flag) |
| Recommended for | Low-risk apps, public data | Production apps with user data |
For production applications, prefer httpOnly cookies. Your server sets the cookie on login and the browser sends it automatically, without JavaScript ever touching the token value.
If you do use localStorage (e.g., for simplicity during development), store the token in memory where possible and implement a robust Content Security Policy to mitigate XSS risk:
// React — sending JWT from localStorage
async function fetchProfile() {
const token = localStorage.getItem('authToken');
const response = await fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Token expired — redirect to login or refresh
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return response.json();
}
Refresh Token Strategy
Short-lived access tokens (15 minutes to 1 hour) minimize the window of token theft, but forcing users to log in every hour is terrible UX. The solution is a refresh token pair:
- Access token: Short-lived (15 min – 1 hour), sent with every API request in the
Authorizationheader. Stored in memory or localStorage. - Refresh token: Long-lived (7 – 30 days), used only to obtain new access tokens. Always stored in an httpOnly, Secure, SameSite=Strict cookie. Should be rotated on every use.
// Server: issue both tokens on login
router.post('/login', async (req, res) => {
// ... validate credentials ...
const accessToken = jwt.sign(
{ userId: user._id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user._id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '30d' }
);
// Store refresh token hash in database for revocation support
await RefreshToken.create({ userId: user._id, token: hashToken(refreshToken) });
// Send refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
});
res.json({ accessToken });
});
// Server: refresh endpoint
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Optionally: check token hash exists in DB (enables revocation)
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
JWT Security Best Practices
- Keep access tokens short-lived: 15 minutes to 1 hour. Stolen tokens are useless after expiry.
- Use strong secrets: Your
JWT_SECRETshould be at least 256 bits of random data. Generate it withopenssl rand -hex 32. Never hardcode it. - Always specify the algorithm: Pass
{ algorithms: ['HS256'] }tojwt.verify(). This prevents the algorithm confusion (none) attack where an attacker sets"alg": "none"in the header. - HTTPS only in production: JWTs in transit are only as secure as your transport layer. Always use TLS in production.
- Store refresh tokens in httpOnly cookies: Not in localStorage or sessionStorage.
- Implement token revocation: For logout and security events, maintain a short-lived blocklist (Redis works well) or use rotating refresh tokens with database tracking.
- Validate all claims: Check
exp(expiry),iss(issuer), andaud(audience) on every request. Thejsonwebtokenlibrary does this automatically when you pass the right options.
Common JWT Mistakes
Storing JWTs in localStorage on high-risk apps
Any XSS vulnerability — even a third-party script you loaded — can read localStorage and exfiltrate all tokens. For applications handling payments, personal data, or admin actions, use httpOnly cookies exclusively.
No expiry on tokens
Tokens without an exp claim are valid forever. A leaked token becomes a permanent backdoor. Always set expiresIn when signing.
The algorithm none attack
Early JWT libraries accepted "alg": "none" in the header, effectively accepting unsigned tokens as valid. Always pass the algorithms option to jwt.verify() to restrict which algorithms are acceptable:
// Always do this — never allow the "none" algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
Sensitive data in the payload
The JWT payload is Base64URL-encoded, not encrypted. Any party with the token can decode and read the payload without knowing the secret. Never include passwords, SSNs, credit card numbers, or internal system details in JWT claims.
Using the same secret for access and refresh tokens
If you use the same secret for both token types, a compromised access token could potentially be used as a refresh token. Use separate secrets for each token type stored in separate environment variables.
Decode Your JWT Tokens
Need to inspect a JWT to check its claims, expiry, or structure? Paste it into the devbit.dev JWT Decoder to instantly decode the header and payload, check the expiry time, and validate the token format — all in your browser with no data sent to any server.
Decode & Inspect Any JWT Instantly
Paste a JWT token to decode its header, payload, and claims. Checks expiry, algorithm, and structure. 100% client-side — your token never leaves your browser.
Open JWT Decoder →Related Developer Tools
- JWT Decoder & Inspector — decode any JSON Web Token instantly, check expiry and claims
- CORS Error Debugger — fix CORS errors with the Authorization header in Express, Nginx
- Hash Generator — generate SHA-256, MD5, and bcrypt hashes for passwords and tokens
- HTTP Status Codes — understand 401 Unauthorized vs 403 Forbidden and other auth codes
- ENV Inspector — validate JWT_SECRET and other environment variables in your .env file
- View all free developer tools