Moses Adebayo·

Security in the age of AI

Three days of an automated attacker hammering a fintech API from thousands of rotating IPs, faking headers and user agents, and changing tactics within hours. What held, what didn't, how I fought back with Cloudflare and Better Stack through MCP, and what I think every small team should do now.

securityAIcloudflareobservabilitybackendMCP

For three days this week, someone hammered an API I look after. It’s a fintech product, so there’s real money behind the login screen, and for a while there it felt like the entire internet had decided to knock on the door at once.

This post is what happened, what I did about it, and what I think has changed. I’ll keep the infrastructure vague on purpose. Everything else I’ll be specific about, because I spent those three days wishing someone had written this down for me.

Waiting is no longer a strategy

Before the story, the context. This year has had more CVEs and more working exploits than any year I can remember, and October, which is supposed to be Cybersecurity Awareness Month, has somehow been the worst of it. In the same week as this attack I was also patching a remote code execution bug in a runtime we depend on. It wasn’t related, just the normal background noise now.

The gap between “a vulnerability is published” and “someone is scanning for it” used to be weeks. Now it’s hours, and the scanning is automated, patient and cheap. So the old posture of shipping, waiting for something to break, then reacting doesn’t work anymore. You have to be proactive: patch on the day, watch your own traffic before someone else does, and assume anything reachable from the internet is already being tested. Everything below is what that looked like for me, in practice, for one very long week.

It didn’t look like a person

The first thing I noticed was that it never got tired.

It started as a scan. Every route we have, every route we might have, every route a framework somewhere ships by default. /admin/users. /admin/withdrawals. /auth/me. /swagger.json. /.env. /.git/HEAD. /actuator/env. Some of those were wild guesses. Some of them were uncomfortably good guesses, real routes, with the right prefix and the right naming style.

Then it signed up. It registered its own accounts, verified them, logged in, and started walking the authenticated surface with a real token, looking for anything that would answer an ordinary user with something meant for an admin.

Then it got clever with paths: /admin/users%20.css. That one’s a cache deception probe. The idea is to trick a CDN into caching a private API response as if it were a stylesheet, then fetch it from the cache without being logged in. It’s not a new trick. What was new to me was seeing it tried at that pace, in the middle of everything else, by something that clearly knew the playbook.

Over roughly 60 hours:

That last number is the one that matters. One request per IP means no single address ever looks suspicious. Any defence that works per IP, which is most of them, sees nothing.

It also didn’t come from anywhere in particular. The very first reconnaissance, eleven hours before the big scan, was a single cloud server in Japan quietly checking a handful of routes. After that the traffic came from everywhere at once: Iraq, Venezuela, India, Turkey, Brazil, the Philippines, Morocco, Bangladesh, Mexico, each with hundreds of different addresses. Under that sat a second layer: a small number of data centre machines (big cloud providers, a couple of hosting companies in the Netherlands, one host in Hong Kong) that made up well under a tenth of the addresses but sent close to half the blocked requests. Residential proxies for the “look like a person” work, rented servers for the heavy lifting. Geo-blocking a country or two would have done nothing.

Everything it sent was a lie

Every header that’s supposed to tell you who’s talking was forged.

The IP addresses came from rotating residential proxy pools. Thousands of addresses that belong to ordinary home connections and phones, rented by the hour. Blocking an IP blocks some stranger’s mum in Morocco for a while and does nothing to the attacker.

The Origin header claimed to be our own web apps. Browsers set Origin honestly; scripts set it to whatever they like. If your CORS policy is your access control, it isn’t access control.

The user agent started out honest: curl, python-requests, Go’s HTTP client, the usual. I blocked those at the edge. Within hours a second wave showed up wearing a perfectly normal-looking Chrome on Windows. The scripts didn’t stop, though. Plain curl and Python kept knocking right alongside the disguised traffic for the whole three days, hundreds of them, as if one part of the operation hadn’t got the memo. That’s the other tell of automation: the cheap, dumb layer never switches off, it just runs underneath the clever one.

This is the part I think people underestimate. A few years ago this level of persistence and adaptation needed a person sitting at a keyboard, and people get bored. An agent working through a checklist doesn’t. It reads your error messages, notices the 403, tries a different header, comes back. It doesn’t need to be brilliant. It just needs to be patient and cheap, and now it is both.

What held

Honestly, most of the boring stuff held, and I want to give it credit because boring is underrated.

What didn’t

The staging environment. It was weeks behind production and missing protections production already had. To an attacker, staging is just another production with worse locks. They found it, of course. If it’s on the internet, it’s in scope, whatever you call it.

Signups were too easy. It was creating accounts with throwaway addresses faster than anyone could delete them. We’d already started moving to a flow where you verify the email before the account exists, so junk signups never touch the database. This week made that feel a lot less optional.

Some endpoints answered garbage with a 500. A negative page number, a malformed lookup. None of them leaked anything, but a 500 tells a scanner “something interesting happened here,” and it’ll come back to poke it.

How I fought back

Here’s the bit that genuinely felt new.

I did most of the investigation and the response from my terminal, with an AI coding agent wired to two MCP servers: one for Better Stack, where our logs live, and one for the Cloudflare API. MCP (the Model Context Protocol) is how you hand a model real tools instead of asking it to guess. So instead of me clicking through dashboards at 2am, I could ask “who’s been hitting the login endpoint in the last hour, grouped by country and user agent” and get a real SQL query run against real logs, with the answer in front of me in seconds.

The loop that mattered: logs tell you what changed, the edge is where you answer it, and the agent shortens the gap between the two.

1. Log what the edge knows

Our request logs already had method, path, status and duration. That wasn’t enough. Behind a CDN, the IP your server sees is the CDN’s, not the client’s. So every request log now also carries:

None of that is exotic. But it’s the difference between “traffic is up” and “traffic is up from 157 IPs in 10 minutes, all claiming the same browser, none of them our app.”

2. Turn the logs into numbers, and the numbers into alerts

In Better Stack I set up extraction rules that pull those fields out of every log line as metrics: status code, endpoint group, client kind, auth events, unique client IPs. On top of that sits a small dashboard (overview numbers, an authentication section, a traffic section) and a handful of alerts, each checked every five minutes:

The alert that earned its keep was “scripted clients getting past the edge.” When it fired, it meant the attacker had changed costume, before I’d have noticed any other way.

3. Answer at the edge, not in your app

Everything I blocked, I blocked at Cloudflare, before it touched the server. The app’s job is to be correct, not to absorb a flood.

The first rule was the obvious one: block scripting tools by user agent, on API hosts only, with payment webhooks carved out so providers can always reach us.

(http.host in {"api.domain.com" "staging-api.domain.com"}
 and not starts_with(http.request.uri.path, "/api/v1/webhooks")
 and (lower(http.user_agent) contains "curl/"
      or lower(http.user_agent) contains "python-requests"
      or lower(http.user_agent) contains "go-http-client"
      or http.user_agent eq ""))
→ Block

That worked for a few hours, which was exactly as long as it took them to change user agents.

The second rule is the one I’m proud of. Once they faked a normal browser, I went back to the logs and asked a different question: not “what looks like a bot,” but “what does our real traffic never look like?” The answer was a specific, two-and-a-half-year-old browser version string. Every one of their requests carried it, from thousands of IPs. Zero of our real users did. Our mobile app identifies itself honestly, our admins run current browsers, and a real person on a browser that old in 2026 is vanishingly rare. One rule, matching one string, and within ten minutes that traffic dropped to zero.

It lasted about twenty minutes. Then the same pool came back as a slightly newer Chrome on Linux, an iPhone on an old iOS point release, and a desktop Safari from 2023. Different costume, same tell: still not what any of our real users run. I widened the rule to old desktop browser versions and those exact strings, and they were back to 403s.

That afternoon it went through four more costumes. First a “Pixel 8” on Android Chrome, which gave itself away by naming the phone: real Chrome on Android has reported every device as Android 10; K for years, and only in-app browsers show the actual model. Then HeadlessChrome, an automated browser that literally says so in its user agent. Then old desktop Firefox, aimed at the admin login, and finally “Googlebot”, logged into accounts it had registered itself. The real Googlebot doesn’t sign up for your app. For that one I didn’t block the name, I checked the claim: Cloudflare knows which requests really come from verified crawlers, so the rule only blocks a Googlebot that isn’t one.

I also got one wrong. The first version of that rule matched the old version string on phones as well, and for about an hour it blocked two real people in Nigeria trying to sign up from old Android phones. I caught it because the blocked events didn’t look like the attacker: one IP, retrying at human speed, a minute apart, instead of one request per IP. I scoped the rule to desktop browsers and they got through. Every fingerprint rule is a bet about what your real users never do, so check the blocks, not just the totals. This is the shape of it now. Not one clever rule, but a loop you can run quickly.

(http.host in {"api.domain.com" "staging-api.domain.com"}
 and not starts_with(http.request.uri.path, "/api/v1/webhooks")
 and http.user_agent contains "<the fingerprint>")
→ Block

The third rule shuts down path probing. An API that returns JSON has no business serving .css, .php, .env or .git files, URL-encoded spaces in paths, or anything called swagger, actuator or wp-:

(http.host in {"api.domain.com" "staging-api.domain.com"}
 and (http.request.uri.path contains "%20"
      or http.request.uri.path contains "/."
      or http.request.uri.path.extension in {"css" "js" "php" "env" "json" "xml" "git" "sql" "bak"}
      or lower(http.request.uri.path) contains "swagger"
      or lower(http.request.uri.path) contains "actuator"))
→ Block

And finally a rate limit on the whole /api/v1/ prefix, per IP, set generously above our busiest real user. Plenty of Nigerian mobile users share one carrier IP, so a tight per-IP limit would hurt real people. I’m honest with myself about what it’s for: it stops one IP from hammering, and that’s all. Against a rotating pool it does almost nothing. That’s why the fingerprint rule exists.

All of this runs on Cloudflare’s free plan.

4. Keep a human on the irreversible stuff

At one point I asked the agent to switch another product’s DNS record from “DNS only” to proxied, so the same rules would cover it. It refused. The permission layer flagged a DNS change as something a person should do, and it handed the step back to me with a link.

In the moment that was mildly annoying. On reflection it was exactly right. I’m very happy for an agent to read every log I have and draft firewall rules all day. I want my own hands on anything that reroutes production traffic. The useful pattern is broad read access, narrow write access, and a human on the irreversible stuff.

What I think should be done

This is the opinion part.

Assume your API surface is known. If it’s in a mobile app, it’s been decompiled. If it’s ever been in a public spec, it’s been saved. Design every route as if the attacker has the full list, because they do, or they’ll guess it. Authorisation on every handler, every time, not “it’s an internal route.”

Stop trusting what clients say about themselves. IP, origin, referer, user agent: all of it is for logging and fingerprinting, none of it is for access control. The only things you can trust are a valid token and the checks you run on it.

Fingerprint by what’s normal for you. Generic bot lists catch lazy bots. You know your own traffic better than any vendor: what your app sends, what your admins run, what countries your users are in, what a normal session looks like. The best rule I wrote this week came from asking “what do my real users never do?”

Per-IP limits are a floor, not a wall. Rotating proxies are cheap. Rate-limit per IP anyway, but put the real limits where the attacker can’t rotate: per account, per email address, per phone number, per device. You can’t stop them changing IPs. You can stop the same email getting fifty reset codes.

Make the expensive things cost something. Account creation, verification codes, password resets, anything that sends an email or SMS. Verify before the account exists. Throttle per recipient. Don’t let one request create a row, send a message and grant a bonus.

Your staging is production to an attacker. If it’s reachable, it needs the same patches, the same edge rules, ideally its own secrets, and ideally an identity gate in front of it. Treat “it’s only staging” as a smell.

Observability is a security tool. You can’t respond to what you can’t see, and you can’t see what you don’t log. Log the real client IP, the user agent, the auth outcome. Turn them into metrics. Put alerts on the rates. The alert is what turns a three-day siege into a twenty-minute response.

Use AI on defence, with the same seriousness they’re using it on offence. The attacker had automation that never sleeps and adapts in hours. I had an agent that could query three days of logs, spot the new fingerprint, and draft a precise edge rule faster than I could open the dashboard. That’s the new baseline: the gap between “something changed” and “we answered it” has to be measured in minutes. Give your agent eyes everywhere, and keep the keys to anything irreversible.

Still watching

By the end of the second day they’d worn six different browser disguises, each one within hours of the last block, and they’ll do it again. Eventually they’ll copy a current browser string exactly, and the fingerprint rule will go quiet. When that happens the “scripted clients” alert will fire, I’ll ask the logs what’s different, and we’ll do this again.

That’s the actual lesson, I think. There’s no rule you write once. There’s a loop: see it, understand it, answer it at the edge, and keep watching. The attacker has automated their side of the loop. We should automate ours.

And don’t wait for your own three days to start. With this many CVEs landing and this much automation pointed at anything with a public IP, the time to set up the logging, the alerts and the edge rules is before you need them. Being proactive used to be the nice-to-have. Now it’s the baseline.

PreviousHeadless WooCommerce with Astro and Cloudflare Workers