LearnFrenzy
Sign in Take a free test

REST API Security · Lesson 16 of 30

JWT Authentication for REST APIs

6 min read Updated Nov 8, 2026 Saurabh Samir

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

  1. Header{"alg":"HS256","typ":"JWT"}
  2. Payload (claims){"sub":"u1","role":"admin","iat":…,"exp":…}
  3. 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'));

Real output of the client against the API

$ 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/me but 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

  1. Loginshort-lived access token (5–15 min) + long-lived refresh token (stored in the database)
  2. Callsthe access token is verified without a database lookup – fast and stateless
  3. Expiredthe client sends the refresh token to /auth/refresh and gets a new access token
  4. 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, iss and aud.
  • Minimal claims – no personal data; authorise every request (role and ownership).
  • Short access tokens, rotated refresh tokens, HTTPS everywhere.

Practice

Try it yourself

  1. Add an ownership check: students may read /api/users/:id/grades only 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.

Next lesson

Popular