Get listed

OWASP secure coding checklist: name the control (2026)

An open ledger with one blank coral line.

OWASP names a class of bug. Your job is the check in your stack that closes that class.

Broken access control still sits first because apps keep returning the next object when the id changes. A checklist is useful when each line maps to a test. It is theater when it maps to a slide.

The usual mistake is reprinting the Top 10 in a README and never adding the two-account test.

This page is the 2025 list as a working order, with links to the deeper SecureCoding guides for each class.

OWASP Top 10:2025. A01 Broken Access Control still sits first: 3.73 percent of tested apps had one or more of the 40 CWEs in that bucket.A printed category is not a control.

The OWASP project page for the Secure Coding Practices Quick Reference Guide says the project is archived and the checklists moved into the Developer Guide. v2.0.1 is still downloadable. That PDF is a kick-start, not a 2026 review. This page is the review: seven jobs, one named control each, and a sibling guide when the job needs more than a paragraph.

This is not the archived SCP dump

The EdgeScan 2020 chart at the top was a yearbook photo.

live Top 10:2025 introduction. Two categories are new or expanded: A03 Software Supply Chain Failures, and A10 Mishandling of Exceptional Conditions. A02 Security Misconfiguration moved to second. Injection is A05. Authentication Failures is A07. Logging and Alerting is A09. SSRF was folded into A01. That list is an awareness document. It does not tell Express what to put on Set-Cookie.

Use this page as the index. When a row needs code and a test, open the sibling. Do not paste the 17-page PDF into a ticket and close it.

The 2026 grid

One job, one control, one place to go deeper. The CSRF sibling is linked twice here and nowhere else. The other names are the rest of the set.

JobNamed controlSibling
HTML outEncode at the sink. Trusted Types if you can.XSS
Cookie POSTSec-Fetch-Site first. Fail closed.CSRF
Object idQuery userId and invoiceId together.IDOR
SQL, shell, MongoBind. Allowlist the identifier.Injection

Three more jobs sit on their own pages. A browser app defaults to a server row behind __Host-session, not a JWT. The header pack is CSP without unsafe-inline, HSTS, COOP, and a blank Referer on an authenticated host. Login UX, regenerate, and named MFA live on the authentication page. Open those three when the row is the session, the response headers, or the login handler.

One request. Seven checks. Skip a row and that sink is open.
BROWSER Cookie: __Host-session=sid
 Sec-Fetch-Site: same-origin
 |
SERVER 1 parse body against an allowlist
 2 look up sid, load userId
 3 authorize invoiceId for that userId
 4 bind SQL, never concatenate
 5 encode HTML at the sink
 |
PACK CSP nonce, HSTS, COOP
LOCKFILE npm ci, not a floating latest

HTML sinks and SQL binds

CWE-79 is the HTML or JavaScript sink. CWE-89 is the SQL interpreter. Validation of type and length shrinks the blast. It is not the primary fix. The XSS guide is encode-at-sink, Trusted Types after Firefox 148, and a CSP nonce. The injection guide is a bind parameter, an allowlist for ORDER BY, and a grep for literal, whereRaw, and queryRawUnsafe.

A comment field that may contain < is legal. The page that prints it must not treat it as markup. textContent or the template engine default is the control. A sanitizer is the leftover case when you truly need a subset of HTML.

// Express handler: bind the value, encode the name
const { rows } = await pool.query(
 "SELECT id, display_name FROM invoices WHERE id = $1 AND user_id = $2",
 [invoiceId, req.session.userId]
);
res.set("Content-Type", "text/html; charset=utf-8");
res.send(`<p>${escapeHtml(rows[0].display_name)}</p>`);

function escapeHtml(s) {
 return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

Identifiers stay invoiceId, userId, and display_name for the rest of this page. If the name later lands in an attribute or a javascript: URL, that is a different sink. Open the XSS page before you invent a second sanitizer.

A cookie-authenticated mutation is two bugs if you skip both checks. First, the browser attached __Host-session to a request the user did not mean. That is the CSRF page: read Sec-Fetch-Site, treat same-site like cross-site unless you listed every host, fall back to Origin then Referer, fail closed when both are missing. Second, a valid session asked for someone else’s invoiceId. That is the IDOR page. A UUID does not authorize the row.

const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);

function cookieMutationAllowed(req) {
 const site = req.get("sec-fetch-site");
 if (site === "same-origin" || site === "none") return true;
 if (!site) return false;
 return SAFE.has(req.method);
}

app.get("/invoices/:invoiceId", async (req, res) => {
 const { invoiceId } = req.params;
 const { userId } = req.session;
 const { rows } = await pool.query(
 "SELECT id, cents FROM invoices WHERE id = $1 AND user_id = $2",
 [invoiceId, userId]
 );
 if (!rows[0]) {
 res.status(404).send("Not found");
 return;
 }
 res.json(rows[0]);
});

express-session 1.19.0, last updated 22 January 2026, still defaults the cookie name to connect.sid and omits SameSite unless you set it. Name it __Host-session. httpOnly, secure, sameSite: "lax", path: "/", no Domain. Call req.session.regenerate after a password or passkey you trust. The session page is the cookie. This page only names it.

A signed blob is optional

A JWT is a claims set. RFC 7519 names the fields. RFC 8725 is the BCP. The format does not give you logout. Delete a Redis row and sid is junk. A signed blob lives until exp unless you also store a jti, which is a session store with extra steps.

Most first-party Express apps do not need jose. Reach for it when another origin must verify without your Redis. Then pin algorithms, aud, iss, and exp. jose 6.2.10 published on 21 August 2026. The JWT page is the verifier and the five-token 401 test. Do not park the blob in localStorage.

// Default for a browser app on one origin
app.use(session({
 name: "__Host-session",
 secret: process.env.SESSION_SECRET,
 store: redisStore,
 resave: false,
 saveUninitialized: false,
 cookie: {
 httpOnly: true,
 secure: true,
 sameSite: "lax",
 path: "/",
 maxAge: 12 * 60 * 60 * 1000,
 },
}));

Headers and the lockfile

helmet 8.3.0 still ships a default CSP that ends with style-src 'self' https: 'unsafe-inline'. As of the current docs, helmetjs.github.io. Treat app.use(helmet()) as a starter pack, then write a nonce per document and drop unsafe-inline on scripts and styles. HSTS max-age=31536000 with includeSubDomains. frame-ancestors 'none' unless you name a framer. COOP same-origin. Set Permissions-Policy yourself. The header pack page is that list. None of those lines read invoiceId.

A03 is the lockfile job. npm ci in CI. Pin versions. Review a new maintainer on a package that runs in the request path. The September 2025 chalk and debug incident is why this row is not "run npm audit and move on."

rg -n "helmet\\(\\)|unsafe-inline|connect\\.sid|Sequelize\\.literal|dangerouslySetInnerHTML" \
 --glob '!node_modules'

Fail closed, then log the event

A10:2025 is Mishandling of Exceptional Conditions. The official intro names improper error handling, logical errors, and failing open. A missing Sec-Fetch-Site on a cookie POST is 403, not 200. A missing userId on an invoice query is 401, not a scan of every row. A Zod failure is 400 with a generic body. Do not return the schema dump to a stranger.

A09 is logging and alerting. Log the auth result, the object id you refused, and the validation miss. Do not log password, Authorization, or a session secret. Alert on a burst of 401s from one account and on a 403 CSRF deny spike. Great logs with no page are a diary.

app.use((err, req, res, next) => {
 req.log.error({ err, route: req.path }, "handler failed");
 res.status(500).send("Unavailable");
});

That handler does not print err.stack to the browser. It does not attach req.body. Production stays boring. The stack stays in the log store you already restrict.

Prove the grid this week

You are not running a pentest. You are proving your own handlers.

  1. Log into your app. DevTools, Application, Cookies. Expect __Host-session, HttpOnly, Secure, Lax or Strict, Path /, no Domain.
  2. Copy as cURL a state-changing request you already make. Add Sec-Fetch-Site: cross-site. Expect 403.
  3. As user A, open user B’s invoiceId if you know one from a fixture. Expect 404 or 403, not 200 with B’s cents.
  4. curl a search box with a quote in the name. The SQL log should show a bind, not a concatenated string.
  5. View source on a profile that contains <em>. Expect escaped text, not italics, unless that field is the named HTML sink.
  6. Print package-lock.json for chalk and debug. Confirm CI runs npm ci.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/account/email" \
 -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
 -H "Sec-Fetch-Site: cross-site" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 --data "email=you@your-app.example"
# Expect: HTTP/2 403

Questions we keep getting

Can I still use the SCP PDF in onboarding?

Yes, as a vocabulary handout. Do not treat v2.0.1 as the review. The project page says archived. Pair a new hire with this grid and the sibling that matches their first ticket.

Does a green header scanner close A01?

No. A01 is the object. The scanner never logged in as two users. Use the IDOR page for the query, and this page for the rest of the grid.

Where does input validation sit on this grid?

On the server, before the interpreter: type, length, allowlist. Encode at the sink anyway. Client required is UX. The input-validation page is that split.