JWT is widely used for authentication in REST APIs and SPAs. This article covers a step-by-step implementation: login, token issuance, verification middleware, and client-side storage.
General flow
- The user submits a username and password
- The server verifies the credentials and generates a JWT
- The client stores the token and sends it in the header with requests
- The server verifies the signature and claims, then returns data or 401
Issuing a token (Node.js + Express)
const jwt = require('jsonwebtoken');
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await findUserByEmail(email);
if (!user || !await verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token, expiresIn: 3600 });
});Verifying a token (middleware)
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid token' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (e) {
if (e.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}Protecting routes
app.get('/api/profile', authMiddleware, (req, res) => {
res.json({ user: req.user });
});Client-side storage
Option 1: localStorage
// После логина
localStorage.setItem('token', data.token);
// При запросах
const token = localStorage.getItem('token');
fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` }
});Drawback: vulnerability to XSS. If a script gains access to the page, it can read the token.
Option 2: httpOnly cookie
The server sets a cookie at login:
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});The browser automatically sends the cookie with requests. JavaScript cannot access the cookie, which protects against XSS. CORS and credentials must be configured.
Option 3: In-memory storage + refresh
Store the access token in memory (a variable) and the refresh token in an httpOnly cookie. When the page reloads, request a new access token through the refresh flow.
Refreshing a token (refresh)
app.post('/api/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await findUserById(payload.sub);
if (!user) return res.status(401).json({ error: 'User not found' });
const accessToken = jwt.sign(
{ sub: user.id },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token: accessToken });
} catch (e) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});Debugging tools
The JWT Decoder on rechecker.ru lets you decode a token and view its payload. Use it only with test tokens.
Security recommendations
- Use HTTPS
- Set a short access token lifetime (15–60 minutes)
- Store the refresh token in an httpOnly cookie
- Keep secrets in environment variables, not in code
- For public APIs, use RS256 instead of HS256
Handling expiration on the client
Intercept 401 and redirect to login or refresh the token:
async function fetchWithAuth(url, options = {}) {
let token = getToken();
let response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` }
});
if (response.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
token = getToken();
response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` }
});
}
}
return response;
}Logout
With localStorage, delete the token. With cookies, the server must clear the cookie in response to a logout request. For refresh tokens, add the token to a blacklist or delete it from server-side storage.
Additional materials
For JWT structure, algorithms, and security basics, see JWT tokens: how they work, structure, and security.
CORS when using cookies
If you store the token in a cookie and make requests from another domain, configure CORS:
app.use(cors({
origin: 'https://frontend.example.com',
credentials: true
}));The client must send credentials: 'include' in fetch.
Role checks
After verifying the token, check its claims:
function requireRole(role) {
return (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.get('/api/admin', authMiddleware, requireRole('admin'), handler);Rate limiting for login
Limit the number of login attempts from a single IP to protect against brute-force attacks. Use Redis or an in-memory store for counters.
Logging
Log successful and failed authentication attempts (without passwords or tokens). This helps with incident investigations.
Testing
Use test tokens with short lifetimes for tests. Mock authentication middleware in unit tests. In E2E tests, obtain a token through a test login or fixture.
Multi-tenancy
When using multiple applications (issuers), use iss and aud for verification. Store public keys or secrets by issuer. During verification, check that iss matches the expected issuer.
Migrating from sessions to JWT
Step by step: add JWT issuance at login alongside the session. Clients switch to JWT. After the migration is complete, disable sessions. Make sure the refresh flow and logout work correctly.
Refresh token security
The refresh token must be stored more securely than the access token: in an httpOnly cookie or secure storage. If compromised, invalidate it immediately. Consider binding it to a device or fingerprint for additional protection.
Logging authentication events
Log successful logins, failed logins (with IP, without the password), token expiration, and refresh events. This is necessary for auditing and incident investigations. Do not log tokens or passwords.