Technical Excellence

404 Is a
Security Answer

Part VI · Excellence Across the Nine Phases 03 · Develop

Bob is signed in to my banking demo. He asks for Alice's account, using an id that only she could have given him, and the server answers 404. The test that checks this is named bob reading alice's account returns 404 (no existence leak), and it passed on 30 August with the other 76 checks in its suite.

Under that name the test asserts one thing: that the status is 404. The claim in the name is larger than the check under it, and this essay is about the distance between the two. The demo is the one behind Not Run Is Not Passing and Money Has Rules the Framework Does Not Know, and I read its code against my own security rules to see what else it tells a stranger.

// the crux

The check decides who gets in. The answer decides what everyone else learns.

// in one breath
  • A test with a promise in its name and one status code in its assertion.
  • Four answers my demo gives a stranger. Two hide something, two confirm it, and the table that should say why was never written.
  • A login that gives one answer for every failure, and a stopwatch that disagrees.
the answer

Two True Answers

Two answers would both have been true. A 403 says the server understood the request and refuses it. A 404 says there is nothing to show. To a stranger holding an id he was never meant to have, the first also reports a fact: something with that id exists, and it belongs to someone else. Ask often enough and a list of live ids falls out of the refusals.

The demo's ids are random UUIDs, so guessing one is hopeless, and I would not build a rule on that. Ids leak through logs, links and emails. OWASP files the class as API1:2023, Broken Object Level Authorization, and lists the fix as a check in every function that takes an id from the client, with random ids as one more layer.

The HTTP specification saw this coming. RFC 9110 lets a server that wants to hide a forbidden resource answer 404 instead, and its own definition of 404 covers a server that “is not willing to disclose that one exists”. That is not obscurity, because nothing about the check is hidden. The same line of code runs for a foreign id and an absent one, and only the reply differs.

the design

Decide What You Confirm

The demo starts from a short list of what is open. Its filter chain names the public paths, among them registration, login and the API documentation, and a closing rule says every other request must be authenticated. Saltzer and Schroeder called the habit fail-safe defaults in 1975: base access decisions on permission rather than exclusion.

The habit has a test, and I think it is the best one in the suite. The contract test walks every operation in the OpenAPI document and asserts that exactly three can be called without a session: the CSRF token, login and registration. Leave a fourth open by accident and the build fails.

Deny by default settles who gets in. It does not settle what a refusal says, and the demo makes that decision in four places.

Four answers to a stranger, read from the demo's code and its tests
A stranger asksThe demo answersWhat that gives away
For an account that is not theirs404, like a missing accountNothing
To sign in with an email that has no account401, the message a wrong password getsNothing in the reply
To register an email that is taken409That the email has an account
To add a payee by an account number that does not exist422Whether that number exists

The last two confirm on purpose: a person needs to hear that an email is taken, and a payer that an account number is wrong. The suite pins both answers, and nothing records that anyone accepted what they give away.

The standards my demo runs on, which I wrote up with an AI assistant, ask for exactly that record, a short threat table with the trust boundary, the threat, and the control or the accepted risk. The demo's own documentation grades its threat model not done, and I have written on this site that threat modelling belongs in detailed design.

the code

Put the Rule Where the Code Cannot Skip It

Seven endpoints take an id in the path, and a transfer request carries two more in its body. All nine are written to answer 404 for a foreign id, in two ways.

The account service loads the row and throws it away if the owner is not the caller. The payee service never loads it: the owner is part of the query, so another customer's row does not exist as far as that code can tell.

Bob sees the same reply either way, and the two are not equally safe. The first works for as long as the author of every new endpoint remembers the filter. The second has nothing to remember, because the rule sits in the name of the query, findByIdAndOwnerCustomerId. I would move the account service to the second kind.

Neither style records which 404 it was, and the second cannot: with the owner in the query, the code has no way to tell a foreign row from a missing one. That is the price of hiding it. What the operator can still watch is the pattern, a burst of 404s on id paths from one session, and Every Error Has an Address gives every error a request id to count and trace it under.

the login

The Same Answer, and the Same Clock

Login is the other place a system answers a stranger about existence. The demo gives one error for an unknown email, a locked account and a wrong password alike, and its unit tests pin all three to the same code. OWASP's authentication guidance asks for that plus the part that is easy to leave out: run the same process whatever the user or the password is, because a difference in processing time is an answer too.

The demo does the extra work. For an unknown email it still checks the password against a dummy BCrypt hash, and the comment above says the point is that known and unknown emails take comparable time. I went looking for that comparison. Every real password in the demo is stored at BCrypt cost 12. The dummy hash is cost 10. Each step of cost doubles the work, which Provos and Mazières built into the algorithm in 1999, so cost 12 does four times what cost 10 does.

So I timed it, with the demo's own Spring Security encoder on my machine. A known email took a median of 259 milliseconds and an unknown one took 65. That is the hash comparison alone, not the endpoint over a network, but a gap of 194 milliseconds is wide enough for a stranger to measure, and it sorts emails into those with accounts and those without.

The limiter beside it counts failures only, because counting successes would throttle everyone behind a shared address, the demo's own reverse proxy included. A unit test holds that rule in place.

the tests

Who Has Been Asked for Someone Else's Id

Nine places hand the server an id. I searched every test in the repository, the JUnit suites, the k6 scripts and the browser specs, for one that asks for someone else's. Five places have one. Four never do: the balance and the ledger entries of an account, the rename of a payee and the detail of a transfer.

Two of those four read Alice's account through the same one-line call as the tested endpoint, so I expect them to hold. Expecting is the whole problem: lose that call from the ledger endpoint in a refactor and nothing in the suite fails. OWASP's advice for this class ends on the point: write tests that evaluate the authorization mechanism, and do not deploy changes that make them fail.

The integration test that borrows a stranger's account as a transfer source asserts the 404 and then something better: that account's balance still reads 1,000.00, so the check fails if the money moves. The k6 check from the top of this page asserts only the reply. It never asks for an id that does not exist, so it cannot show that the two answers match, and that match is what its name claims. The results log calls the k6 run pass, 77 checks out of 77, and it is. Not Run Is Not Passing says to report the run you did, not the run you meant to do, and this check proves a status while its name promises more.

A test that earns the name is table-driven: every operation that takes an id, asked twice by Bob, once with Alice's id and once with an id nobody holds, and the two replies compared on status, error code and message. The suite already has the walk. The contract test that pins the three open operations visits every operation in the OpenAPI document, and it can insist that each one with an id in its path has a row.

the edge

What the Edge Enforces

The standards say management endpoints are not reachable through the public edge. In the demo that rule holds at nginx, which proxies the API and its documentation and nothing else. The application does not enforce it: its own security rules let the health and metrics paths through without a login, so the edge alone keeps them private. One file lower, the compose file publishes the backend's own port to the host and calls it the debug port. Docker publishes a port on every interface unless the mapping names one, so the API, its documentation and the actuator all answer on the host, around the edge. The fix is a prefix on one line, 127.0.0.1.

The edge has a second job, telling the application who is calling. The login limiter takes the caller's address from the first entry of X-Forwarded-For. nginx appends the address it saw to whatever the client already sent, so the first entry is the one the client wrote. MDN's warning about this header lists rate-limiter avoidance first among the consequences. Either fix is small: overwrite the header at the edge, or read the last entry, the one the edge added.

where each rule stands
Eight rules, and where each one stands in the demo
#The rulePhaseVerdictWhy
01Deny by defaultDesignpassPinned by a test
02A short threat tableDesignnot doneNever written
03A foreign id answers 404, at every idDevelop, TestpartialFive of nine asked
04One answer and one clock at loginDeveloppartial65 ms against 259
05Login limits count failures onlyDeveloppassHeld by unit tests
06Management endpoints off the public edgeDeploypartialCompose publishes it
07The caller's address comes from the edgeDeploypartialThe client writes the header
08TLS, HSTS and secure cookiesDeploynot donePlain HTTP
// the part worth keeping

A suite that walks every endpoint to prove what is open has to walk every id to prove what is private.

The demo has no customers and holds no real money, so nothing here was used against anyone. I read my own rules against my own code, and they disagreed in six places. I would rather find that with a stopwatch and two config files than in somebody else's report.
// carry forward

This page covers what a system tells a stranger. Where its secrets live, and what is inside the image that runs it, is the other half of security and a piece of its own.

// continue exploring