You are logged in. You hit your own billing page and the browser fires:
GET /api/invoices/1043 HTTP/1.1
Host: app.acme.com
Authorization: Bearer eyJhbGciOi...
Cookie: session=valid
Back comes your invoice. Fine. Now you change one digit:
GET /api/invoices/1044 HTTP/1.1
Host: app.acme.com
Authorization: Bearer eyJhbGciOi...
Cookie: session=valid
Invoice 1044 belongs to a different customer. If the response is their invoice, you just walked out with someone else's data using nothing but your own valid session and the up-arrow key. That is IDOR (Insecure Direct Object Reference), and in API terms it is BOLA (Broken Object Level Authorization). It is the number one item on the OWASP API Security Top 10.
Here is the part that catches people. Look at that second request again. There is no SQL in it. No <script>. No ../../etc/passwd. No union select, no encoded payload, no oversized header. It is a clean, well-formed, authenticated GET to a route your API absolutely serves. Every byte of it looks exactly like the first request, because it is exactly like the first request. One integer changed.
So walk it through your defenses. Your WAF looks at it and sees a valid method, a known path shape, a real bearer token, and a body with nothing suspicious in it. Pass. Your rate limiter sees one request. Pass. Your TLS terminates fine. Pass. The only thing in your entire stack that had the information needed to stop that request was the handler behind /api/invoices/{id}, which needed to check "does the caller who owns this session also own invoice 1044?" and did not. The authorization check was the one guard that could have caught it, and it was missing. Everything else waved it through because, to everything else, there was nothing to catch.
What a signature WAF actually does
A signature WAF (Cloudflare WAF, AWS WAF, ModSecurity with the OWASP Core Rule Set) works by matching your traffic against a list of known-bad patterns. The OWASP CRS is thousands of these rules: regexes and heuristics for SQL injection, cross-site scripting, path traversal, command injection, known scanner user-agents, malicious file uploads. Plus rate limiting and IP reputation on top. When a request matches enough badness, it gets blocked or scored up.
This model is genuinely good at what it is for. It filters out the constant background radiation of the internet: the automated bots spraying Log4Shell strings at every host they can find, the SQLi fuzzers, the WordPress exploit kits hammering /wp-login.php. If you run an API on the public internet without something soaking up that noise, your logs are unreadable and your odds are worse. I am not telling you to turn your WAF off.
I am telling you what it structurally cannot see. A signature WAF answers one question: does this request look like a known attack? IDOR does not look like a known attack. Neither does broken authentication, where a valid token is used in a context it should not be allowed in. Neither does business-logic abuse, like applying a coupon a thousand times, or calling a state-transition endpoint out of order to skip a payment step. These are the attacks that breach real APIs, and they are all made of well-formed requests. The pattern-matching model has nothing to match, because the maliciousness is not in the shape of the request. It is in the relationship between the caller and the object, and that relationship lives in your data, not in the bytes on the wire.
OWASP made this explicit. In the API Security Top 10 (the 2023 list), API1:2023 is Broken Object Level Authorization (BOLA), and API5:2023 is Broken Function Level Authorization (BFLA). BOLA is "I can access an object I do not own." BFLA is "I can call a function I am not entitled to call," like a normal user hitting an admin-only endpoint that happens to accept their token. The two authorization failures sit at the very top of the list precisely because they are so common and so invisible to the tooling most teams already run. A signature WAF is not on that list of controls because it was never designed to be.
The inversion: allow-list instead of deny-list
Positive security flips the question. Instead of does this request match something bad, it asks does this request match this app's own normal behavior? You build an allow-list of what your application actually does, then you block whatever deviates from it.
The trick is that "normal" is not a rule someone writes. It is learned from your app's real behavior. A positive-security layer watches traffic and builds a model: these are the routes that exist, this is the shape each one takes (method, path structure, which query params appear and of what kind), this is whether the caller is usually authenticated, and, importantly, these are the per-tenant access patterns. What does a normal session touch? Whose objects does a given identity normally reach?
Now replay the IDOR. Your session has a learned baseline: over hundreds of requests, this identity reads invoices that belong to this identity. Then a request comes in for an object outside that pattern. It is still a well-formed GET to a known route. But it deviates from this caller's learned normal, and deviation is exactly what a positive-security model is built to flag. It does not need a rule that says "invoice 1044 is off-limits to user 7." It just needs to have learned that user 7 does not reach into that neighborhood, and then notice when user 7 suddenly does. The catch comes from the baseline, not from a signature.
That is the whole difference in one sentence. A deny-list needs someone to have anticipated the attack. An allow-list only needs to know what your app normally does, and every real IDOR is, by definition, your app doing something it normally does not.
What this looks like in practice
This is the model behind Nemesis Shield, which is what I build, so I will show you the real integration rather than hand-wave. It is a per-app SDK you mount as outermost middleware. It computes a privacy-preserving shape of each request locally (method, the normalized path like /invoices/{int}, query-param structure, status, and whether the caller was authenticated) and never ships your bodies, responses, or secrets.
FastAPI, one line:
from nemesis_shield.asgi import SentinelMiddleware
app.add_middleware(SentinelMiddleware, token=os.environ["NEMESIS_TOKEN"])
Express, one line:
import { sentinel } from "@nemesis-shield-autogon/sentinel/express";
app.use(sentinel({ token: process.env.NEMESIS_TOKEN }));
Install is the obvious pip install nemesis-shield or npm install @nemesis-shield-autogon/sentinel, token comes from the environment, and there are the same one-liners for Go, Ruby, PHP, Java, .NET, Rust, and the edge runtimes. Mount it first so it sees every route, not just your API prefix.
Every app starts in observe mode. It blocks nothing. It only learns, building the per-app, per-tenant baseline from real traffic. That matters because the whole model rests on knowing your normal, and you do not want to enforce against a baseline that has not seen your app yet.
If you are shipping a brand-new API, there is no traffic to learn from, so you can drive it:
npx @nemesis-shield-autogon/learn --target http://localhost:3000 --app-token nsk_... --repo .
Nemesis Learn exercises every route (it can read your repo to discover them, log in to reach protected routes, and chain CRUD flows) so the baseline completes in minutes instead of waiting weeks on organic traffic. It reports coverage back so the console shows when you are actually ready.
Then you review. Learned behaviors show up in a queue with the evidence behind them, you approve the legitimate ones, and when you are satisfied you flip the app to enforce in the console. No redeploy. From that point, requests whose shape falls outside the approved, per-tenant baseline get a 403 and land as findings. The key word is per-tenant: this is not one global ruleset pretending every customer looks the same. The baseline is scoped to your app and its access patterns, which is the only way "this caller does not normally reach that object" means anything.
The honest limits
I am not going to tell you this replaces fixing your authorization. It does not, and anyone who says their WAF-shaped product does is selling you a nightlight and calling it a lock.
Positive security has real costs you should go in knowing:
- It needs a clean learning period. If you learn from traffic that already contains abuse, you bake the abuse into "normal." Learn in dev/staging or a known-good window, and review what it learned before you trust it.
- It is a complement, not a substitute. The actual fix for the invoice IDOR is an authorization check in the handler: does this caller own this object? Write that check. Positive security is defense in depth that catches the ones you missed and the new ones you have not written yet. It is not permission to skip the check.
- False positives are real, which is why observe-first exists. A legitimate new feature is, to the model, a deviation. That is what the approve/deny review queue is for, and it is why enforcement is a deliberate flip and not a default. Managed well, the noise is bounded. Ignored, it will annoy you.
Business-logic abuse that stays entirely inside normal behavior (a real user doing a real thing too many times, within their own scope) is also not something an access-pattern baseline catches on its own. Different problem, different control.
Close
Your signature WAF is doing its job. It just was never built to see the request that changed one digit and walked off with someone else's invoice, because that request was never malformed. It was well-formed and unauthorized, and those are different problems that need different defenses.
Positive security is the defense for the second one, and it is free to start, one line to add, and safe by default because it observes before it ever blocks. If you build with an AI editor, you do not even add it yourself:
npx -y @nemesis-shield-autogon/mcp
Point Cursor or Claude Code at the Nemesis MCP server and it wires the SDK in as it builds. Then go write the authorization check too.
Author: David Obi
Top comments (0)