The support ticket said an admin kept getting kicked out of the control panel. Not at login. A few clicks in, every time. Our abuse filter had flagged the account for clicking too fast. Which is what the filter is for: catch something hammering the site and shut it down. Except nothing was hammering the site. The attacker it caught was a paying customer, clicking through menus we built.

I opened the ticket expecting a threshold problem. Maybe the daily-hit ceiling was too low for an active power user. I pulled the filter’s record for the account, looked at the flag state and the timestamps, and did the math. The clicks were fast, but not attack-fast. Around 8 to 12 requests in a short window, which is the pace of someone clicking through menus.

the filter was counting our own redirects as hits

The production app’s admin panel does 302 redirects. A lot of them. The pattern is standard. Click a menu item, land on a routing page, get bounced to the actual destination. Two HTTP requests, one user action, under 500 milliseconds. For a quick-click admin, that chain fires repeatedly. Four menu clicks becomes eight or twelve logged requests on the same IP in a few seconds.

The filter’s stored procedure did not know about this pattern. It counted hits. A hit was a hit. If two hits arrived within the threshold window from the same IP, the speed counter incremented. If the counter crossed the daily limit, the speed flag got set. Nobody wrote a carve-out for the app’s own internal navigation because when the filter was written, nobody had thought to ask whether normal in-app routing would ever look like a burst. The routing model had evolved over the years, and the filter never changed with it.

what claude missed first

I was working this with Claude. The initial read of the filter code came back clean. Stored procedure logic correct as written, thresholds matched the documented intent, no SQL bugs. Claude flagged the speed threshold as potentially aggressive but framed it as a tuning question rather than a classification problem.

That framing was wrong. The answer to “the threshold is too tight” is to raise the number. The actual problem was that the number was counting the wrong things. You can raise the ceiling as high as you want and still block a power user, because every admin click fires a 302 chain and every hop in that chain refills the bucket faster than a human could ever clear it. The real bug was the classification gap, and the threshold was only a symptom of it.

the four-part fix

Once the root cause was clear, the fix had four moves. They had to land together, because a partial fix would have left the underlying count wrong.

The starting point was a request classification layer. The stored procedure had no concept of request origin: a 302 redirect hop and an external probe hit the same counter, indistinguishable, treated identically. It needed to distinguish internal navigation from inbound probing before deciding whether to increment the speed counter. With that in place, the next move was to skip the speed-flag increment for the internal-navigation class entirely. Navigational hits still get logged, but they no longer feed the counter that gates account state. One honest caveat: the classification leans on request metadata the client ultimately supplies, so it reduces false positives without proving anything about intent, and it is not perfect.

That handled new flags, but not stale ones. An auto-clear took care of speed flags set while the account was otherwise idle: if the flag was set by a burst that turns out to have been navigation, and the account goes quiet for a defined window afterward, the flag clears itself rather than persisting until someone notices. The last move was raising the daily threshold to give legitimate power users more headroom before any flag fires at all. On its own, that would have been insufficient. With the classification fix in place, it becomes a sensible backstop instead of a band-aid on the wrong problem.

old defensive code does this quietly

The filter is old. It was probably right when it was written. The app’s navigation pattern at the time was simpler, slower, less redirect-heavy. The heuristic made sense against the traffic model it was built for.

What happens to a defensive rule over time, when nobody revisits it, is that the app evolves and the rule does not. Navigation patterns change. Admin workflows get more complex. The number of redirects per user action grows as the control panel grows. The filter keeps counting the same way. One day a ticket comes in because an admin got blocked, and it turns out the routing has been teaching the filter the wrong lesson for years.

I do not know when the tipping point happened. The ticket I am writing about is the first customer-visible case I have on record, but I have no signal it was the first time the filter did this. Power users who hit the problem and did not report it would have looked like unexplained session issues rather than a filter false positive. The filter was running, logging, flagging, and mutating account state, and at the time nothing surfaced flagged accounts proactively, so the first signal we got was a customer calling it in.

what i am sitting with

After the fix shipped, I started asking what it would take to catch this class of problem before a user reports it. The thing I want is not complicated in theory. A query that joins filter flags against request-pattern logs and surfaces cases where flagged accounts show a profile consistent with navigation rather than probing. A daily sweep would have caught this weeks earlier.

What I do not have yet is clarity on how many other defensive rules in the production app are running on stale assumptions. This filter is one system. There are others. Each one was correct when written. Each one is sitting on top of a navigation model that has been evolving over the years, and that audit is still ahead of me.

An abuse filter that cannot tell an attacker from the app’s own redirect logic is not security. It is random account damage.