Implementing JWT Authentication for Web Applications
JWT (JSON Web Token) is a compact token containing a signed set of claims. Unlike session-based approaches, the server doesn't maintain state—everything needed is embedded in the token itself. Horizontal scalability without sticky sessions, no database roundtrips on every request.
JWT Structure
A token consists of three base64url-encoded parts separated by a dot:
header.payload.signature
// Decoded header:
{ "alg": "RS256", "typ": "JWT" }
// Decoded payload:
{
"sub": "user_42", // subject — user ID
"iss": "api.example.com", // issuer
"aud": "app.example.com", // audience
"iat": 1735600000, // issued at
"exp": 1735686400, // expires at (24 hours)
"jti": "uuid-v4", // JWT ID for revocation
"roles": ["admin"],
"plan": "pro"
}
Signature—HMAC-SHA256 (HS256) or RSA/ECDSA (RS256/ES256). RS256 is preferable: private key only on the issuing server, public key can be distributed to any resource server for verification.
Implementation (Node.js + jose)
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
// Generate key pair (once, store in secrets)
const { privateKey, publicKey } = await generateKeyPair('RS256');
// Issue tokens
async function issueTokens(userId: string, roles: string[]) {
const now = Math.floor(Date.now() / 1000);
const accessToken = await new SignJWT({ roles, plan: 'pro' })
.setProtectedHeader({ alg: 'RS256' })
.setSubject(userId)
.setIssuedAt(now)
.setExpirationTime('15m') // short-lived
.setJti(crypto.randomUUID())
.sign(privateKey);
const refreshToken = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256' })
.setSubject(userId)
.setIssuedAt(now)
.setExpirationTime('30d') // long-lived
.setJti(crypto.randomUUID())
.sign(privateKey);
return { accessToken, refreshToken };
}
// Verification
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'api.example.com',
audience: 'app.example.com',
});
return payload;
}
Access Token + Refresh Token Strategy
Access token lives 15 minutes—minimal window during compromise. Refresh token—30 days, stored securely:
// On login—set refresh token in httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // inaccessible to JS
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 30 * 24 * 3600 * 1000,
path: '/api/auth/refresh', // cookie sent ONLY to /refresh
});
// Access token—in memory (React state or module variable), NOT in localStorage
// localStorage is vulnerable to XSS
Refresh endpoint:
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const payload = await verifyToken(refreshToken);
// Check if token is revoked (by jti)
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
const user = await db.user.findUnique({ where: { id: payload.sub } });
const tokens = await issueTokens(user.id, user.roles);
// Rotate refresh token
await redis.setex(`revoked:${payload.jti}`, 30 * 24 * 3600, '1');
res.cookie('refresh_token', tokens.refreshToken, { /* ... */ });
res.json({ access_token: tokens.accessToken });
});
Revocation via Redis Blocklist
JWT is stateless—you can't "revoke" without additional storage. Solution—blocklist with TTL:
// Logout
app.post('/api/auth/logout', authenticate, async (req, res) => {
const { jti, exp } = req.jwtPayload;
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`revoked:${jti}`, ttl, '1');
}
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
res.json({ success: true });
});
// Verification middleware with blocklist check
async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const payload = await verifyToken(token);
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
req.jwtPayload = payload;
next();
}
Laravel—Implementation via tymon/jwt-auth
composer require tymon/jwt-auth
php artisan jwt:secret
// config/auth.php
'guards' => [
'api' => ['driver' => 'jwt', 'provider' => 'users'],
],
// AuthController
public function login(LoginRequest $request)
{
$credentials = $request->only(['email', 'password']);
if (!$token = auth('api')->attempt($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
public function refresh()
{
return response()->json([
'access_token' => auth('api')->refresh(),
]);
}
What NOT to Put in JWT
JWT is visible to anyone who intercepts the token (before signature verification)—the signature guarantees integrity, not confidentiality. Don't put in payload:
- Passwords, secrets
- Payment data
- Personal data beyond necessity (GDPR—data minimization)
Optimal payload: sub (user_id), roles, plan, jti, standard claims.
Timeline
JWT auth with RS256, access+refresh tokens, httpOnly cookie for refresh, Redis revocation, logout: 3–5 days. With auto-refresh in React (silent refresh), multi-device support, session audit log: 1–2 weeks.







