
Secure coding practices are the checks you repeat: encode, parameterize, authorize the object, and test the fail path.
MITRE’s current Top 25 is a reading order, not a mystery. CWE-79, CWE-89, and CWE-352 stay high because those checks keep getting skipped on the new route.
The usual mistake is a poster of practices and a handler that still loads by id.
This page is the short list that belongs in a review, with links to the full guides.
MITRE’s 2025 CWE Top 25 still ranks CWE-79 first, CWE-89 second, and CWE-352 third.A standard is not a handler.
This page is the index of names. The long work already lives on four siblings: XSS, injection, IDOR, and the CSRF guide linked twice below. Use those when the sink is messy. Stay here when you only need the function to put in the ticket.
Slogans never grepped a sink
Then it praised situational awareness, warnings as errors, and bug bounty hunters. Those are culture notes. They do not tell Express what to put on Set-Cookie. They do not tell Postgres which columns belong in WHERE.
OWASP Top 10:2025 list. A01 is still Broken Access Control. A05 is Injection. A07 is Authentication Failures. A03 is Software Supply Chain Failures. That list is an awareness document. It does not name escapeHtml. It does not name queryInvoice.
A practice you can defend in review is a symbol the reviewer can search. If the symbol is missing, the practice is missing. Helmet 8.3.0, published 12 July 2026, writes response headers. It is not queryInvoice. It is not escapeHtml.
Four jobs, four function names
One job, one symbol, one sibling when the paragraph is not enough. Identifiers stay invoiceId, userId, and displayName for the rest of this page.
| Job | Function | Flagship |
|---|---|---|
| HTML out | escapeHtml | XSS |
| SQL value | queryInvoice | Injection |
| Object id | same query, both keys | IDOR |
| Cookie POST | csrfAllowed | CSRF |
BROWSER Cookie: __Host-session=sid Sec-Fetch-Site: same-origin | SERVER csrfAllowed(req) cookie POST gate load userId from sid queryInvoice(invoiceId, userId) escapeHtml(displayName) | SINK JSON or HTML, never raw markup
If you cannot point at those four symbols in the repo, you do not have four practices. You have a slide.
escapeHtml and queryInvoice
CWE-79 is untrusted data becoming markup. CWE-89 is untrusted data becoming SQL grammar. Type checks shrink the blast. They are not the sink fix. A comment may legally contain <em>. The page that prints it must treat that text as text. A legal integer still goes into SQL as $1, not as string add.
escapeHtml is the HTML-body writer. Attribute sinks, javascript: URLs, and CSS are different encodings. Those live on the XSS flagship. Do not invent a second sanitizer on this page. textContent or the template default is the same idea when the framework already escapes.
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
async function queryInvoice(invoiceId, userId) {
const { rows } = await pool.query(
"SELECT id, display_name, cents FROM invoices WHERE id = $1 AND user_id = $2",
[invoiceId, userId]
);
return rows[0] || null;
}
app.get("/invoices/:invoiceId", async (req, res) => {
const row = await queryInvoice(req.params.invoiceId, req.session.userId);
if (!row) {
res.status(404).send("Not found");
return;
}
res.set("Content-Type", "text/html; charset=utf-8");
res.send(`<p>${escapeHtml(row.display_name)}</p>`);
});
queryInvoice does two jobs on purpose. The bind stops CWE-89. The user_id column stops CWE-639. A UUID in the URL is not a lock. ORDER BY still cannot take a bind. Map a token such as cents to a column you wrote. The injection flagship is the hatch list: literal, whereRaw, queryRawUnsafe.
CVE-2026-24908 is the reminder. NVD published the record on 25 February 2026. AISLE’s writeup, dated 28 April 2026, said OpenEMR’s Patient REST API concatenated _sort into ORDER BY. CVSS 10.0. The miss was an identifier, the bind you cannot do. sortColumn is the named fallback for that gap.
const SORT = { cents: "cents", created: "created_at" };
function sortColumn(token) {
return SORT[token] || "created_at";
}
async function listInvoices(userId, token) {
const col = sortColumn(token);
const { rows } = await pool.query(
`SELECT id, display_name, cents FROM invoices WHERE user_id = $1 ORDER BY ${col} ASC`,
[userId]
);
return rows;
}
The template literal above interpolates col only after sortColumn returned a key you wrote. The client string never reaches SQL. If you cannot point at sortColumn next to every ORDER BY, you do not have that practice yet.
csrfAllowed then the scoped row
A cookie-authenticated mutation is two misses if you skip both symbols. First, the browser attached __Host-session to a request the user did not mean. That is CWE-352. MDN marks Sec-Fetch-Site Baseline widely available since March 2023. The browser sets it. Frontend script cannot. Second, a valid session asked for someone else’s invoiceId. That is the IDOR flagship. queryInvoice already scoped the row. Do not add a second lookup that drops userId.
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
function csrfAllowed(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.use((req, res, next) => {
if (SAFE.has(req.method)) return next();
if (csrfAllowed(req)) return next();
res.status(403).send("Forbidden");
});
When the header is missing, fail closed. The CSRF guide is the Origin then Referer fallback, the sibling-host case, and why csurf 1.11.0 is retired. Express TC deprecated that package on 16 May 2025. Do not install it to feel busy. csrfAllowed is the name this page will grep.
The Host prefix on the session cookie
express-session 1.19.0, updated 22 January 2026, still defaults the cookie name to connect.sid and omits SameSite unless you set it. That default is a slogan. The practice is the prefix and the flags.
const cookieOpts = {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
};
app.use(session({
name: "__Host-session",
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: cookieOpts,
}));
The __Host- prefix refuses Domain, requires Path=/, and requires Secure. A sibling host cannot plant that name. Call req.session.regenerate after a password or passkey you trust so the pre-login sid is junk. MemoryStore is the development default in express-session. The readme says do not use it in production. Redis or Postgres is the store that makes logout a delete.
A JWT waits until another origin must verify without your store. That verifier is a different flagship. Most first-party Express apps never need it. A blob in localStorage is a portable secret for the whole TTL. XSS reads it and leaves. An HttpOnly cookie cannot be read by script. The script can still call your API while the tab is open. That is an XSS problem, not a reason to move the secret into JavaScript storage.
A03:2025 is Software Supply Chain Failures. The practice here is a command, not a feeling. npm ci in CI. Pin the lockfile. Review a new maintainer on a package that runs in the request path. The September 2025 chalk and debug incident is why "run npm audit and move on" is not a name you can grep.
Grep the names this week
You are not running a pentest. You are proving the symbols exist and that a miss returns 403 or 404.
- Search the repo for
escapeHtml,textContent, or the template engine’s default escape. A rawinnerHTMLor string-built<p>without that call is the XSS ticket. - Search for
queryInvoiceor the sameWHERE id = $1 AND user_id = $2shape. A handler that loads byinvoiceIdalone is the IDOR ticket. - Search for
csrfAllowedorsec-fetch-site. Missing on a cookie POST is the CSRF ticket. - DevTools, Application, Cookies. Expect
__Host-session, HttpOnly, Secure, Lax or Strict, Path/, no Domain. - Copy as cURL a state-changing request you already make. Set
Sec-Fetch-Site: cross-site. Expect 403.
rg -n "innerHTML|dangerouslySetInnerHTML|Sequelize\\.literal|whereRaw|connect\\.sid|csrfAllowed|__Host-session" \
--glob '!node_modules'
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
A 200 on that replay means csrfAllowed never ran. A 200 on user B’s invoiceId while you are user A means queryInvoice dropped userId. Those are the tickets. The flagships are how you finish them.
Print package-lock.json for a request-path package you did not write. Confirm CI runs npm ci, not npm install that rewrites the lock. A floating caret on express or pg is a supply-chain hatch. It is not as loud as a missing escapeHtml. It is still a name you can fail the build on.
Questions we keep getting
Is helmet a practice on this list?
It is a header pack. Keep it. Do not count it as escapeHtml or queryInvoice. helmet 8.3.0 still ships a default CSP that includes unsafe-inline on styles. Treat app.use(helmet()) as a starter, then write a nonce. None of those lines authorize a row.
Where does "validate input" sit?
On the server, before the interpreter: type, length, allowlist. Then still call escapeHtml and queryInvoice. A Zod pass that concatenates displayName into SQL has not finished. Client required is UX.
Do I need a JWT to be modern?
No. A first-party browser app on one origin wants a server row you can delete. Prefer __Host-session with the flags above. Reach for a signed blob only when a peer host must verify without your store.



