Article · 17 September 2026 · 6 min read

Debug Detective: your WAF is eating your API

The API was returning nothing. Not an error I could read, not a stack trace, not a timeout. Some requests went through and some came back refused at the door, and the ones that failed were failing before anything of mine ever saw them.

Didi holds a folded paper docket up to a closed sliding hatch in a heavy steel shutter, lit by a caged lamp above. Sudo the fly hovers at his shoulder.

The scene

The app was fine. That is the part I want to put first, because it took me hours to believe it.

Users hit a list, applied a filter, and got nothing back. Not every time. Filter one way and the page came back full. Filter another way and it came back empty. The same endpoint, the same session, the same afternoon.

Intermittent is the word you reach for when you cannot see the pattern yet. It is almost always the wrong word. There is a pattern; you are just not holding the right end of it.

Round the back

First thing worth doing when the edge might be involved: go round it. Sudo and I called the API directly, behind the perimeter, with the same filter that had just failed.

The same request refused through the perimeter and answered immediately when called directly against the service.
Refused at the front door, answered at the back. That result names the layer, and I did not read the name.

It answered. Immediately, correctly, in full.

That is a clean result and it is worth saying out loud what it means. The application is innocent. The data is there, the query works, the code is right. Something between the user and the app is refusing the request.

I had the answer at this point. It took me another few hours to notice.

The false lead

Because the thing between the user and the app is not one thing. It is a load balancer, a WAF, TLS termination, health checks, and whatever else you assembled the last time you needed a perimeter. My bypass had proved the fault was in that layer, and I read that as "somewhere in the edge" rather than "the thing I just went around".

So I went after the load balancer. Backend health, session affinity, timeouts, the lot. Then CORS, because empty responses in a browser make you suspicious of preflight. Then the token, because the requests were authenticated and it was the next box to tick.

Auth was valid. It was valid on the requests that worked and valid on the requests that did not, which should have told me something, and did not, because I was busy proving a thing that was already proven.

A few hours. Ticking boxes that were already ticked.

The reveal

The requests that failed all carried filter syntax. The ones that worked did not.

Seven filter strings sent to the same endpoint, four answered with 200 and three refused with 403.
Seven filters, same endpoint. The split is not what you would guess.

Look at which ones die. name eq 'a' or 1 is, semantically, a person trying it on, and it sails through. name='a'-- is what an ordinary query builder emits, and it is refused. The rule is not reasoning about SQL. It is matching characters, and a quote sitting next to an operator is the pattern.

That is the whole of the intermittency. A user could filter all morning and hit nothing, then type a name with an apostrophe in it and walk into a wall.

A WAF audit log entry naming rule 942100, SQL Injection Attack Detected via libinjection, with the matched data.
Rule 942100. Reproduced locally against the same Core Rule Set the managed WAF derives from.

Cloud Armor's preconfigured rules are derived from the OWASP Core Rule Set, and the Core Rule Set has a family of rules for SQL injection. My front end builds filters out of exactly the sort of punctuation those rules were written to catch.

Nobody was attacking me. My own user interface was sending something that looked like an attack, and the guard on the door was doing precisely the job I had hired it to do.

Everything shown here is reproduced locally against OWASP CRS, not captured from the system where it happened. The mechanism and the rule are the same; the traffic is mine.

Tuning, not turning off

The tempting fix is to disable the SQL injection rules. I did not, and I would argue against it.

What I did was lower the sensitivity on that ruleset. The rule stays in the path, it stays scoring, and the threshold moves to somewhere my own traffic clears. That is a smaller and more honest change than an exception, because it does not carve a hole shaped like my application and then forget the hole exists.

A rule you have switched off protects nobody. A rule you have tuned is still a rule.

What I found while I was in there

Two things, and neither was what I came for.

The first is that nothing alerted on blocks. The perimeter had been refusing traffic and telling nobody at all. The only reason I knew about this one is that a human noticed and said so. Every other refusal, for however long, went into a log nobody read. A control that cannot tell you when it fires is not observable, and an unobservable control is a guess you have agreed to trust.

The second is worse in the other direction. Some services had no policy attached at all. I had gone in believing my problem was too much blocking, and found parts of the estate sitting behind the same load balancer with nothing in front of them.

So the perimeter was simultaneously too aggressive where it was watching and absent where it was not. Both of those are the same failure wearing different clothes: nobody had looked.

Detective's note

The perimeter is part of the system. It is the only part that can refuse a request without any of your code ever running, which is exactly what makes it invisible when it does.

Does your perimeter tell you when it says no?

Most teams find out their WAF is blocking real users from a support ticket. If you want a second look at yours, get in touch.

Get in touch