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.
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
Know beforehand
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
- What the OWASP Top 10 is and how to read the 2025 edition
- A01: Broken Access Control
- A05: Injection — SQL and XSS
- A04 and A07: cryptography, passwords and sign-in
- A02, A03 and A08: configuration, dependencies and integrity
- A06, A09 and A10: design, logs and exceptional conditions
- How 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
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:
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-labnode:test testsCREATE 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');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)) };
}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.
// 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;
}Vulnerable suffix are wrong on purpose. The fix moves the ownership check into the SQL query itselfWhy 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.
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/);
}
});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:
// 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;
}Vulnerable suffix are wrong on purpose. In the fix the query text is constant and values go as parameters $1 and $2import 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"]);
});$ 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)OR 1=1 returned another manager's lead, UNION returned e-mails and password hashes from a different tableThe 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-postgresdocs 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:
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch],
);
}
// Fixed: HTML entity encoding for the HTML body context.
export function renderSearch(q) {
return `<p>Results for: ${escapeHtml(q)}</p>`;
}Vulnerable suffix are wrong on purpose. The fix encodes five characters as HTML entities — the OWASP cheat sheet rule for the HTML body context$ npm test
…
vulnerable: <p>Results for: <script>alert(document.cookie)</script></p>
fixed: <p>Results for: <script>alert(document.cookie)</script></p>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:
$ 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-OOZdoA04 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.
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);
}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 comparisonWhy 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.
// 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" };
}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$ 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- 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,SecureandSameSite. 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.randomnear 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:
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;
}Vulnerable suffix are wrong on purpose (the vulnerable branch). Headers follow the OWASP HTTP Headers cheat sheet$ 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"}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:
$ 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--audit-level=high returns a non-zero exit code, so CI fails; after npm audit fix lodash was upgraded to 4.18.1The --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.
curl -s https://cdn.jsdelivr.net/npm/dayjs@1.11.23/dayjs.min.js | openssl dgst -sha384 -binary | openssl base64 -A<!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>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.
// 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 };
}Vulnerable suffix are wrong on purpose. A business limit must live on the server, not only in the UIA09 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.
// 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 });
}Vulnerable suffix are wrong on purpose. The fix is a structured log: one JSON event per line, no passwordsA10, 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.
// 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();
};
}Vulnerable suffix are wrong on purpose. In Express 5 a rejected promise from middleware is passed to the error handler automatically$ 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"}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
$ npm test
…
ℹ tests 17
ℹ pass 17
ℹ fail 0Denial 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-byis off; security headers are set. npm ciandnpm auditin CI; the lock file is committed; third-party scripts carryintegrity.- 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.