Here is a mistake that is easy to make and hard to notice: treating "you're not allowed to do that" and "no real client could have sent that" as the same event.
They feel similar. Both end in a denied request. Both look like something you
might want to know about. So the naive access log records them identically, a
single denied line, and the result is a log that cries wolf. A player's UI
fires a stale action, a laggy client replays a request, someone clicks a button
a half-second after losing permission, all of it lands in the same bucket as an
actual forged payload. You learn to ignore the bucket. Which means you've built
a security signal you don't read.
Two different questions
The fix is to notice that a privileged request answers two separate questions, in order:
- Is this even shaped like something a real client sends? Does it have an actor the engine vouches for, a capability that's a string, a target when the action needs one? A legitimate client cannot produce a request that fails this check. The client code that builds the request simply never emits a malformed one.
- Is this actor allowed to do this? Given a well-formed request, does the permission model grant it?
A failure at step 2 is normal. Denials are the system working: someone asked
for something they can't have, and the answer was no. That's a WARN at most,
and honestly often an INFO. Your own UI causes these.
A failure at step 1 is different in kind. No honest client produces a
structurally impossible payload, because the honest client is the code you
wrote, and it doesn't construct garbage. So a step-1 failure is high-confidence
evidence that someone is hand-crafting requests, which is exactly the thing you
built the access log to catch. That's an ALERT.
The whole distinction costs you one function: a shape check that runs before the permission check and raises the severity when it fails.
The part people get wrong next
Once you can reliably detect forged payloads, there's a temptation to act on the detection automatically: ban the actor, drop the session, blacklist the IP. Resist it.
The security goal is already met. The forged request was denied; the action did not run. Auto-banning is a second, separate decision with its own failure mode, and its false positives are expensive: you'll eventually flag your own testing, your own new UI, a client on a bad network, an edge case you didn't foresee. An automated irreversible response to a heuristic is how you turn a detection win into a support ticket.
So the rule I keep coming back to, across an RBAC gateway and a fraud pipeline both: let the machine narrow the decision to the point a human can make it quickly, and keep the irreversible call human. The gateway logs and flags. It does not ban. The fraud scorer routes to review. It does not auto-refund. The cheap, reversible, high-value work is automated; the costly, irreversible work stays with a person who can see the context the heuristic can't.
A denied request is not an attack. A forged one might be. The entire art is in not confusing the two, and in not overreacting to either.