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 check decides who gets in. The answer decides what everyone else learns.
- 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.
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.
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.
| A stranger asks | The demo answers | What that gives away |
|---|---|---|
| For an account that is not theirs | 404, like a missing account | Nothing |
| To sign in with an email that has no account | 401, the message a wrong password gets | Nothing in the reply |
| To register an email that is taken | 409 | That the email has an account |
| To add a payee by an account number that does not exist | 422 | Whether 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.
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 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.
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.
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.
| # | The rule | Phase | Verdict | Why |
|---|---|---|---|---|
| 01 | Deny by default | Design | pass | Pinned by a test |
| 02 | A short threat table | Design | not done | Never written |
| 03 | A foreign id answers 404, at every id | Develop, Test | partial | Five of nine asked |
| 04 | One answer and one clock at login | Develop | partial | 65 ms against 259 |
| 05 | Login limits count failures only | Develop | pass | Held by unit tests |
| 06 | Management endpoints off the public edge | Deploy | partial | Compose publishes it |
| 07 | The caller's address comes from the edge | Deploy | partial | The client writes the header |
| 08 | TLS, HSTS and secure cookies | Deploy | not done | Plain HTTP |
A suite that walks every endpoint to prove what is open has to walk every id to prove what is private.
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.