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:

  1. 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.
  2. 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.