How to buildOctober 11, 202613 min read
DevOps, Testing & SecurityStep 11 of 12

OWASP Top 10 for developers

In short

The OWASP Top 10 is OWASP's document on the ten most critical risk categories for web applications; the current published edition is from 2025. Broken access control is still number one, security misconfiguration has climbed to second, and new categories cover the software supply chain and the handling of exceptional conditions. Each category is a group of weaknesses (CWEs) closed by concrete techniques in code: server-side record ownership checks, parameterized queries, output encoding, slow password hashing and failing closed.

FundamentalsLevel: IntermediateApplication Security

After this you will be able to

  • name the OWASP Top 10:2025 categories and explain how the 2025 edition differs from 2021
  • give an example of vulnerable Node.js, Express or SQL code for each category and its fix
  • reproduce SQL injection and XSS on a local lab and prove with a test that parameterization and output encoding close them
  • review your project against a checklist and pick verification tools: tests, npm audit, DAST, ASVS

Verified with

OWASP Top 10:2025Node.js 24 LTS (24.18)Express 5.3pg 8.23PostgreSQL 17npm 11.16

Checked against docs: October 11, 2026

Contents
  1. 01What the OWASP Top 10 is and how to read the 2025 edition
  2. 02A01: Broken Access Control
  3. 03A05: Injection — SQL and XSS
  4. 04A04 and A07: cryptography, passwords and sign-in
  5. 05A02, A03 and A08: configuration, dependencies and integrity
  6. 06A06, A09 and A10: design, logs and exceptional conditions
  7. 07How to check your project: tools and checklist

What the OWASP Top 10 is and how to read the 2025 edition

OWASP (Open Worldwide Application Security Project) is a non-profit community that publishes open application security material. Its authors call the Top 10 a standard awareness document for developers: a consensus on the most critical risks, not an exhaustive list or a certification. owasp.org/Top10 leads to the 2025 edition, and the project page calls it the current released version. It is the eighth installment of the list.

Each category is a group of CWEs (Common Weakness Enumeration, a catalogue of weakness types): the 10 categories of the 2025 edition hold 248 CWEs. Eight categories were selected from testing data on more than 2.8 million applications, two from a practitioner survey, because data reflects the past and not everything can be found automatically. The authors aimed to name categories after the root cause rather than the symptom.

A01 Broken Access Control

In short
A user does what they have no right to do
What changed
Still first; SSRF (A10 in 2021) is merged into it

A02 Security Misconfiguration

In short
Unsafe defaults, extra features, verbose errors
What changed
Up from 5th place

A03 Software Supply Chain Failures

In short
Vulnerable and malicious dependencies, builds, CI/CD
What changed
Expansion of A06:2021 Vulnerable and Outdated Components

A04 Cryptographic Failures

In short
Weak algorithms, keys in code, passwords without a slow hash
What changed
Down from 2nd place

A05 Injection

In short
Input runs as code: SQL, OS commands, XSS
What changed
Down from 3rd place

A06 Insecure Design

In short
The protection was never designed
What changed
Down from 4th place

A07 Authentication Failures

In short
Password guessing, weak sessions, account enumeration
What changed
Same place, shorter name

A08 Software or Data Integrity Failures

In short
Unverified code and data are treated as trusted
What changed
Unchanged

A09 Security Logging and Alerting Failures

In short
Attacks are invisible in logs, or visible but nobody is told
What changed
“Monitoring” replaced by “alerting” in the name

A10 Mishandling of Exceptional Conditions

In short
An error leads to an open state or a leak
What changed
New category
OWASP Top 10:2025 and changes since 2021

Below, each category gets its essence, a “vulnerable code → fix” pair in Node.js 24, Express 5 and PostgreSQL 17, and a way to check it. Vulnerable and fixed functions sit side by side, and tests prove the difference. The lab runs in Docker:

bash
docker run -d --rm --name owasp-lab -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:17-alpine
docker exec owasp-lab psql -U postgres -c "CREATE DATABASE owasp_demo"
docker exec -i owasp-lab psql -U postgres -d owasp_demo < db/schema.sql

npm init -y && npm pkg set type=module
npm install express@5 pg@8
npm pkg set scripts.test="node --test --test-concurrency=1"
npm test

docker stop owasp-lab
Isolated lab: PostgreSQL 17 in a container on port 55432 and a project with node:test tests
db/schema.sql
CREATE TABLE users (
  id            integer PRIMARY KEY,
  email         text NOT NULL UNIQUE,
  role          text NOT NULL CHECK (role IN ('manager', 'admin')),
  password_hash text NOT NULL
);

CREATE TABLE leads (
  id         integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  owner_id   integer NOT NULL REFERENCES users (id),
  name       text NOT NULL,
  phone      text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO users VALUES
  (1, 'aigerim@example.com', 'manager', 'scrypt$demo-hash-1'),
  (2, 'daniyar@example.com', 'manager', 'scrypt$demo-hash-2'),
  (3, 'admin@example.com',   'admin',   'scrypt$demo-hash-3');

INSERT INTO leads (owner_id, name, phone) VALUES
  (1, 'Coffee shop on Abay', '+77010000001'),
  (1, 'Dental clinic',       '+77010000002'),
  (2, 'Beauty salon',        '+77010000003');
Schema and test data: two managers, an admin, three leads
support/http-db.js
import pg from "pg";

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL ?? "postgres://postgres:test@localhost:55432/owasp_demo",
});

export async function serve(app) {
  const server = await new Promise((resolve) => {
    const s = app.listen(0, () => resolve(s));
  });
  const base = `http://localhost:${server.address().port}`;
  return { base, close: () => new Promise((resolve) => server.close(resolve)) };
}
Test helper module: a connection pool and Express on a free port

A01: Broken Access Control

Access control keeps users within their permissions. Typical failures listed by OWASP: reading someone else's record by its id (IDOR — insecure direct object reference), POST, PUT and DELETE API endpoints open to everyone, checks only on the frontend, tampered JWTs or cookies, a wrong CORS setup, guessing admin URLs. The key rule from the recommendations: a check works only when it runs on the server, and everything except public resources is denied by default.

src/a01-access.js
// TRAINING EXAMPLE, VULNERABLE: any signed-in user can read any lead by id (IDOR).
export async function getLeadVulnerable(pool, user, id) {
  const { rows } = await pool.query("SELECT id, owner_id, name, phone FROM leads WHERE id = $1", [id]);
  return rows[0] ?? null;
}

// Fixed: ownership is part of the query; admins see everything.
export async function getLead(pool, user, id) {
  const { rows } = await pool.query(
    "SELECT id, owner_id, name, phone FROM leads WHERE id = $1 AND (owner_id = $2 OR $3 = 'admin')",
    [id, user.id, user.role],
  );
  return rows[0] ?? null;
}

// SSRF is part of A01 since 2025: the server fetches only allow-listed hosts.
const ALLOWED_HOSTS = new Set(["api.partner.example"]);

export function assertAllowedUrl(input) {
  const url = new URL(input);
  if (url.protocol !== "https:" || !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error(`URL not allowed: ${url.origin}`);
  }
  return url;
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. The fix moves the ownership check into the SQL query itself

Why a condition in the query instead of a check afterwards: you cannot “forget” to compare owner_id in one of the routes, and someone else's record is indistinguishable from a missing one — the server answers 404 and does not confirm that the id exists. Roles, permissions and the choice between 403 and 404 are covered in detail in the authorization lesson.

Since 2025 A01 also includes SSRF (server-side request forgery): at a user's request the server fetches a supplied URL and ends up inside the internal network — for example at the cloud metadata address 169.254.169.254. The OWASP SSRF cheat sheet recommends an allow-list of destinations whenever possible; assertAllowedUrl above does exactly that and compares the whole host name, not a substring.

test/a01-access.test.js
import assert from "node:assert/strict";
import { after, test } from "node:test";
import { assertAllowedUrl, getLead, getLeadVulnerable } from "../src/a01-access.js";
import { pool } from "../support/http-db.js";

after(() => pool.end());

const aigerim = { id: 1, role: "manager" };
const admin = { id: 3, role: "admin" };
const daniyarLeadId = 3;

test("IDOR: the vulnerable version returns someone else's lead", async () => {
  const lead = await getLeadVulnerable(pool, aigerim, daniyarLeadId);
  assert.equal(lead.name, "Beauty salon");
});

test("the fixed version hides other owners' leads but not from admins", async () => {
  assert.equal(await getLead(pool, aigerim, daniyarLeadId), null);
  assert.equal((await getLead(pool, aigerim, 1)).name, "Coffee shop on Abay");
  assert.equal((await getLead(pool, admin, daniyarLeadId)).name, "Beauty salon");
});

test("SSRF guard accepts only allow-listed https hosts", () => {
  assert.equal(assertAllowedUrl("https://api.partner.example/v1/rates").hostname, "api.partner.example");
  for (const bad of [
    "http://api.partner.example/v1",
    "https://169.254.169.254/latest/meta-data/",
    "https://api.partner.example.evil.test/",
    "file:///etc/passwd",
  ]) {
    assert.throws(() => assertAllowedUrl(bad), /not allowed/);
  }
});
A01 tests: manager Aigerim cannot see Daniyar's lead, the admin can; the SSRF filter rejects http, the metadata IP, a look-alike domain and file://

How to check: for every route with an id, a test “a second user gets 404/403”. OWASP explicitly recommends including functional access control checks in unit and integration tests and logging access control failures.

A05: Injection — SQL and XSS

Injection happens when user data reaches an interpreter — a database, a shell, a browser — and part of the data runs as a command. OWASP's defence is essentially one: keep data separate from commands. Let's start with SQL. The vulnerable function builds the query with a template string:

src/a05-injection.js
// TRAINING EXAMPLE, VULNERABLE: user input is concatenated into SQL text.
export async function searchLeadsVulnerable(pool, ownerId, q) {
  const sql = `SELECT id, name, phone FROM leads WHERE owner_id = ${ownerId} AND name ILIKE '%${q}%'`;
  const { rows } = await pool.query(sql);
  return rows;
}

// Fixed: the query text is constant, values travel separately as parameters.
export async function searchLeads(pool, ownerId, q) {
  const { rows } = await pool.query(
    "SELECT id, name, phone FROM leads WHERE owner_id = $1 AND name ILIKE '%' || $2 || '%'",
    [ownerId, q],
  );
  return rows;
}

// Column names cannot be parameters: pick them from an allow-list.
const SORTABLE = new Map([
  ["name", "name"],
  ["newest", "created_at DESC"],
]);

export async function listLeads(pool, ownerId, sort) {
  const orderBy = SORTABLE.get(sort) ?? "created_at DESC";
  const { rows } = await pool.query(`SELECT id, name FROM leads WHERE owner_id = $1 ORDER BY ${orderBy}`, [
    ownerId,
  ]);
  return rows;
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. In the fix the query text is constant and values go as parameters $1 and $2
test/a05-injection.test.js
import assert from "node:assert/strict";
import { after, test } from "node:test";
import { listLeads, searchLeads, searchLeadsVulnerable } from "../src/a05-injection.js";
import { pool } from "../support/http-db.js";

after(() => pool.end());

const OR_TRUE = "' OR 1=1 --";
const UNION = "' UNION SELECT id, email, password_hash FROM users --";

test("vulnerable search: OR 1=1 returns other managers' leads", async () => {
  const rows = await searchLeadsVulnerable(pool, 1, OR_TRUE);
  console.log("OR 1=1  →", rows.map((r) => r.name));
  assert.equal(rows.length, 3);
});

test("vulnerable search: UNION leaks the users table", async () => {
  const rows = await searchLeadsVulnerable(pool, 1, UNION);
  console.log("UNION   →", rows.map((r) => `${r.name} ${r.phone}`));
  assert.ok(rows.some((r) => r.name === "admin@example.com"));
});

test("parameterized search treats the payload as plain text", async () => {
  assert.deepEqual(await searchLeads(pool, 1, OR_TRUE), []);
  assert.deepEqual(await searchLeads(pool, 1, UNION), []);
  const rows = await searchLeads(pool, 1, "coffee");
  assert.deepEqual(rows.map((r) => r.name), ["Coffee shop on Abay"]);
});

test("sort column comes from an allow-list", async () => {
  const rows = await listLeads(pool, 1, "name; DROP TABLE leads --");
  assert.equal(rows.length, 2);
  const byName = await listLeads(pool, 1, "name");
  assert.deepEqual(byName.map((r) => r.name), ["Coffee shop on Abay", "Dental clinic"]);
});
Tests: two attacks on the vulnerable function and the same strings against the fixed one
terminal
$ npm test
…
OR 1=1  → [ 'Coffee shop on Abay', 'Dental clinic', 'Beauty salon' ]
✔ vulnerable search: OR 1=1 returns other managers' leads (29.0166ms)
UNION   → [
  'daniyar@example.com scrypt$demo-hash-2',
  'Dental clinic +77010000002',
  'Coffee shop on Abay +77010000001',
  'aigerim@example.com scrypt$demo-hash-1',
  'admin@example.com scrypt$demo-hash-3'
]
✔ vulnerable search: UNION leaks the users table (3.6767ms)
✔ parameterized search treats the payload as plain text (4.9012ms)
✔ sort column comes from an allow-list (3.4683ms)
Real output on PostgreSQL 17: OR 1=1 returned another manager's lead, UNION returned e-mails and password hashes from a different table

The string ' OR 1=1 -- closes the quote, adds an always-true condition and comments out the rest of the query — so manager #1 saw manager #2's lead. UNION SELECT goes further and appends rows from the users table. In the parameterized version the pg library sends the query text and the values separately, and PostgreSQL substitutes the values itself — the attack becomes an ordinary search for an odd string and finds zero leads.

  • Table and column names cannot be parameters: both OWASP and the node-postgres docs point this out. That is why the sort order is picked from an allow-list (SORTABLE), and anything else falls back to the default.
  • ORMs and query builders parameterize for you — until you use “raw” methods with string concatenation.
  • The same rule applies to OS commands: do not build a string for a shell, pass arguments as an array via execFile — according to the Node.js docs it does not spawn a shell by default.

XSS (cross-site scripting) is the same kind of injection, except the interpreter is the victim's browser. The server inserts input into HTML as is, and a <script> string from the URL runs on the page:

src/a05-xss.js
// TRAINING EXAMPLE, VULNERABLE: the query string is inserted into HTML as is.
export function renderSearchVulnerable(q) {
  return `<p>Results for: ${q}</p>`;
}

export function escapeHtml(value) {
  return String(value).replace(
    /[&<>"']/g,
    (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
  );
}

// Fixed: HTML entity encoding for the HTML body context.
export function renderSearch(q) {
  return `<p>Results for: ${escapeHtml(q)}</p>`;
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. The fix encodes five characters as HTML entities — the OWASP cheat sheet rule for the HTML body context
terminal
$ npm test
…
vulnerable: <p>Results for: <script>alert(document.cookie)</script></p>
fixed:      <p>Results for: &lt;script&gt;alert(document.cookie)&lt;/script&gt;</p>
Real server response before and after the fix

Encoding depends on context: HTML attributes, JavaScript, CSS and URLs have different rules. Template engines and React escape text automatically, but escape hatches — dangerouslySetInnerHTML, inserting HTML without a sanitizer — reopen the hole. The second line of defence is the Content-Security-Policy header. We checked both effects in Chromium with Playwright:

terminal
$ node support/browser-check.mjs   # Playwright 1.64 + Chromium
SRI original  → typeof dayjs: function
SRI tampered  → typeof dayjs: undefined
console error: Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js' with computed SHA-384 integrit
XSS, no CSP   → title: "pwned"
XSS, with CSP → title: ""
console error: Executing inline script violates the following Content Security Policy directive 'default-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-OOZdo
Real output of an auxiliary check script (its code is not shown: it only opens pages in Chromium and prints the result; console messages are cut to 160 characters). Without CSP the injected script changed the page title, with CSP the browser blocked the inline script. The first lines belong to A08 and SRI (see below)

A04 and A07: cryptography, passwords and sign-in

A04 covers mistakes in using cryptography: data sent without TLS, obsolete MD5 and SHA-1, keys in the repository, randomness from Math.random, passwords stored in plain text or with a fast hash. OWASP recommends TLS 1.2 or higher with HSTS, encrypting sensitive data at rest, keeping the most sensitive keys in a hardware or cloud-based HSM, and storing passwords only with salted adaptive functions: Argon2id, scrypt, yescrypt or PBKDF2. The OWASP cheat sheet keeps bcrypt for legacy systems.

src/a04-passwords.js
import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

// TRAINING EXAMPLE, VULNERABLE: a fast hash without salt.
export function hashPasswordVulnerable(password) {
  return createHash("sha256").update(password).digest("hex");
}

const scryptAsync = promisify(scrypt);
// OWASP minimum for scrypt: N = 2^17, r = 8, p = 1 (needs ~128 MiB, so raise maxmem).
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password, salt, 32, PARAMS);
  return ["scrypt", PARAMS.N, PARAMS.r, PARAMS.p, salt.toString("base64"), key.toString("base64")].join("$");
}

export async function verifyPassword(password, stored) {
  const [alg, N, r, p, salt, hash] = stored.split("$");
  if (alg !== "scrypt") return false;
  const expected = Buffer.from(hash, "base64");
  const options = { N: Number(N), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem };
  const actual = await scryptAsync(password, Buffer.from(salt, "base64"), expected.length, options);
  return timingSafeEqual(actual, expected);
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. The fix is Node.js's built-in scrypt with the OWASP cheat sheet parameters, a salt and a constant-time comparison

Why SHA-256 is bad specifically for passwords: it is fast, and equal passwords give equal hashes — a leaked table shows who shares a password, and guessing runs at enormous speed. scrypt with a random salt yields different hashes for the same password and is expensive in memory and time. The OWASP cheat sheet puts Argon2id first — in the authentication lesson we used it through the argon2 package; scrypt is for when Argon2id is not available, and it needs no dependencies.

A07 covers authentication failures: the system accepts an illegitimate user as legitimate. OWASP lists password guessing and credential stuffing (signing in with leaked login–password pairs), weak and default passwords, different messages for “no such user” and “wrong password”, a session id in the URL, and a session that does not change after sign-in or is not invalidated on logout.

src/a07-login.js
// TRAINING EXAMPLE, VULNERABLE: different messages reveal which emails exist,
// and nothing limits the number of attempts.
export async function loginVulnerable(users, email, password, verify) {
  const user = users.get(email);
  if (!user) return { ok: false, message: "User not found" };
  if (!(await verify(password, user.passwordHash))) return { ok: false, message: "Wrong password" };
  return { ok: true, user };
}

// Fixed: one message for every failure and a limit per email + IP.
export function createLoginLimiter({ max = 5, windowMs = 15 * 60 * 1000 } = {}) {
  const attempts = new Map();
  return {
    isBlocked(key, now = Date.now()) {
      const entry = attempts.get(key);
      return Boolean(entry && now - entry.first < windowMs && entry.count >= max);
    },
    fail(key, now = Date.now()) {
      const entry = attempts.get(key);
      if (!entry || now - entry.first >= windowMs) attempts.set(key, { first: now, count: 1 });
      else entry.count += 1;
    },
    reset(key) {
      attempts.delete(key);
    },
  };
}

export async function login(users, limiter, { email, password, ip }, { verify, dummyHash }) {
  const key = `${email}|${ip}`;
  if (limiter.isBlocked(key)) return { ok: false, status: 429, message: "Too many attempts, try later" };
  const user = users.get(email);
  // Hash even for unknown emails, so response time does not reveal them either.
  const valid = await verify(password, user?.passwordHash ?? dummyHash);
  if (!user || !valid) return limitFail(limiter, key);
  limiter.reset(key);
  return { ok: true, status: 200, user };
}

function limitFail(limiter, key) {
  limiter.fail(key);
  return { ok: false, status: 401, message: "Invalid email or password" };
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. The fix: one message for every failure, hashing even for an unknown e-mail, an attempt limit per e-mail + IP pair
terminal
$ npm test
…
sha256: daaad6e5604e8e17bd9f108d91e26afe6281dac8fda0091040a7a6d7bd9b43b5
scrypt (232 ms): scrypt$131072$8$1$HBwGieqeUUj/t2wm2qaA+g==$5XlbvmafNblQXaR1yBaI45Zp2BSKDv/fOjsPCpOHDYk=
vulnerable: User not found / Wrong password
fixed: Invalid email or password / Invalid email or password
next attempts: 401, 401, 429
Real output of the A04 and A07 tests
  • The in-process limit is for training: with several app instances keep the counters in Redis or the database. OWASP warns that limiting must not turn into a denial of service for the real user.
  • MFA (multi-factor authentication) comes first in OWASP's recommendations: it protects against guessing, credential stuffing and reuse of stolen passwords.
  • Issue a new session id after sign-in, and set cookies with HttpOnly, Secure and SameSite. This, together with an Express sign-in using Argon2id, is shown in the lesson “Authentication: sessions, cookies and JWT”.
  • How to check: a test that an unknown e-mail and a wrong password get the same response, a test for 429 after N attempts, and a code search for createHash("md5", sha1, Math.random near tokens and passwords.

A02, A03 and A08: configuration, dependencies and integrity

A02 is security misconfiguration: unnecessary features and pages enabled, default accounts unchanged, stack traces sent to the client, no security headers. The Express docs remind you that the built-in error handler sends the stack to the client unless NODE_ENV is production. It is safer not to rely on a variable and install your own handler:

src/app.js
import { randomUUID } from "node:crypto";
import express from "express";
import { renderSearch, renderSearchVulnerable } from "./a05-xss.js";

export function createApp({ log, vulnerable = false }) {
  const app = express();
  app.disable("x-powered-by");

  app.use((req, res, next) => {
    res.set({
      "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
      "X-Content-Type-Options": "nosniff",
      "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
      "Referrer-Policy": "strict-origin-when-cross-origin",
    });
    next();
  });

  app.get("/search", (req, res) => {
    const q = String(req.query.q ?? "");
    res.type("html").send(vulnerable ? renderSearchVulnerable(q) : renderSearch(q));
  });

  app.get("/boom", () => {
    throw new Error("relation \"leads_archive\" does not exist");
  });

  if (vulnerable) {
    // TRAINING EXAMPLE, VULNERABLE: the stack trace goes to the client.
    app.use((err, req, res, _next) => res.status(500).send(err.stack));
  } else {
    // Fixed: details go to the log, the client gets an id to quote to support.
    app.use((err, req, res, _next) => {
      const errorId = randomUUID();
      log.error({ event: "http.unhandled_error", errorId, path: req.path, message: err.message });
      res.status(500).json({ error: "internal_error", errorId });
    });
  }
  return app;
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose (the vulnerable branch). Headers follow the OWASP HTTP Headers cheat sheet
terminal
$ npm test
…
vulnerable 500: Error: relation "leads_archive" does not exist |     at file:///C:/Users/…/owasp-top-10/src/app.js:25:11
fixed 500: {"error":"internal_error","errorId":"359acb48-a504-4c85-b5e8-7257d721f848"}
Real output: the vulnerable version discloses the database error text and the file path on the server, the fixed one only an id for support

Instead of setting headers by hand, Express apps usually add the Helmet middleware — the Express security best practices guide recommends it. How to check A02: request a missing route and a failing route — the response must contain no stack and no versions; test the headers; configure environments from code, not by hand.

A03 is software supply chain failures: vulnerable, abandoned or malicious dependencies, insecure builds and CI/CD. In the category description OWASP cites the self-propagating npm worm Shai-Hulud from 2025: infected versions of popular packages used a post-install script to steal data and npm tokens. Basic measures: a lock file and npm ci in CI, regular npm audit, tracking transitive dependencies (SBOM), upgrading when needed rather than “everything at once”. Here is a real check of a project with an old lodash version:

terminal
$ npm install lodash@4.17.20
$ npm audit --audit-level=high
# npm audit report

lodash  <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc
lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rh
Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg
fix available via `npm audit fix`
node_modules/lodash

1 high severity vulnerability

To address all issues, run:
  npm audit fix

$ npm audit fix
$ npm audit --audit-level=high
found 0 vulnerabilities
Real npm 11.16 output: --audit-level=high returns a non-zero exit code, so CI fails; after npm audit fix lodash was upgraded to 4.18.1

The --ignore-scripts flag (or the ignore-scripts=true setting) stops packages from running their install scripts — this closes the path used by attacks like Shai-Hulud, but breaks packages that need a script to build, so enable it deliberately.

A08 is integrity failures: code or data from an untrusted source is treated as trusted. OWASP's examples: scripts from third-party CDNs without verification, unsigned auto-updates, CI pulling unverified artifacts, and insecure deserialization of data the client can modify. For CDN scripts there is Subresource Integrity: the browser compares the file's hash with the integrity attribute.

bash
curl -s https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A
The hash for the integrity attribute — the command from the MDN docs
public/sri.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SRI demo</title>
    <script
      src="https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js"
      integrity="sha384-1Ft4/JTNbt7S/44V0himpcjx+YfQ1a6W6SYBJMr94ba6jwZhWoWoLaZsMmtJILgq"
      crossorigin="anonymous"
    ></script>
  </head>
  <body></body>
</html>
Loading from a third-party CDN with an integrity check

The browser check is in the output above: with the correct hash dayjs loaded, with a modified one Chromium blocked the file and reported in the console that no valid digest was found. The same rule applies to data: do not trust anything that came from the client unsigned — for example, a role in an unsigned cookie.

A06, A09 and A10: design, logs and exceptional conditions

A06 is insecure design: the needed protection does not exist at all, and a perfect implementation cannot fix that. In OWASP's example a cinema gives a group discount for up to 15 seats without a deposit, and an attacker books 600 seats in all cinemas in a few requests. The cure happens at design time: threat modeling for sign-in, access and business logic, misuse cases next to user stories, and plausibility checks at every tier.

src/a06-booking.js
// TRAINING EXAMPLE, VULNERABLE: the business limit exists only in the UI.
export function parseBookingVulnerable(body) {
  return { seats: body.seats };
}

// Fixed: the rule "at most 15 seats without a deposit" lives on the server.
export const MAX_SEATS_WITHOUT_DEPOSIT = 15;

export function parseBooking(body) {
  const seats = Number(body?.seats);
  if (!Number.isInteger(seats) || seats < 1) return { ok: false, error: "invalid_seats" };
  if (seats > MAX_SEATS_WITHOUT_DEPOSIT) return { ok: false, error: "deposit_required" };
  return { ok: true, seats };
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. A business limit must live on the server, not only in the UI

A09 is security logging and alerting failures: failed logins and access denials are not logged, logs are not protected from tampering, and even when a suspicious event is recorded nobody hears about it. OWASP stresses that great logging without alerting is of minimal value. The logs themselves are a risk too: secrets and personal data in them, and forged lines injected through line breaks in input.

src/a09-logging.js
// TRAINING EXAMPLE, VULNERABLE: string logs let input forge new lines, and the password leaks.
export function logFailedLoginVulnerable(write, { email, password }) {
  write(`WARN login failed for ${email} with password ${password}\n`);
}

// Fixed: one JSON object per line, no secrets; JSON.stringify escapes line breaks.
export function createLogger(write) {
  const entry = (level) => (fields) =>
    write(JSON.stringify({ time: new Date().toISOString(), level, ...fields }) + "\n");
  return { info: entry("info"), warn: entry("warn"), error: entry("error") };
}

export function logFailedLogin(log, { email, ip }) {
  log.warn({ event: "auth.login_failed", email, ip });
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. The fix is a structured log: one JSON event per line, no passwords

A10, new in 2025, is mishandling of exceptional conditions. When an error is swallowed, the system ends up in an unpredictable state, and the most dangerous outcome is failing open: the check broke, but the action went through. OWASP demands the opposite — fail closed: roll back the whole transaction on error, log the event and have a global error handler.

src/a10-errors.js
// TRAINING EXAMPLE, VULNERABLE: if the ownership check throws, the request goes through (fail open).
export function requireOwnerVulnerable(isOwner) {
  return async (req, res, next) => {
    try {
      if (!(await isOwner(req.user.id, req.params.id))) return res.status(404).end();
    } catch (err) {
      console.error(err);
    }
    next();
  };
}

// Fixed (fail closed): no swallowed errors. In Express 5 a rejected async middleware
// goes straight to the error handler, and the route below never runs.
export function requireOwner(isOwner) {
  return async (req, res, next) => {
    if (!(await isOwner(req.user.id, req.params.id))) return res.status(404).end();
    next();
  };
}
Training vulnerable code: functions with the Vulnerable suffix are wrong on purpose. In Express 5 a rejected promise from middleware is passed to the error handler automatically
terminal
$ npm test
…
WARN login failed for x@example.com
INFO login ok for admin@example.com with password hunter2
{"time":"2026-10-12T01:44:45.765Z","level":"warn","event":"auth.login_failed","email":"x@example.com\nINFO login ok for admin@example.com","ip":"10.0.0.1"}
vulnerable: 200 {"deleted":"3"} | fixed: 500 {"error":"internal_error"}
Real output: the string log got a forged “INFO login ok” line and the password; the JSON log kept the line break inside the field. With the database “down”, the vulnerable middleware let the deletion through, the fixed one returned 500

How to check: for A06, tests that abuse business rules (negative and huge quantities, repeated requests); for A09, a test that a failed login produces an event without the password, plus an alert rule for spikes of such events; for A10, tests with a failing dependency substituted — database, external API, timeout. An error must result in a denial, not in success.

How to check your project: tools and checklist

terminal
$ npm test
…
ℹ tests 17
ℹ pass 17
ℹ fail 0
Lab run summary: 17 tests, every “vulnerable → fixed” pair confirmed

Denial and misuse tests

What it finds
A01, A06, A07, A10: other users' records, limits, failing closed
When
On every pull request

npm audit --audit-level=high, SBOM

What it finds
A03: known vulnerabilities in dependencies
When
In CI and on a schedule

SAST and code review

What it finds
A04, A05: concatenated SQL, weak hashes, secrets in code
When
On every pull request

DAST (for example OWASP ZAP)

What it finds
A02, A05: headers, errors, reflected input
When
On a test environment before release

ASVS 5.0.0

What it finds
A full list of requirements by level
When
During design and audits
  • Every route with an id checks ownership on the server; everything non-public is denied by default.
  • Outgoing requests to user-supplied URLs go only to allow-listed hosts.
  • All SQL queries are parameterized; column names come from an allow-list.
  • Output is encoded for its context; Content-Security-Policy is enabled.
  • Passwords use Argon2id or scrypt with a salt; secrets are not in the repository; HTTPS and HSTS everywhere.
  • One message for failed sign-in, an attempt limit, a new session after sign-in, MFA for admins.
  • Clients never see stacks or versions; x-powered-by is off; security headers are set.
  • npm ci and npm audit in CI; the lock file is committed; third-party scripts carry integrity.
  • Business limits are checked on the server; sign-in, access and payments have a threat model.
  • Failed sign-ins and access denials are logged in a structured way without secrets; alerts exist.
  • A failing check or dependency results in a denial and a transaction rollback, not in success.

Next: storing secrets and environment variables, integration security with webhook signatures, and a separate topic — LLM application security: OWASP covers prompt injection in its own list for LLMs.

How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.