
Defense in depth is a second control that still holds when the first one misses, not a pile of products.
If the ORM escape hatch concatenates SQL, a WAF may catch a textbook payload and miss the next one. Parameterization is the depth that matters. A second vendor logo is not.
The usual mistake is adding a tool for every OWASP category and leaving the handler that loads by id unchecked.
This page is which layers actually stack, and the incidents where the second layer was missing when the first failed.
CVE-2026-24908 is a CVSS 10.0 in OpenEMR’s Patient REST API. AISLE published the set on 28 April 2026 and said the product sits in front of more than 100,000 providers. _sort was concatenated into ORDER BY. A moat slide would have called the edge a layer. The interpreter still ran the string.
This page is four checks on exportInvoice that do not share a failure mode. Pair it with the injection guide for the interpreter, the IDOR guide for the object rule, and the secure coding checklist for the control name you write on the ticket.
A castle story is not a handler
Depth means two independent controls must fail before the sink honors a hostile request. Independent means they do not share a miss. A login cookie and a WAF that both trust the same session are one story with two logos.
Security Stack Exchange question 514 and the frankodwyer answer dated 2011. The useful sentence is the one that refuses a laundry list of firewalls and ciphers. Two or more independent controls have to fail. If only one of them needs to fail before you have lost, you never had depth.
NIST SP 800-27 Rev A is the document the old cite quoted. CSRC page. That publication is from June 2004 and is withdrawn. Do not put Principle 16 on a 2026 design note. Write the four checks. Name the helper. Name the deny.
IBM’s 30 July 2025 newsroom note on the Cost of a Data Breach Report put the 2025 global average at $4.44 million, down from $4.88 million in 2024. that note. A dollar figure is not a layer. Spend the hour on exportInvoice, not on reprinting the press line.
Unsure means 403, not next()
Fail closed is the only way a layer counts. If the check cannot see the header, the actor, or the row, it denies. Fail open is how a WAF in detect mode plus a missing Origin check becomes a slide that still returns 200.
MDN marks Sec-Fetch-Site Baseline widely available since March 2023. The browser sets it. Frontend script cannot. Values are same-origin, same-site, cross-site, and none. Treat same-site like cross-site on a mutating cookie route unless you have listed every hostname on the eTLD+1. When the header is absent, do not skip the check. Run the Origin then Referer fallback, and deny if both are missing.
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
const ALLOWED = new Set([process.env.APP_ORIGIN]);
function originAllowed(req) {
const origin = req.get("origin");
if (origin) return ALLOWED.has(origin);
const referer = req.get("referer");
if (!referer) return false;
try {
return ALLOWED.has(new URL(referer).origin);
} catch (err) {
return false;
}
}
function csrfAllowed(req) {
const site = req.get("sec-fetch-site");
if (site === "same-origin" || site === "none") return true;
if (site === "cross-site" || site === "same-site") {
return SAFE.has(req.method);
}
return false;
}
function failClosed(req, res, next) {
if (SAFE.has(req.method)) return next();
res.set("Vary", "Sec-Fetch-Site, Sec-Fetch-Mode");
const site = req.get("sec-fetch-site");
if (site) {
if (csrfAllowed(req)) return next();
res.status(403).send("Forbidden");
return;
}
if (originAllowed(req)) return next();
res.status(403).send("Forbidden");
}
originAllowed is the named fallback. It is a code path, not a comment. Native jobs that must pass send Sec-Fetch-Site: none on purpose. They do not ask you to delete the middleware. The CSRF guide is the longer form of this header. This page only insists the miss is a deny.
Four independent checks on one export
On invoice-app the export is GET /invoices/:invoiceId/export.csv. Four checks, four failure modes. A miss on one does not excuse a miss on the next.
| Layer | Symbol | Deny |
|---|---|---|
| Site | failClosed | 403 if header missing or foreign |
| Session | requireUser | 401 if no row for the cookie |
| Object | canInvoice | 404 if Bob is not the owner |
| Sink | queryInvoice | bind $1, never concat _sort |
Site and session are different questions. One asks where the browser said the request came from. The other asks whether you minted a session. Object is a third question: does this actor own this row. Sink is a fourth: does the interpreter see grammar from the client. If you skip object because session passed, Bob reads Alice. If you skip sink because object passed, a sort token becomes SQL.
REQ GET /invoices/:id/export.csv Cookie __Host-session | v SITE failClosed missing header -> 403 cross-site POST -> 403 | v AUTH requireUser no session row -> 401 | v OBJ canInvoice(actor, invoice) miss -> 404 | v SINK queryInvoice $1 literal -> job red
GET on an export is a state read that still leaks cents. Lax will attach the cookie. That is why object and sink still run on GET. Do not reserve failClosed for POST only and then ship a CSV on GET with no canInvoice.
A webhook is a fifth hatch with a different actor. failClosed does not apply. The independent check is verifyBillingHmac on the raw body, then canInvoice if the payload names an invoice. A queue worker is the same shape: no browser header, still an object rule. If you skip the helper because “it is not HTTP,” Bob’s cents leave through SQS. Depth is the helper at every entry, not a longer perimeter list.
Login plus a WAF is still one story
The usual slide is perimeter, identity, application, data. The usual ship is a Cloudflare rule, requireUser, and a query that takes req.params.invoiceId with no owner clause. That is one story. The perimeter never saw the object. Identity never saw the row. Data trusted the id.
OpenEMR’s CVSS 10.0 was an identifier in ORDER BY. A bind cannot hold a column name. Map the token to a column you wrote. A WAF that blocks ' OR 1=1 will not block invoice_cents if that is a legal identifier. The sink layer is the allowlist, not the edge regex.
Physical locks and CCTV belong on the office. They are not a fourth check on this handler.That mix is how the slogan ate the page. Keep building security on the building ticket. Keep canInvoice on the export ticket.
The deny path on invoice-app
Identifiers stay failClosed, requireUser, canInvoice, and queryInvoice. The handler calls them in that order. A skip is a bug, not a performance win.
// src/routes/invoice.js
async function exportInvoice(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
const invoice = await queryInvoice(req.params.invoiceId);
if (!invoice || !canInvoice(actor, invoice)) {
return res.status(404).end();
}
const body = await renderCsv(invoice.id);
res.set("Content-Type", "text/csv");
return res.send(body);
}
function requireUser(req) {
return req.session && req.session.userId
? { userId: req.session.userId, role: req.session.role }
: null;
}
function canInvoice(actor, invoice) {
if (actor.role === "admin") return true;
return invoice.ownerId === actor.userId;
}
// src/db/queryInvoice.js
async function queryInvoice(invoiceId) {
const { rows } = await pool.query(
"SELECT id, owner_id AS \"ownerId\", cents FROM invoices WHERE id = $1",
[invoiceId]
);
return rows[0] || null;
}
404 on an object miss hides whether the id exists. That is the deny this shop uses. 403 is fine if you have already decided to reveal existence. Pick one and test it. Do not return 200 with an empty CSV. Do not return 500 because invoice was null and renderCsv threw.
Sort tokens, if you expose them, live in a map you wrote. Never paste req.query.sort into SQL. OpenEMR’s _sort is the 2026 reminder. The hatch names Sequelize.literal, whereRaw, and $queryRawUnsafe still grep. A new literal without an allow line fails the job. That job is the sink layer staying closed when a human reaches for a string.
# tools/hatch_grep.sh
set -euo pipefail
PAT='Sequelize\.literal|whereRaw|\$queryRawUnsafe|\.query\(|knex\.raw\('
rg -n -e "$PAT" --glob '!node_modules' --glob '!allow-hatches.txt' src \
| sort > /tmp/hatch-hits.txt
touch allow-hatches.txt
sort -u allow-hatches.txt > /tmp/hatch-allow.txt
if ! comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt | grep -q.; then
exit 0
fi
echo "new hatch string. add a reviewed allow line or remove the call"
comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt
exit 1
OWASP ASVS 5.0.0 shipped on 30 May 2025 at Global AppSec EU Barcelona. You do not walk 17 chapters on a CSV. You walk the object requirement and the query requirement. Write those ids on the ticket that closed canInvoice. A printed ASVS badge with a fail-open header check is still a slogan.
Set __Host-session with httpOnly, secure, sameSite: "lax", and path: "/". That cookie flag is a belt on the session layer. It is not canInvoice. A sibling host that can write a cookie without the Host prefix is a cookie-toss bug. The object helper still has to run after you fix the prefix. Two fixes, two tickets, two tests. That is independence in the backlog, not a castle caption.
Prove the layers yourself
You are proving each layer returned its deny when the input was hostile or missing. Use a repo you own. Do not replay OpenEMR. Do not send a cookie anywhere else.
- Mount
failClosedon mutating routes. Copy a first-party POST from DevTools. ReplaceSec-Fetch-Sitewithcross-site. Expect 403. - Delete the
Sec-Fetch-Siteline. Expect 403 fromoriginAllowedif Origin and Referer are also gone. - Log in as Bob. Request Alice’s
/invoices/{aliceId}/export.csv. Expect 404. - Add
Sequelize.literal(req.query.sort)on a branch. Expecthatch_grep.shto exit 1. - Grep the tree for
failClosed,canInvoice, andqueryInvoiceso the next hire can find the four names.
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
rg -n "failClosed|canInvoice|queryInvoice|Sequelize\\.literal" \
--glob '!node_modules'
If failClosed logs and still calls next(), the layer is a detector. Detectors are useful on a SOC ticket. They are not depth on a cookie POST. If canInvoice exists and exportInvoice never calls it, the helper is a poster. Identifiers stay those four names.
Questions we keep getting
Is a WAF ever a real layer?
Yes, as virtual patching for a known string while you ship the helper, and as a place for a custom deny you wrote. A managed ruleset you never read is not independent of canInvoice. It does not see the row. Keep it if it is cheap and fail closed on the routes you care about. Do not count it as the object check.
Why 404 instead of 403 on an object miss?
404 does not confirm the id. That is the choice on invoice-app. If your API already reveals existence through a list endpoint, 403 is honest. The control is the helper, not the status integer. The test must expect the status you picked.
Do I still need SameSite if failClosed runs?
Yes. Set SameSite=Lax and the Host prefix on __Host-session. Lax is a belt for browsers that attach cookies. failClosed is the server read. They fail differently. That is the independence test from question 514.



