REST API Security · Lesson 16 of 30
JWT Authentication for REST APIs
A JSON Web Token (JWT) is a signed, compact token that carries claims – who the user is, their role, when the token expires. The API signs it at login; later, it verifies the signature on each request without looking anything up. That makes JWTs a natural fit for stateless REST APIs – as long as you understand their trade-offs.
In this lesson
- What's inside a JWT
- Login, signing and a verification middleware
- Role-based authorisation
- Expiry, refresh tokens and revocation
Anatomy of a JWT
header.payload.signature – each part base64url-encoded
- Header{"alg":"HS256","typ":"JWT"}
- Payload (claims){"sub":"u1","role":"admin","iat":…,"exp":…}
- SignatureHMAC-SHA256(header + payload, secret) – proves nobody changed them
Signed, not encrypted
Anyone holding a JWT can base64-decode and read its payload. Put identifiers and roles in it – never passwords, personal data or secrets.
A JWT-protected API
// server.js
import express from 'express';
import jwt from 'jsonwebtoken';
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET ?? 'dev-only-secret-change-me'; // from a secret store in production
const users = [
{ id: 'u1', email: 'asha@example.com', password: 'correct-horse', role: 'admin' }, // demo: real apps store bcrypt/argon2 hashes
{ id: 'u2', email: 'ravi@example.com', password: 'battery-staple', role: 'student' },
];
app.post('/api/auth/login', (req, res) => {
const user = users.find((u) => u.email === req.body?.email && u.password === req.body?.password);
if (!user) return res.status(401).json({ error: 'Invalid email or password' }); // don't reveal which one
const accessToken = jwt.sign({ role: user.role }, SECRET, {
subject: user.id,
expiresIn: '15m',
issuer: 'api.learnfrenzy.com',
algorithm: 'HS256',
});
res.json({ accessToken, tokenType: 'Bearer', expiresIn: 900 });
});
function authenticate(req, res, next) {
const [scheme, token] = (req.get('Authorization') ?? '').split(' ');
if (scheme !== 'Bearer' || !token) return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'Missing bearer token' });
try {
const claims = jwt.verify(token, SECRET, { algorithms: ['HS256'], issuer: 'api.learnfrenzy.com' }); // pin the algorithm
req.user = { id: claims.sub, role: claims.role };
next();
} catch (err) {
const error = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
res.status(401).set('WWW-Authenticate', `Bearer error="invalid_token"`).json({ error });
}
}
const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user.role) ? next() : res.status(403).json({ error: `Requires role: ${roles.join(' or ')}` });
app.get('/api/me', authenticate, (req, res) => res.json(req.user));
app.delete('/api/courses/:id', authenticate, requireRole('admin'), (req, res) => res.status(204).end());
app.listen(process.env.PORT ?? 3000);
// client.js
const API = 'http://localhost:3000';
async function login(email, password) {
const res = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return { status: res.status, body: await res.json() };
}
const call = async (method, path, token) => {
const res = await fetch(API + path, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} });
return `${method} ${path} → ${res.status} ${res.status === 204 ? '' : JSON.stringify(await res.json())}`;
};
console.log('wrong password:', (await login('ravi@example.com', 'nope')).status);
const ravi = (await login('ravi@example.com', 'battery-staple')).body;
console.log('token payload:', JSON.parse(Buffer.from(ravi.accessToken.split('.')[1], 'base64url')).role);
console.log(await call('GET', '/api/me', ravi.accessToken));
console.log(await call('DELETE', '/api/courses/react', ravi.accessToken));
const asha = (await login('asha@example.com', 'correct-horse')).body;
console.log(await call('DELETE', '/api/courses/react', asha.accessToken));
const tampered = asha.accessToken.slice(0, -2) + (asha.accessToken.endsWith('A') ? 'BB' : 'AA');
console.log(await call('GET', '/api/me', tampered));
console.log(await call('GET', '/api/me'));
$ node client.js
wrong password: 401
token payload: student
GET /api/me → 200 {"id":"u2","role":"student"}
DELETE /api/courses/react → 403 {"error":"Requires role: admin"}
DELETE /api/courses/react → 204
GET /api/me → 401 {"error":"Invalid token"}
GET /api/me → 401 {"error":"Missing bearer token"}- Ravi's token works for
/api/mebut gets 403 on an admin-only action; Asha's admin token gets 204. - Changing a single character of the token broke the signature: 401 Invalid token. That's what makes the claims trustworthy.
algorithms: ['HS256']pins the algorithm, blocking the classic "alg: none" and key-confusion attacks.
Expiry, refresh and revocation
Access + refresh tokens
- Loginshort-lived access token (5–15 min) + long-lived refresh token (stored in the database)
- Callsthe access token is verified without a database lookup – fast and stateless
- Expiredthe client sends the refresh token to /auth/refresh and gets a new access token
- Logout / compromisedelete the refresh token server-side; the access token dies within minutes
A JWT can't be "un-issued" before it expires – that's the price of statelessness. Keep access tokens short, rotate refresh tokens on every use, and keep a deny-list only if you need instant revocation. For browser apps, many teams keep tokens out of JavaScript entirely and use HttpOnly, Secure, SameSite cookies.
JWT checklist
- Strong secret (32+ random bytes) from a secret store, or asymmetric keys (RS256/ES256) when other services verify tokens.
- Pin algorithms; check
exp,issandaud. - Minimal claims – no personal data; authorise every request (role and ownership).
- Short access tokens, rotated refresh tokens, HTTPS everywhere.
Practice
Try it yourself
Add an ownership check: students may read
/api/users/:id/gradesonly for their own id; admins may read anyone's.Show solution
// inside the same server, after authenticate app.get('/api/users/:id/grades', authenticate, (req, res) => { const isOwner = req.user.id === req.params.id; if (!isOwner && req.user.role !== 'admin') { return res.status(404).json({ error: 'Not found' }); // 404 hides whether the user exists } res.json({ userId: req.params.id, grades: [] }); });Checking ownership on every object is the fix for the most common API vulnerability – Broken Object Level Authorisation (next lessons).
Quick check
Can a client read a JWT's payload?
Yes – it's only base64url-encoded. The signature prevents changes, not reading.
Why pin the algorithm when verifying?
To stop attackers switching to "none" or a weaker algorithm.
How do you log someone out with JWTs?
Revoke their refresh token server-side; keep access tokens short-lived.
Summary
- A JWT carries signed claims; verify signature, algorithm, expiry and issuer on every request.
- Authenticate with middleware; authorise with roles and ownership checks.
- Use short access tokens plus rotated refresh tokens; never put secrets in the payload.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.