Skip to contact form
← BlogEngineeringAugust 11, 2026 · 4 min read

A 403 is not a loading problem

Nine pages told a new colleague that a deliberate permission refusal was a temporary glitch, and offered a retry button that could never work.

e hired for sales, which meant that for the first time in this codebase somebody was going to open the admin who was not one of the two people who built it. So before anybody arrived, we made an account with the narrow role, signed in as them, and clicked every link in their own navigation, one after another, on a profile that had done nothing yet.

Nine pages were lying to them. Not badly, and not on purpose — which is the interesting part, because every one of those pages was rendering exactly what it had been told to render, and the suite of six thousand tests was entirely green.

The audit log is in a sales rep's navigation deliberately. Hiding it would only conceal where the record is, not that there is one, and the deterrent is the record. So the page is meant to be reachable and meant to refuse. Refusing well is the entire job, and it was the part we had not written.

Aretrybuttonthatcannotworkisworsethannoretrybutton.Itsaysthefailureistemporary,andinvitessomebodytokeeppressing.

Read the old copy again with a new starter's eyes. "This is a loading problem" — it was not, the server answered correctly and quickly. "There may well be work here" — there is no work behind a wall you are not meant to be behind. "Nothing has been lost" — nothing was ever there. Three sentences, three untrue claims, and then a button that fires the same refused request for as long as somebody is willing to keep clicking.

Because the bug does not live in any of the pieces. The route guard is right: sales accounts have a narrower book, and the audit log is not in it. The shared error component is right too, for the case it was written for — a dropped request on a work queue, where an empty list would falsely read as a quiet day. Each part is correct and well-tested in isolation.

The defect only exists where a role, a route guard and a shared component meet, and nothing in a unit test stands in that spot. A person does. That is the whole argument for occasionally getting a browser to walk the product as somebody rather than exercising it as a set of functions.

The error component learned the difference between a request that failed and a request that was refused. Different words, a lock instead of a warning triangle, a line naming who can change it — and no button, because there is nothing to retry and offering one was the actual bug. The classification happens at the call site and takes the response rather than the status code, so nobody can mix up which of the two they were holding.

Then a guard test caught our first attempt. The lock icon was drawn at a grey that looked completely fine and measured at less than four and a half to one against the panel behind it, which is below the floor for body text. The test named the exact tier that would clear it. We had spent an hour making a page tell the truth and nearly shipped it in a colour some readers could not comfortably read.

Not much, on any single afternoon. The compounding cost is what somebody learns in their first week. A new colleague who meets three flaky-looking pages before lunch concludes the tool is unreliable, and that belief is expensive to shift and almost impossible to detect — because the person who could correct it is the person who cannot see what they are seeing.

They were not looking at a broken tool. They were looking at a tool working exactly as designed, describing itself badly.

Want this kind of thinking on your project?

This is how we work through real decisions. If you're weighing a build of your own, tell us about it — we reply within one working day.