
A secure architecture makes the object check hard to skip. Testing proves a second user cannot pass it.
CWE-639 is a user-controlled key. A handler that loads by id and then maybe compares owner is the architecture that fails. A handler that puts tenant in the same WHERE is the one that holds.
The usual mistake is a diagram of tiers and no test that crosses those tiers with two accounts.
This page is the architecture habit and the test habit, in that order.
CWE-639 is authorization bypass through a user-controlled key. A handler that loads by id and then maybe compares owner_id is the architecture miss. A passing suite that never sends a foreign id is the other miss.
Scanners and a poster of practices are not an architecture. The architecture is an actor minted from a store row I can delete, a helper that will not return a row the actor cannot see, and tests that go red when either piece is absent. Keep the IDOR guide for the incident list. Keep session management for the cookie flags. Keep the secure coding checklist for the rest of the request.
Who is calling is not which row
Authentication answers who. CWE-306 is a critical function with no login at all. Authorization answers which objects that caller may read or change. CWE-862 is missing authz. CWE-863 is incorrect authz. CWE-639 is the user-controlled key form. Most Express CRUD is CWE-639: the route is the right verb, the row is the wrong owner.
OWASP API1:2023. Three lines on that page do this section’s job. Object ids can be integers, UUIDs, or strings, and they are still easy to identify. Comparing the session user to the path is not enough when the object is not the user. Every function that uses client input to access a record has to run the check on that record.
A JWT sub that matches req.params.userId only covers the profile-is-the-user case. An invoice, a file, a VIN, or a chat thread needs a check on that object. See the JWT page when the token format is the ticket. This page is the row after the token has already parsed.
The object check lives in the query
Load-then-compare is how PATCH and DELETE skip the rule. The handler selects by id, then maybe reads owner_id. Reads sometimes get the second line. Writes often do not. A 403 on a found row and a 404 on a missing one also tell a caller which ids exist.
The query that closes the class makes a foreign request look like a missing row. Tenant and id in the same predicate, on every verb. The actor comes from the session, never from the body.
// BAD: unscoped load, then a compare someone will forget on PATCH
// const { rows } = await pool.query(
// "SELECT * FROM invoices WHERE id = $1",
// [invoiceId]
// );
function canSeeSql() {
return `SELECT id, memo, amount_cents
FROM invoices
WHERE id = $1 AND org_id = $2`;
}
async function canSee(pool, invoiceId, orgId) {
if (!orgId) return null;
const { rows } = await pool.query(canSeeSql(), [invoiceId, orgId]);
return rows[0] || null;
}
function memoFromBody(body) {
if (typeof body.memo !== "string") return null;
if (body.memo.length === 0 || body.memo.length > 200) return null;
return body.memo;
}
canSee is the named fallback. A missing orgId returns null before the database sees a query. That is fail closed at the helper, not “throw if we remember.” Every GET, PATCH, and DELETE calls this function. A new route that talks to invoices without canSee is the architecture bug, even if the SQL in that route looks similar.
app.patch("/invoices/:invoiceId", async (req, res) => {
if (!req.actor) {
res.status(401).send("Unauthorized");
return;
}
const row = await canSee(pool, req.params.invoiceId, req.actor.orgId);
if (!row) {
res.status(404).send("Not found");
return;
}
const memo = memoFromBody(req.body);
if (memo === null) {
res.status(400).send("Bad request");
return;
}
const result = await pool.query(
`UPDATE invoices
SET memo = $3
WHERE id = $1 AND org_id = $2`,
[req.params.invoiceId, req.actor.orgId, memo]
);
if (result.rowCount === 0) {
res.status(404).send("Not found");
return;
}
res.status(204).end();
});
The UPDATE repeats the same predicate. canSee is not a cache of permission you then write without a scope. A race can move the row between the read and the write. The write still asks for org_id. Zero rows is 404, same as the read.
Mass assignment is the write-shaped twin. A PATCH that spreads req.body into the row lets the client set org_id, owner_id, or role. The query can be perfect and the body still moves the invoice. memoFromBody is the named fallback for the write. It returns null on a missing or huge string. The handler then 400s. It never reads org_id from the client. GraphQL has the same job under a different name: an invoiceId argument still goes through canSee. A resolver that loads by global id and then “checks the user” is load-then-compare again.
One helper, so a new route cannot skip it
Middleware mints req.actor from the session store. Handlers never accept orgId from the client. A helper that still takes orgId as a free argument is fine only if the only caller is middleware. If a route can pass req.body.orgId, the client is back in charge.
async function loadActor(req) {
const sid = req.cookies["__Host-session"];
if (!sid) return null;
const actor = await sessions.find(sid);
if (!actor || !actor.orgId || !actor.userId) return null;
return actor;
}
app.use(async (req, res, next) => {
req.actor = await loadActor(req);
next();
});
loadActor fails closed. A missing cookie, a missing store row, or a row without orgId is a null actor. Later handlers treat null as 401. Do not invent a guest orgId so templates render. A guest who can call canSee is an unscoped read with extra steps.
| Piece | Named function | Fail |
|---|---|---|
| Session | loadActor | null, then 401 |
| Object | canSee | null, then 404 |
| Write | same WHERE | rowCount 0, then 404 |
request | loadActor(sid) |-- missing sid or store row --> 401 | canSee(invoiceId, actor.orgId) |-- zero rows --> 404 | handler UPDATE... WHERE id AND org_id rowCount 0 --> 404
Make the deny path red
A test that only creates one user and reads that user’s invoice is a documentation of the happy path. It cannot see CWE-639. Fail closed means the suite is red when the deny path is missing, not when an assert was skipped.
Three cases belong in the same file as canSee. No actor. Actor A, invoice B. Actor B, invoice B. The middle case is the architecture test. If someone later changes canSeeSql to drop org_id, that case must fail.
test("canSee denies a foreign invoice", async () => {
const a = await seedOrg("A");
const b = await seedOrg("B");
const invoiceId = await seedInvoice(b.orgId, { memo: "B only" });
const row = await canSee(pool, invoiceId, a.orgId);
assert.equal(row, null);
});
test("canSee denies a missing actor org", async () => {
const b = await seedOrg("B");
const invoiceId = await seedInvoice(b.orgId, { memo: "B only" });
const row = await canSee(pool, invoiceId, undefined);
assert.equal(row, null);
});
test("PATCH with A cookie and B id is 404", async () => {
const res = await request(app)
.patch("/invoices/" + bInvoiceId)
.set("Cookie", "__Host-session=" + aSid)
.send({ memo: "no" });
assert.equal(res.status, 404);
});
Status rules for the suite: 401 only when loadActor returned null. 404 when canSee returned null or the write touched zero rows. 200 or 204 only after both helpers returned a real actor and a real row. A 403 on a known id is an existence oracle. Keep 404 for both “missing” and “not yours” unless a compliance report forces 403, and even then do not run a second unscoped SELECT to pick the status.
A mock that always returns a fixture row hides the deny path. If the test suite stubs canSee to return org B’s invoice for every caller, the PATCH case above goes green while production still serves B. Stub sessions.find if you must. Do not stub the function whose job is the 404. Point the suite at a real test database and the real helper.
Drop the store row today
The actor is a store row, not a signed blob you cannot cut off. express-session 1.19.0, updated 22 January 2026, still defaults the cookie name to connect.sid and omits SameSite unless you set it. Prefer __Host-session. Call regenerate after a password or passkey you trust.
function loadSession() {
return session({
name: "__Host-session",
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 12 * 60 * 60 * 1000,
},
});
}
A JWT waits until another origin must verify without your store. If you still mint one, pin algorithms, aud, iss, and exp, and keep a jti you can delete. That jti list is a session store with extra steps. Do not park the blob in localStorage. Do not treat sub as canSee.
Two of your own accounts
You are not enumerating a production tenant. You are proving your own fixtures returned 404.
- Seed org A and org B. Insert
bInvoiceIdunder B. - Log in as A on your local app. Copy
__Host-sessionfrom DevTools. - GET and PATCH
/invoices/bInvoiceIdwith A’s cookie. Expect 404 both times. - Repeat with the cookie deleted. Expect 401. A 200 means
loadActorwas skipped. - Grep for
FROM invoices WHERE id =withoutorg_id. That string is the next failing test you have not written yet.
curl -sS -D - -o /tmp/can-see-body -X PATCH \
"https://your-app.example/invoices/bInvoiceId" \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
-H "Content-Type: application/json" \
--data '{"memo":"no"}'
# Expect: HTTP/2 404
curl -sS -D - -o /dev/null -X GET \
"https://your-app.example/invoices/bInvoiceId"
# Expect: HTTP/2 401
CI runs the same two requests against the test server. The build is red on 200. That is the architecture. Helpers first. Tests that refuse to go green when the deny path is gone. Everything else is a poster.
Questions we keep getting
Is comparing JWT sub to the path enough?
Only when the object is the user. API1 says that compare is not a fix for invoices, files, or threads. Put org_id in canSee. Keep the JWT page for the claims format.
Should a foreign id be 403?
404. Same status as a row that does not exist, from the same scoped query. 403 on a known id is an existence leak. If a human UI needs a different sentence, decide after the scoped query, not with a second unscoped load.
Can I skip canSee on an internal admin route?
No. Admin is a different actor with a different orgId or a role the helper understands. An unscoped SELECT for “staff” is how a stolen staff cookie becomes every invoice. Name the role in the predicate. Do not drop the predicate.



