The Aroma Store API works, persists and validates, but right now anyone with curl can raise the price of a coffee, delete the catalogue or read every customer's orders. Today we close that door with the two questions every API must be able to answer: who are you and what may you do. They are different questions, they have different HTTP codes —401 and 403, which the contract has separated since 02-04— and they are solved by different mechanisms. We will implement customer registration with the password protected by bcrypt, the login that issues a signed JWT, the middleware that verifies it and tells a missing token from an expired one, Aroma Store's roles with their permission matrix, and resource-level authorisation, which is what genuinely stops Marta from reading another customer's orders.
Contents
- Authentication and authorisation are not the same thing
- An overview of authentication mechanisms
- Why a stateless REST API fits with tokens
- The customer model and its repository
- Passwords: why bcrypt and never plain text
- Registration:
POST /v1/customers - Login:
POST /v1/sessions - Anatomy of a JWT
- Claims and what must never go into the payload
- The authentication middleware
- A
401done properly:WWW-Authenticateand the catalogue's two codes - Role-based authorisation:
requireRole - Resource-level authorisation
403or404: when to hide existence- Aroma Store's permission matrix
- Expiry, refresh tokens and revocation
- Where the client stores the token
- Authentication and authorisation are not the same thing
| Authentication | Authorisation | |
|---|---|---|
| Question | Who are you? | May you do this? |
| Moment | First | Afterwards |
| HTTP code | 401 Unauthorized | 403 Forbidden |
| Mandatory header | WWW-Authenticate |
None |
| Fix for the client | Authenticate or renew the token | None: do not insist |
| Catalogue codes | not_authenticated, token_expired |
insufficient_permissions |
The most widespread confusion in real APIs is returning 401 where 403 belongs. The difference is operational, not academic:
401means "I do not know who you are, or I no longer believe you". The client can fix it: renew the token, log in again and retry. Retrying makes sense.403means "I know perfectly well who you are and you do not have permission". Retrying with the same token will always give the same result. Retrying makes no sense.
A well-written client automates its reaction to a 401 —renew and repeat— and shows the user a message on a 403. If you mix the codes, Aroma Store's SPA will fall into an infinite renewal loop when a permission is denied.
A historical footnote that confuses people: the official name of 401 in the RFC is Unauthorized, when it should be Unauthenticated. It is a naming error from 1997 that can no longer be corrected. Trust the semantics, not the name.
- An overview of authentication mechanisms
| Mechanism | How it travels | In favour | Against | Typical use |
|---|---|---|---|---|
| Basic | Authorization: Basic base64(user:password) |
Trivial to implement | Sends the password on every request; only acceptable over HTTPS and hardly even then | Internal tools, prototypes |
| API key | Own header or Authorization |
Simple, good for server-to-server | Does not identify a person, does not expire on its own, hard to rotate | Partner integrations |
| Cookie session | Cookie: session=abc, state on the server |
Immediate revocation, the browser manages it | Stateful, complicates scaling, exposed to CSRF | Classic web applications |
| JWT (Bearer) | Authorization: Bearer <token> |
Stateless, verifiable without querying the DB, carries claims | Cannot easily be revoked, the payload is readable | Modern REST APIs |
| OAuth 2.0 / OIDC | A bearer token issued by a third party | Delegated access, "sign in with Google" | Considerable complexity | Third-party access, SSO |
Aroma Store uses JWT for its own consumers (the SPA, Aroma Mobile, the internal panel) and API keys for the partner SwiftShip, which is a machine. OAuth 2.0 and OpenID Connect —delegated access for third parties and "sign in with…"— are covered in full in 04-03; here we only need to place them: OAuth does not replace what we are about to build, it adds a protocol on top so that somebody else issues the tokens.
- Why a stateless REST API fits with tokens
In 01-04 we set the statelessness constraint: every request contains everything needed to serve it, and the server keeps no session context between requests.
A cookie session breaks that: the server stores session_abc → customer cus_842 in memory or in Redis, and every request depends on that store. Consequences: with three instances behind a load balancer, either they share a session store or you need session affinity; and that store is a single point of failure.
A signed token inverts the model: the information travels with the request and the server only checks the signature. Any instance can serve it without consulting anything.
| Cookie session | Signed token | |
|---|---|---|
| Where the identity lives | On the server | In the token, on the client |
| Horizontal scaling | Requires a shared store | Immediate |
| Cost per request | A lookup in the store | Signature verification (microseconds) |
| Revocation | Immediate: it is deleted | Hard: the token remains valid |
| Fits with REST | So-so | Yes |
That "hard" on revocation is the genuine trade-off and we will deal with it in section 16. There is no free lunch.
- The customer model and its repository
The customers table has existed since the 001-initial.sql migration of 03-05, with email UNIQUE, password_hash and role. What we are missing is its repository:
// src/repositories/customers-sqlite.js
import { database } from '../config/database.js';
function toModel(row) {
if (!row) return undefined;
return {
id: row.id,
name: row.name,
email: row.email,
passwordHash: row.password_hash,
role: row.role,
createdAt: row.created_at,
};
}
const statements = {
byId: database.prepare('SELECT * FROM customers WHERE id = ?'),
byEmail: database.prepare('SELECT * FROM customers WHERE email = ?'),
insert: database.prepare(`
INSERT INTO customers (id, name, email, password_hash, role, created_at)
VALUES (@id, @name, @email, @passwordHash, @role, @createdAt)
`),
nextNumber: database.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 840) + 1 AS next FROM customers"
),
};
export const customerRepository = {
findById(id) {
return toModel(statements.byId.get(id));
},
findByEmail(email) {
// The email is ALWAYS normalised to lowercase, both when storing and when
// searching: '[email protected]' and '[email protected]' are one person.
return toModel(statements.byEmail.get(email.toLowerCase()));
},
create({ name, email, passwordHash, role = 'customer' }) {
const id = `cus_${statements.nextNumber.get().next}`;
statements.insert.run({
id,
name,
email: email.toLowerCase(),
passwordHash,
role,
createdAt: new Date().toISOString(),
});
return this.findById(id);
},
};And the mapper gains a function. Notice what does not appear:
// src/services/mappers.js (added)
/**
* Customer → public representation.
* passwordHash does NOT appear. Not on registration, not in the detail view,
* not in any collection. A leaked hash can be attacked without limit offline,
* and besides, nobody has any reason to see it.
*/
export function customerToRepresentation(customer) {
return {
id: customer.id,
name: customer.name,
email: customer.email,
role: customer.role,
createdAt: customer.createdAt,
_links: {
self: { href: `/v1/customers/${customer.id}` },
orders: { href: `/v1/customers/${customer.id}/orders` },
preferences: { href: `/v1/customers/${customer.id}/preferences` },
},
};
}Building the representation field by field, instead of doing { ...customer, passwordHash: undefined }, is what guarantees that a sensitive field added tomorrow does not leak by accident. An inclusion list, never an exclusion list.
- Passwords: why bcrypt and never plain text
The rules, in order of importance:
- The password is never stored. Not even encrypted: encryption is reversible, and whoever holds the key holds them all.
- A hash is stored, the output of an irreversible function.
- Not just any hash will do. MD5 and SHA-1 are broken. SHA-256 is not broken, but it is far too fast: a GPU computes billions per second and tries a whole dictionary in minutes.
- A password hashing function is used, designed to be slow with an adjustable cost: bcrypt, scrypt or Argon2.
bcrypt does two things that define it:
- Salt: it generates a random value per password and incorporates it into the hash. Two users with the same password get different hashes, which defeats precomputed tables (rainbow tables). The salt lives inside the resulting hash; there is no need to store it separately.
- Cost: a parameter (10 by default, that is 2¹⁰ = 1024 iterations) that can be raised over time as hardware improves.
$2b$10$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhW │ │ │ │ │ │ └── salt (22 chars) └── hash (31 chars) │ └── cost: 10 └── algorithm: 2b (bcrypt)
// src/services/authentication.js
import bcrypt from 'bcrypt';
/**
* bcrypt cost. 10 ≈ 60-100 ms per hash on ordinary 2026 hardware.
* A compromise: slow enough to hold back a brute-force attack, fast enough
* not to block the login. Each +1 DOUBLES the time.
*/
const BCRYPT_COST = 10;
/** Generates the hash of a password. Asynchronous: it does not block the loop. */
export async function hashPassword(password) {
return bcrypt.hash(password, BCRYPT_COST);
}
/**
* Checks a password against its hash.
* bcrypt extracts the salt and the cost from the hash itself, so it keeps
* working even if we raise BCRYPT_COST to 12 tomorrow.
*/
export async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}Two weighty nuances. bcrypt.compare runs in constant time: it takes the same however it turns out, so as not to leak information through the response time. And hashing is asynchronous on purpose: 80 ms of computation on the main thread would block every other request; bcrypt does it in Node's thread pool.
As a note about the future: Argon2 won the password hashing competition in 2015 and is OWASP's current recommendation for new projects, because on top of time it consumes memory, which penalises attackers with GPUs. bcrypt remains perfectly acceptable and is the most widespread and battle-tested option; that is why we use bcrypt.
- Registration:
POST /v1/customers
POST /v1/customers// src/schemas/customers.js
import { z } from 'zod';
export const registrationSchema = z
.object({
name: z.string().trim().min(2, 'The name needs at least 2 characters').max(120),
email: z.string().trim().toLowerCase().email('The email address is not in a valid format'),
password: z
.string()
.min(10, 'The password needs at least 10 characters')
.max(200, 'The password cannot exceed 200 characters'),
})
.strict();
export const loginSchema = z
.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(1),
})
.strict();About the 200-character maximum: bcrypt truncates at 72 bytes, so a generous but explicit limit avoids surprises and, above all, stops anyone from sending a 10 MB password to burn CPU. And about the minimum of 10 instead of rules of the "one uppercase, one digit and one symbol" kind: the modern NIST recommendation is to reward length rather than impose composition rules, which only produce Password1! and passwords written on sticky notes.
// src/services/authentication.js (continued)
import { customerRepository } from '../repositories/customers-sqlite.js';
export const authenticationService = {
/** Registers a customer. Returns {customer} or {error}. */
async register({ name, email, password }) {
if (customerRepository.findByEmail(email)) {
// 409: the conflict is with the current state, not with the format.
return { error: 'email_already_registered' };
}
const passwordHash = await hashPassword(password);
const customer = customerRepository.create({
name,
email,
passwordHash,
role: 'customer', // the role is NEVER chosen by whoever registers
});
return { customer };
},
};As happened with route_not_found in 03-02, email_already_registered is a new code that was not in the catalogue of 02-04. Adding it is legitimate —the catalogue only grows— and documenting it in openapi.yaml is mandatory. The 409 is the right code: the body was valid, what clashes is the current state of the system.
The line with the role is one of the most important in this lesson. registrationSchema is .strict() and does not include role, so a body containing "role": "administrator" gets 400 unknown_field. And even if the schema were lax, the service sets 'customer' literally. Two independent barriers against privilege escalation, because a single one always ends up failing.
// src/controllers/customers.js
import { authenticationService } from '../services/authentication.js';
import { customerToRepresentation } from '../services/mappers.js';
export const customerController = {
/** POST /v1/customers */
async register(req, res) {
const { customer, error } = await authenticationService.register(req.body);
if (error === 'email_already_registered') {
return res.status(409).json({
error: {
code: 'email_already_registered',
message: 'An account already exists with that email address.',
details: [],
},
});
}
res.set('Location', `/v1/customers/${customer.id}`);
res.status(201).json(customerToRepresentation(customer));
},
};// src/routes/customers.js
import { Router } from 'express';
import { customerController } from '../controllers/customers.js';
import { validate } from '../middleware/validation.js';
import { registrationSchema } from '../schemas/customers.js';
export const customerRoutes = Router();
// A PUBLIC route: you cannot demand registration in order to register.
customerRoutes.post('/', validate(registrationSchema), customerController.register);curl -i -s -X POST http://localhost:3000/v1/customers \
-H "Content-Type: application/json" \
-d '{"name":"Lucy Fenwick","email":"[email protected]","password":"light-roast-2026"}'HTTP/1.1 201 Created
Location: /v1/customers/cus_843
{"id":"cus_843","name":"Lucy Fenwick","email":"[email protected]","role":"customer","createdAt":"2026-03-14T10:28:00.000Z","_links":{...}}Not a trace of the password or the hash in the response, exactly as it should be.
Update the seed data. Marta's
cus_842was seeded in 03-05 with the text'pending-until-03-06'inpassword_hash, which is not a valid hash and will therefore never let anyone in. Replace it inmigrations/seed.jswith a real hash generated byawait hashPassword('speciality-coffee-2026'), and take the opportunity to seed acus_001with theemployeerole and acus_002with theadministratorrole: you will need them to test the permission matrix and for the tests of 03-08.
A note about async: this controller is asynchronous because bcrypt is. If it threw an exception, Express 4 would not catch it and the request would hang, exactly as we warned in 03-03. It works today because the service returns {error} instead of throwing; in 03-07 we will solve it properly with the asyncHandler() wrapper.
- Login:
POST /v1/sessions
POST /v1/sessionsLook at the URI: POST /v1/sessions, not /login. It is consistent with 02-02: signing in is creating a session resource, not invoking a verb. A DELETE /v1/sessions/current would be signing out, with the caveats of section 16.
// src/services/authentication.js (continued)
import jwt from 'jsonwebtoken';
import { environment } from '../config/environment.js';
/** Signs a JWT for a customer. */
export function issueToken(customer) {
return jwt.sign(
{
// Private claims: the minimum needed to authorise without querying the DB.
role: customer.role,
},
environment.jwtSecret,
{
subject: customer.id, // sub: who it identifies
expiresIn: environment.jwtExpiry, // exp: '1h' from .env
issuer: 'api.aromastore.example', // iss: who issued it
algorithm: 'HS256', // symmetric signature
}
);
}
/** Verifies credentials and returns a token, or null if they are not valid. */
export async function signIn({ email, password }) {
const customer = customerRepository.findByEmail(email);
if (!customer) {
// The same amount of time is spent as if it existed, so that the clock
// does not reveal which addresses are registered (enumeration attack).
await verifyPassword(password, '$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv');
return null;
}
const correct = await verifyPassword(password, customer.passwordHash);
if (!correct) return null;
return {
token: issueToken(customer),
expiresIn: environment.jwtExpiry,
customer,
};
}// src/controllers/sessions.js
import { signIn } from '../services/authentication.js';
import { customerToRepresentation } from '../services/mappers.js';
export const sessionController = {
/** POST /v1/sessions */
async create(req, res) {
const session = await signIn(req.body);
if (!session) {
// The SAME message for "no such email" and "wrong password".
res.set('WWW-Authenticate', 'Bearer realm="api.aromastore.example"');
return res.status(401).json({
error: {
code: 'not_authenticated',
message: 'The credentials are not correct.',
details: [],
},
});
}
res.status(201).json({
token: session.token,
type: 'Bearer',
expiresIn: session.expiresIn,
customer: customerToRepresentation(session.customer),
});
},
};// src/routes/sessions.js
import { Router } from 'express';
import { sessionController } from '../controllers/sessions.js';
import { validate } from '../middleware/validation.js';
import { loginSchema } from '../schemas/customers.js';
export const sessionRoutes = Router();
sessionRoutes.post('/', validate(loginSchema), sessionController.create);And in src/routes/index.js:
Why the same message in both failure cases. If we answered "that email is not registered" in one case and "wrong password" in the other, anyone could work out which addresses have an account with Aroma Store by trying them one by one. That is a leak of personal data with real value for fraud and phishing. The generic response costs a little convenience and more than makes up for it. For the same reason the service spends time comparing against a fake hash when the email does not exist: without that, a failure in 3 ms versus one in 90 ms would give the same thing away by another route.
curl -s -X POST http://localhost:3000/v1/sessions \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"speciality-coffee-2026"}' | jq{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjdXNfODQyIiwicm9sZSI6ImN1c3RvbWVyIiwiaXNzIjoiYXBpLmFyb21hc3RvcmUuZXhhbXBsZSIsImlhdCI6MTc3MzQ4NDIwMCwiZXhwIjoxNzczNDg3ODAwfQ.k3vQ2xR7fW1sPmT9aZbN4cE8hJdL0gYuXi6oV5rSqBw",
"type": "Bearer",
"expiresIn": "1h",
"customer": { "id": "cus_842", "name": "Marta García", "role": "customer", "...": "..." }
}
- Anatomy of a JWT
A JWT is three parts separated by dots, each encoded in base64url:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJjdXNfODQyIiwicm9sZSI6... . k3vQ2xR7fW1sPmT9aZbN4c... └────────── header ────────────────┘ └────────── payload ──────────┘ └────── signature ──────┘
Header, decoded:
Payload (the claims), decoded:
{
"sub": "cus_842",
"role": "customer",
"iss": "api.aromastore.example",
"iat": 1773484200,
"exp": 1773487800
}Signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret).
You can check it for yourself right now:
# The second part of the token, decoded. NO secret required.
echo "eyJzdWIiOiJjdXNfODQyIiwicm9sZSI6ImN1c3RvbWVyIiwiaXNzIjoiYXBpLmFyb21hc3RvcmUuZXhhbXBsZSIsImlhdCI6MTc3MzQ4NDIwMCwiZXhwIjoxNzczNDg3ODAwfQ" | base64 -d{"sub":"cus_842","role":"customer","iss":"api.aromastore.example","iat":1773484200,"exp":1773487800}This is the most misunderstood property of a JWT: the payload is NOT encrypted, only encoded. Base64 is not security; it is a way of writing bytes with printable characters. Anyone who intercepts the token can read its contents.
So what does the signature give us? Integrity and authenticity: it guarantees that the contents have not been modified and that it was issued by whoever holds the secret. If an attacker changes "role":"customer" to "role":"administrator", the signature no longer matches and jwt.verify rejects the token. They cannot recompute the signature because they do not know the secret.
About HS256: it is symmetric, a single secret serves both to sign and to verify. Perfect when the same system does both, as here. The alternative is RS256, asymmetric: you sign with the private key and verify with the public one, so other services can validate tokens without being able to issue them. That is what OAuth providers use (04-03).
And a classic security warning: there was a historical vulnerability in several libraries that accepted "alg": "none" —an unsigned token— because they trusted the algorithm declared in the token's own header. The defence is to fix the expected algorithm when verifying, which is why our code will pass algorithms: ['HS256'] explicitly.
- Claims and what must never go into the payload
Standard claims from RFC 7519:
| Claim | Name | Meaning | Do we use it? |
|---|---|---|---|
sub |
Subject | Who the token identifies | Yes: cus_842 |
iat |
Issued At | When it was issued | Yes, automatic |
exp |
Expiration | When it expires | Yes: mandatory |
iss |
Issuer | Who issued it | Yes |
aud |
Audience | Who it is for | No (a single API) |
nbf |
Not Before | Not valid before | No |
jti |
JWT ID | Unique identifier of the token | No (useful for revocation) |
And ours, role, which is a private claim.
The golden rule of the payload: it is public and it is immutable. From that the two lists follow.
What NEVER to put in:
| Do not put | Why |
|---|---|
| Passwords or their hash | Anybody can read them |
| Card numbers, national ID, address | Personal data readable by whoever intercepts the token |
| API keys or secrets | Likewise |
| Data that changes often | The token is not updated: it goes stale until it expires |
| Long lists of permissions | The token travels on every request; it bloats every header |
That fourth point has an important and rather counter-intuitive operational consequence: if an administrator demotes an employee to customer, their token keeps saying role: employee until it expires. With a one-hour expiry, that is up to a one-hour window. For critical operations, the answer is not to rely on the claim alone and to look up the real role in the database; we will come back to this when we talk about revocation.
What to put in: the minimum, stable and non-sensitive. sub, exp, iss and, at most, a role. A well-designed token takes 150-250 bytes.
- The authentication middleware
// src/middleware/authentication.js
import jwt from 'jsonwebtoken';
import { environment } from '../config/environment.js';
const CHALLENGE = 'Bearer realm="api.aromastore.example"';
/** A uniform 401 response, with the header RFC 9110 demands. */
function notAuthenticated(res, code, message) {
res.set('WWW-Authenticate', CHALLENGE);
return res.status(401).json({ error: { code, message, details: [] } });
}
/**
* Demands a valid JWT. If there is one, it leaves the identity in req.user
* and hands over the turn; if not, it answers 401 and cuts the chain.
*/
export function authenticate(req, res, next) {
const header = req.get('Authorization');
// --- 1. Missing or malformed token ---
if (!header || !header.startsWith('Bearer ')) {
return notAuthenticated(
res,
'not_authenticated',
'The Authorization header with a Bearer token is missing.'
);
}
const token = header.slice('Bearer '.length).trim();
try {
// --- 2. Verification of the signature and the expiry ---
const payload = jwt.verify(token, environment.jwtSecret, {
algorithms: ['HS256'], // NEVER trust the token's own 'alg'
issuer: 'api.aromastore.example',
});
// --- 3. Identity available to the rest of the chain ---
req.user = {
id: payload.sub,
role: payload.role ?? 'customer',
};
next();
} catch (error) {
// --- 4. Expired and invalid are DIFFERENT cases in the catalogue ---
if (error.name === 'TokenExpiredError') {
return notAuthenticated(
res,
'token_expired',
'The token has expired. Sign in again to obtain a new one.'
);
}
return notAuthenticated(res, 'not_authenticated', 'The token is not valid.');
}
}
/**
* Optional variant: if there is a valid token, it fills in req.user; if there
* is no token, it lets the request through anyway. Useful on GET /v1/coffees,
* which is public but can be personalised if we know who is asking.
*/
export function authenticateOptional(req, res, next) {
const header = req.get('Authorization');
if (!header) return next();
return authenticate(req, res, next);
}
- A
401 done properly: WWW-Authenticate and the catalogue's two codes
401 done properly: WWW-Authenticate and the catalogue's two codesA 401 must carry the WWW-Authenticate header; RFC 9110 requires it. Its job is to tell the client how to authenticate, and omitting it breaks the protocol as well as leaving the integrator with no clues.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.aromastore.example"
Content-Type: application/json; charset=utf-8
{"error":{"code":"not_authenticated","message":"The Authorization header with a Bearer token is missing.","details":[]}}# With an expired token
curl -s -H "Authorization: Bearer $OLD_TOKEN" http://localhost:3000/v1/orders | jq .error.codeWhy the catalogue distinguishes not_authenticated from token_expired. Both are 401, but the client's reaction is different: on token_expired, the SPA silently renews the token and repeats the request without bothering the user; on not_authenticated, it takes them to the sign-in screen. With a single code, the client would have to guess. It is a perfect example of why having a business code alongside the HTTP code is worthwhile (02-04).
Now we protect the routes. In src/routes/orders.js:
import { authenticate } from '../middleware/authentication.js';
// Every route in this router demands an identity. This use() with no path
// applies to EVERYTHING declared after it in the router.
orderRoutes.use(authenticate);
orderRoutes.get('/', validate(orderQuerySchema, 'query'), orderController.list);
orderRoutes.get('/:id', orderController.get);
- Role-based authorisation:
requireRole
requireRoleAroma Store has four roles:
| Role | Who | What they do |
|---|---|---|
customer |
Buyers | Buy, see their orders, write reviews |
employee |
Customer support | See every order, moderate reviews, manage shipments |
administrator |
Managers | All of the above plus managing the catalogue |
partner |
SwiftShip | Only update the shipment of the orders assigned to them |
// src/middleware/authentication.js (continued)
/**
* Demands that req.user holds one of the given roles.
* It is ALWAYS registered after authenticate: no identity, no permissions.
*/
export function requireRole(...allowedRoles) {
return (req, res, next) => {
// Safeguard: a missing req.user means the ordering is wrong.
if (!req.user) {
return notAuthenticated(res, 'not_authenticated', 'This operation requires authentication.');
}
if (!allowedRoles.includes(req.user.role)) {
// 403, not 401: we know who they are; they simply cannot.
return res.status(403).json({
error: {
code: 'insufficient_permissions',
message: `This operation requires one of these roles: ${allowedRoles.join(', ')}.`,
details: [],
},
});
}
next();
};
}And the coffee routes end up with their permission declaration on display:
// src/routes/coffees.js (the module's final version)
import { authenticate, requireRole } from '../middleware/authentication.js';
// PUBLIC reading: the catalogue is the shop window.
coffeeRoutes.get('/', validate(coffeeQuerySchema, 'query'), coffeeController.list);
coffeeRoutes.get('/:id', validate(coffeeIdParamsSchema, 'params'), coffeeController.get);
// Writing: employees and administrators only.
coffeeRoutes.post(
'/',
authenticate,
requireRole('employee', 'administrator'),
validate(createCoffeeSchema),
coffeeController.create
);
coffeeRoutes.put(
'/:id',
authenticate,
requireRole('employee', 'administrator'),
validate(coffeeIdParamsSchema, 'params'),
validate(replaceCoffeeSchema),
coffeeController.replace
);
coffeeRoutes.patch(
'/:id',
authenticate,
requireRole('employee', 'administrator'),
validate(coffeeIdParamsSchema, 'params'),
validate(modifyCoffeeSchema),
coffeeController.modify
);
// Removing from the catalogue: administrators only.
coffeeRoutes.delete(
'/:id',
authenticate,
requireRole('administrator'),
validate(coffeeIdParamsSchema, 'params'),
coffeeController.remove
);The order of the chain is mandatory: authenticate → requireRole → validate → controller. Authenticating before authorising is obvious; validating after checking permissions is less obvious and equally deliberate: there is no point spending cycles parsing the body of somebody who has no right to send it, and it also avoids leaking information about the expected shape of the resource to someone who should not know it.
- Resource-level authorisation
The role is not enough. Marta (cus_842) has the customer role and may consult orders… but only her own. No generic middleware can know that, because it depends on the data:
// src/services/orders.js (added)
export const orderService = {
/**
* Fetches an order while checking that the requester may see it.
* The check lives in the SERVICE because it needs the order loaded:
* only by querying the database can you tell whose it is.
*/
getFor(id, user) {
const order = orderRepository.findById(id);
if (!order) return { notFound: true };
const isOwner = order.customerId === user.id;
const isStaff = ['employee', 'administrator'].includes(user.role);
if (!isOwner && !isStaff) {
return { notFound: true }; // see section 14
}
return { order };
},
/** Lists orders, restricting to the customer's own unless they are staff. */
listFor(criteria, user) {
const isStaff = ['employee', 'administrator'].includes(user.role);
// A customer may NOT query another customer's orders even if they ask
// for them by query parameter: whatever they send is ignored and their
// own id is forced instead.
const customerId = isStaff ? criteria.customerId : user.id;
return orderRepository.findAll({ ...criteria, customerId });
},
};That last function deserves attention. If we simply passed req.query.customerId to the repository, any authenticated customer could read anybody else's orders with ?customerId=cus_999. That is the vulnerability known as IDOR (Insecure Direct Object Reference), and it has topped OWASP's list of API-specific risks for years precisely because it is invisible: the functional tests pass, the 401 works, the role is right… and other people's data leaks anyway.
The rule, worth writing into the team's style guide: the owner's identifier is never taken from the client's input; it is taken from the token.
403 or 404: when to hide existence
403 or 404: when to hide existenceWhen Marta requests GET /v1/orders/ord_9999, an order that exists but belongs to somebody else, there are two defensible answers:
| Answer | Advantage | Drawback |
|---|---|---|
403 insufficient_permissions |
Honest and easier to debug | It confirms that the order exists |
404 order_not_found |
Leaks nothing | Can bewilder a legitimate integrator |
A 403 leaking information is not a theoretical detail: it allows resources to be enumerated. By trying ord_5001, ord_5002, ord_5003… you can distinguish "it exists but is not yours" (403) from "it does not exist" (404), and thereby work out how many orders the shop has and how fast it is growing. That is competitive intelligence served on a plate, and in other domains (medical records, case files) the mere fact that a resource exists is already sensitive information.
Aroma Store's criterion:
404when the resource belongs to somebody else and the requester has no legitimate reason to know it exists. That is the case of other people's orders.403when the resource is clearly shared or public and what is missing is permission for an operation. Example: acustomerwho triesDELETE /v1/coffees/cof_001gets403, because the coffee is public and nobody doubts it exists.
What matters is choosing a criterion and documenting it. An API that returns 403 sometimes and 404 other times for the same kind of situation is impossible to integrate with.
- Aroma Store's permission matrix
| Endpoint | Public | customer |
employee |
administrator |
partner |
|---|---|---|---|---|---|
GET /v1/coffees |
✅ | ✅ | ✅ | ✅ | ✅ |
GET /v1/coffees/{id} |
✅ | ✅ | ✅ | ✅ | ✅ |
POST /v1/coffees |
❌ | ❌ | ✅ | ✅ | ❌ |
PUT/PATCH /v1/coffees/{id} |
❌ | ❌ | ✅ | ✅ | ❌ |
DELETE /v1/coffees/{id} |
❌ | ❌ | ❌ | ✅ | ❌ |
POST /v1/customers |
✅ | — | — | — | — |
POST /v1/sessions |
✅ | — | — | — | — |
GET /v1/customers/{id} |
❌ | Their own only | ✅ | ✅ | ❌ |
GET /v1/orders |
❌ | Their own only | ✅ | ✅ | Assigned only |
GET /v1/orders/{id} |
❌ | Their own only | ✅ | ✅ | Assigned only |
POST /v1/orders |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/orders/{id}/payment |
❌ | Their own only | ❌ | ✅ | ❌ |
PUT /v1/orders/{id}/shipment |
❌ | ❌ | ✅ | ✅ | ✅ |
POST /v1/coffees/{id}/reviews |
❌ | ✅ | ✅ | ✅ | ❌ |
POST /v1/reviews/{id}/approval |
❌ | ❌ | ✅ | ✅ | ❌ |
Two rows deserve comment. POST /v1/orders/{id}/payment cannot be done by an employee: nobody should be able to pay on somebody else's behalf, and restricting it is both an anti-fraud measure and a protection for the employee themselves. And PUT /v1/orders/{id}/shipment is the only thing the partner can touch, that partner being a SwiftShip machine: the principle of least privilege put into practice.
This table is not decorative documentation: it is the specification of the requireRole calls in the code, it is part of openapi.yaml (02-08), and in 03-08 it will become tests that check that every ❌ cell does in fact return 403.
- Expiry, refresh tokens and revocation
The problem, said plainly: a valid JWT cannot be invalidated. There is no server state to delete; as long as the signature matches and exp has not passed, the token is good. If somebody's token is stolen, the attacker is in until it expires. And "signing out" on the client only deletes the token from that device: the stolen copy keeps working.
The strategies, and their costs:
| Strategy | How it works | Cost |
|---|---|---|
| Short life | exp of 15 min to 1 h |
A small window of exposure; forces frequent renewal |
| Refresh token | A long-lived token (days) that only serves to request a new one | It has to be stored and be revocable |
| Revocation list | A table of invalidated jti, consulted on every request |
It reintroduces the state we wanted to avoid |
| Secret rotation | Rotate JWT_SECRET |
It invalidates every token at once |
The usual pattern combines the first two:
sequenceDiagram participant C as Client participant API C->>API: POST /v1/sessions (email + password) API-->>C: access token (1 h) + refresh (30 days) C->>API: GET /v1/orders with Bearer API-->>C: 200 OK Note over C,API: an hour goes by C->>API: GET /v1/orders with Bearer API-->>C: 401 token_expired C->>API: POST /v1/sessions/renewal (refresh) API-->>C: a new access token C->>API: GET /v1/orders (retry) API-->>C: 200 OK
The key to the split: the access token is short and stateless, so most requests consult nothing; the refresh token is long-lived but stored in the database, so it can be revoked —and it is used once an hour, not on every request. The state is concentrated where it barely costs anything.
For Aroma Store, the reasonable compromise is: a one-hour access token, a 30-day refresh token stored in the database and revocable, immediate revocation in three situations (password change, explicit sign-out, suspected theft) and, for critical operations —paying, changing the password— looking up the real role in the database instead of trusting the token's claim.
A design warning: do not fall for the temptation of querying the database on every request "for safety". If you do that, you have rebuilt stateful sessions while also paying the cost of JWTs. If your case demands universal immediate revocation, cookie sessions are a legitimate and simpler option; choose them deliberately.
- Where the client stores the token
| Place | XSS risk | CSRF risk | Note |
|---|---|---|---|
localStorage |
High: any script can read it | None | The most convenient and the most common |
sessionStorage |
High | None | Lost when the tab closes |
| A variable in memory | Low | None | Lost on reload |
HttpOnly + Secure + SameSite cookie |
Low: JavaScript cannot read it | Needs a defence | The safest option for browsers |
The recommendation for Aroma Store's SPA: an HttpOnly; Secure; SameSite=Strict cookie for the refresh token, and the access token in memory. That way an XSS attack cannot steal the long-lived session, which is the truly valuable part.
For Aroma Mobile, which is not a browser, the system's secure store is used (Keychain on iOS, Keystore on Android), never a plain preferences file.
And three rules that hold for any client: the token travels only over HTTPS (in the clear, anyone on the network can copy it); never in the URL (?token=... ends up in the server logs, in the browser history and in the Referer header); and it is never written to a log. All of this is expanded in 04-02.
Common Mistakes and Tips
1. Returning 403 where 401 belongs, or the other way round. The client cannot tell whether to renew the token or give up. Authentication is 401; permissions are 403.
2. Omitting WWW-Authenticate on the 401. The RFC requires it and without it the integrator does not know which scheme to use.
3. Storing passwords with SHA-256. Far too fast. Use bcrypt, scrypt or Argon2.
4. Trusting the alg in the token's header. Always pass algorithms: ['HS256'] to jwt.verify.
5. Putting personal or changing data in the payload. It is readable by anyone and it is not updated until it expires.
6. Accepting the role in the registration body. Immediate privilege escalation. The role is set by the server.
7. Leaking passwordHash in a response. Build the representation field by field; never return the internal model as it is.
8. Checking only the role and forgetting ownership. An authenticated customer with ?customerId=cus_999 must not see other people's orders. The owner's id comes from the token.
9. Different login messages depending on the failure. It allows registered addresses to be enumerated. One single generic message.
10. A JWT with no exp. An eternal token is a key that can never be changed.
Tip: write the permission matrix before programming the middleware, review it with somebody who knows the business, and turn it into tests (03-08). Authorisation holes almost always appear in the endpoints nobody thought to review.
Exercises
Exercise 1
Implement GET /v1/customers/:id with these rules: a customer may only see their own record; employee and administrator may see any of them; the partner may see none. Decide whether a customer requesting somebody else's record receives 403 or 404, justify the choice using the criterion of section 14, and write the route with its middleware chain.
Exercise 2
A developer proposes putting the full name, the email address, the shipping address and the list of the last five orders into the JWT payload, "so the SPA does not have to ask for them". List four concrete problems with that proposal and offer an alternative that solves their real need.
Exercise 3
Write the middleware requireOwnershipOrStaff(getOwnerId) generalising the check from section 13: it takes a function that, given req, returns the id of the resource's owner, and lets the request through if the requester is the owner or holds the employee/administrator role. Then explain why, despite being possible, it is not the best solution for orders and what is done instead.
Solutions
Solution 1
// src/controllers/customers.js (added)
get(req, res) {
const { id } = req.params;
const requester = req.user;
const isStaff = ['employee', 'administrator'].includes(requester.role);
const isSelf = requester.id === id;
if (!isStaff && !isSelf) {
// 404, not 403: we do not confirm that this customer exists.
return res.status(404).json({
error: {
code: 'customer_not_found',
message: `There is no customer with the identifier '${id}'.`,
details: [],
},
});
}
const customer = customerService.get(id);
if (!customer) {
return res.status(404).json({
error: {
code: 'customer_not_found',
message: `There is no customer with the identifier '${id}'.`,
details: [],
},
});
}
res.status(200).json(customerToRepresentation(customer));
},// src/routes/customers.js
customerRoutes.get(
'/:id',
authenticate,
requireRole('customer', 'employee', 'administrator'), // excludes 'partner'
customerController.get
);Why 404 and not 403: a customer's record contains personal data, and here existence itself is already information. With 403, anyone could try cus_800, cus_801, cus_802… and work out how many accounts Aroma Store has, how fast it is growing and what range the identifiers fall in. With a uniform 404, somebody else's customer is indistinguishable from a nonexistent one. Note that both branches return exactly the same body: if the message differed, the leak would come back in through the back door.
The partner is excluded by requireRole and receives 403: it is a machine with a clear contract that does not include customer data, so honesty here leaks nothing useful.
Solution 2
Four concrete problems:
- Leak of personal data. The payload is base64, not encrypted. Anyone who captures the token —a corporate proxy, a badly configured log, a browser extension— reads Marta's email address and shipping address. With the token in
localStorage, one XSS gets all of that in one go. - Stale data. If Marta changes her address, the token keeps stating the old one until it expires. And if the token lasted a month, the SPA would spend a month showing incorrect data that also appears to be blessed by the server.
- Size. Five orders with their items can be 2 KB. That token travels on every request, including image requests if those are protected. Many servers and proxies limit headers to 8 KB, and exceeding it produces a bewildering
431. It is wasted traffic on every call. - The wrong responsibility. The token is an identity credential, not a data cache. Mixing the two makes the SPA depend on the token's internal structure and means that any change to the customer's data forces you to touch credential issuance.
Alternative: the payload stays as sub, role, exp and iss, and the SPA obtains the data with one call to GET /v1/customers/{id} right after the login, caching it in its own state. The real need —avoiding repeated requests— is solved with HTTP caching on the client (04-06), not by stuffing data into the credential. Besides, the response of POST /v1/sessions already returns customer with the public representation, so in practice even that extra call is unnecessary.
Solution 3
// src/middleware/authentication.js (added)
/**
* Lets the request through if the requester owns the resource or is shop
* staff. 'getOwnerId' receives req and returns the owner's id, or
* undefined if the resource does not exist.
*/
export function requireOwnershipOrStaff(getOwnerId, notFoundCode) {
return (req, res, next) => {
if (!req.user) {
return notAuthenticated(res, 'not_authenticated', 'This operation requires authentication.');
}
if (['employee', 'administrator'].includes(req.user.role)) return next();
const ownerId = getOwnerId(req);
// A nonexistent resource and somebody else's resource give the SAME answer.
if (ownerId === undefined || ownerId !== req.user.id) {
// The specific catalogue code is supplied by whoever uses the
// middleware: that way there is no need to invent a generic
// 'resource_not_found'.
return res.status(404).json({
error: {
code: notFoundCode,
message: 'The requested resource does not exist.',
details: [],
},
});
}
next();
};
}
// Usage:
orderRoutes.get(
'/:id',
authenticate,
requireOwnershipOrStaff(
(req) => orderRepository.findById(req.params.id)?.customerId,
'order_not_found'
),
orderController.get
);Why it is not the best solution for orders, with three reasons:
- A duplicated query. The middleware loads the order to find out whose it is, and then the controller loads it again in order to respond. That is two queries for one request, and the pattern repeats on every endpoint protected this way.
- It does not work for collections.
GET /v1/ordershas no single owner: the results have to be filtered, not the whole request accepted or rejected. A middleware that can only say yes or no cannot express "only yours", and that is precisely the most frequent operation. - The rule is split across two places. Part of "who may see an order" lives in the route and part in the service, and when the policy changes —for instance, letting a
partnersee the orders assigned to them— you have to remember to touch both.
What is done instead: the check lives in the service, with getFor(id, user) and listFor(criteria, user), as we wrote in section 13. The service already has the order loaded, so there is no extra query; it can filter as well as reject; and the entire access policy for orders sits in a single file that can be tested without HTTP (03-08). The requireRole middleware remains useful for what genuinely depends only on the role —who may touch the catalogue— which is a decision that does not need to look at the data.
Conclusion
The API now knows who is calling and what they may do, and it says so with the precision the contract demands. Authentication is solved with JWTs signed using HS256: registration with the password protected by bcrypt with salt and an adjustable cost, login at POST /v1/sessions —a resource, not a verb— with a generic message so as not to reveal which addresses exist, and a middleware that verifies the signature while pinning the algorithm, fills in req.user and distinguishes the two cases the catalogue separates: not_authenticated when there is no token or it is invalid, token_expired when it has simply lapsed, both with WWW-Authenticate as the RFC requires. Authorisation works on two levels: requireRole for what depends only on the role, and the ownership check in the service for what depends on the data, with the owner's identifier taken always from the token and never from the client's input.
And you have seen what a JWT is not: the payload is encoded, not encrypted, so it is readable by anyone and admits neither personal data nor secrets; its claims are not updated until the token expires; and revoking it requires reintroducing state, which is exactly what the stateless model was trying to avoid. Hence the compromise: short, stateless access tokens, long-lived refresh tokens that are stored and revocable, and a database lookup only for critical operations.
One piece of technical debt can no longer be put off, and you have watched it grow with every lesson: the hand-written res.status(404).json({error: {...}}) calls are scattered across controllers, middleware and services, with the same body copied over and over; the services return {error: 'email_already_registered'} or {notFound: true} instead of failing cleanly; and now that there are async controllers thanks to bcrypt, an unexpected exception leaves the request hanging with no response. In 03-07, Error Handling, we unify all of it: the ApiError class with its factories, thrown from the services without them knowing anything about HTTP; the four-argument error middleware, registered last, that tells a catalogue error from an unexpected one and emits 500 internal_error with a traceId and without leaking the stack; the asyncHandler() wrapper that finally deals with rejected promises; and the translation of Zod, SQLite and malformed-JSON errors into the single error format the consumer knows.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
