Stories · Season 1 — Pemberton & Crumb · Episode 09
The Auth Check Everyone Forgot
In which an endpoint ships wide open, and the only thing protecting it was that nobody had found it yet.

The story
Crumb wanted a refund button. Support agents — and only support agents — should
be able to refund an order from the admin panel. Dana built it: a
RefundOrderCommand, a handler, an endpoint, a nice button. To make sure only
support could use it, Dana added the authorization check in the handler:
if (!user.IsInRole("Support")) throw new ForbiddenException();
It worked. Support could refund; nobody else could. Ship it.
Three weeks later, a new feature needed to issue refunds too — automatically,
when a saga decided an order had failed (remember story 008?). So a colleague
wired the saga to dispatch RefundOrderCommand directly, in-process. No HTTP, no
controller, no user. And here’s the thing: that path never touched the
controller’s auth check. The role check lived in one specific entry point, and
this was a different entry point.
Worse was the public one. A different CancelSubscriptionCommand had been added
in a hurry, and the developer simply forgot the auth check entirely. There was
no compiler error, no test failure, no red light. The endpoint shipped, fully
functional, fully unprotected. For two months, anyone who guessed the URL could
cancel anyone’s subscription. Nobody noticed because nothing was wrong — until
a security researcher emailed Crumb a very polite, very alarming proof of
concept.
Why this is hard the traditional way
The traditional model is authorization is something you remember to add. It lives in the handler, or a controller filter, or a middleware — wherever the developer put it, on whichever entry points the developer thought of. Which means security has two fatal gaps:
- The forgotten check. A missing
if (user.IsInRole(...))looks exactly like correct code. It compiles, it runs, it passes tests. The absence of a security check is invisible — you can’t see a line that isn’t there. The default is open, and open-by-default plus human memory equals an eventual breach. - The bypassed check. Auth bolted onto the HTTP layer only protects the HTTP path. The moment a saga, a scheduled job, or a message consumer dispatches the same command in-process, it sails straight past the controller filter. Your refund command is guarded at the front door and wide open at the side door.
You’re relying on every developer remembering to protect every entry point of every sensitive operation, forever. The math is not in your favor.
How Relay changes the ending
Relay makes authorization fail-closed and message-level. The security posture is declared on the message itself, not on one of its entry points — so it applies no matter how the message is dispatched: HTTP, saga, scheduler, broker, or in-process.
// Authorization travels with the command. Every dispatch path enforces it —
// HTTP, saga, scheduled job, message consumer. There is no side door.
[RequireRole("Support")]
public sealed record RefundOrderCommand(Guid OrderId, decimal Amount) : ICommand;
And the part that would have saved Pemberton & Crumb: the host refuses to start
if a message has no authorization posture and you haven’t explicitly allowed it.
The forgotten check on CancelSubscriptionCommand isn’t a silent gap that ships
to production — it’s a startup failure on the developer’s own machine. The
default isn’t open; the default is the app won’t boot until you’ve said,
out loud, who’s allowed. Even “this one’s public” has to be a deliberate
[AllowAnonymous], written down and reviewable in code review.
Replayed with Relay, the saga’s automatic refund is authorized by the same rule as the button, because the rule lives on the command. And the unprotected subscription endpoint never reaches production, because the app wouldn’t start until someone decided its access policy on purpose.
What it costs you to ignore this
- The default is a breach waiting to happen. Open-by-default plus human memory guarantees that eventually something sensitive ships unprotected.
- A check on one entry point protects only that entry point. Sagas, schedulers, and consumers happily bypass HTTP-layer auth — exactly the paths least likely to be security-reviewed.
- You can’t see a missing check. Absent code throws no errors and fails no tests, so the gap survives review and lurks until someone finds it.
- Discovery is on someone else’s terms. You either find these gaps yourself, or a researcher (or an attacker) finds them for you.