Four alerts, one attacker: correlating API alerts into an attack story
At 03:07 UTC an IP you’ve never seen starts walking your API.
It touches /api/users/{id}, /api/orders, /api/invoices/{id}, /api/v1/accounts, and 19 more route templates in four minutes. Every request is well formed. Most return 404 or 403. None of them stands out on its own.
At 03:12 the same IP calls /admin/export/users. That route hasn’t been called once in the past seven days.
From 03:14 to 03:22 it sends 140 requests to /api/auth/login. All of them get a 401.
At 03:23 one of them gets a 200.
If your tooling alerts on each of those moments separately, the on-call engineer gets four alerts, at four different severities, about four different routes. Each one is accurate. None of them says what is actually happening.
Four true alerts can still tell the wrong story
Here is what a per-event queue looks like at 03:25:
| Time (UTC) | Alert | Route |
|---|---|---|
| 03:11 | One IP hit 23 distinct routes in 5 minutes | many |
| 03:12 | Request to a route with no traffic in 7 days | /admin/export/users |
| 03:22 | 140 failed authentications from one IP in 10 minutes | /api/auth/login |
| 03:23 | Successful login after repeated failures | /api/auth/login |
Read top to bottom, it’s obvious. Nobody reads it top to bottom at 3 AM, though.
The scan alert arrived first and looked like a crawler. The rare-route alert landed in a different channel. The brute-force alert got acknowledged by someone who blocked the IP and went back to bed. The successful login sat in the queue as one more row.
The problem isn’t a missing detector. Every signal fired. The problem is that the attack was a sequence, and the alerts were delivered as a list.
Group by actor, then by time
The fix doesn’t need a model. It needs two decisions you can write down.
1. What makes alerts belong to the same actor? In our case, the same client IP, or the same API key. Pick the identity your API actually trusts, and group on it.
2. When does one story end and the next begin? We use a gap rule: sort an actor’s alerts by time, and if more than 30 minutes pass between two consecutive alerts, start a new story.
That’s the whole grouping step. In the example above, the largest gap between alerts is 11 minutes, so all four belong to one story.
Here’s why it’s worth keeping that simple. When the grouping is wrong, you can say exactly why. “These two weren’t joined because there were 34 minutes between them” is a sentence an engineer can check, argue with, and change. “The correlation engine assigned a low relatedness score” is not.
Name the pattern from what’s in the group
Grouping tells you these belong together. The next question is what is this?
We answer it from the set of alert types in the story, with fixed precedence:
| Alert types in the story | Pattern |
|---|---|
| Endpoint scanning + repeated auth failures | Recon → Brute Force |
| Endpoint scanning + rare endpoint or traffic spike | Recon → Exfiltration |
| Repeated auth failures only | Credential Stuffing |
| API key burst | API Key Abuse |
| Anything else | Unknown |
Our example contains scanning, a rare-endpoint hit, and auth failures. It matches both of the first two rows. Recon → Brute Force wins because it’s the more specific and more dangerous combination, and the precedence is written down rather than inferred.
“Unknown” is a real answer, too. A rule that says “I don’t recognize this sequence” is more useful than one that forces every group into the closest label.
Map each step to ATT&CK so the story has a direction
Each alert type maps to a MITRE ATT&CK technique, which gives the story a direction you can read without opening four tabs:
- 03:07–03:11 Reconnaissance. Distinct-route scanning → T1595 Active Scanning.
- 03:12 Initial Access attempt. Dormant
/adminroute → T1190 Exploit Public-Facing Application. - 03:14–03:22 Credential Access. Repeated 401s on login → T1110 Brute Force.
- 03:23 Defense Evasion. A 200 after the failures → T1078 Valid Accounts.
That last step is the one that matters most, and it’s the easiest to lose in a flat queue. A successful authentication after 140 failures from the same IP is the moment a brute-force attempt becomes a possible account compromise. In a list, it’s one more row. In a story, it’s the ending.
The rules behind each step
Correlation is only as trustworthy as the alerts it joins. In APINine, the four moments above come from three detectors, each firing on a threshold you can read and tune per project:
- Endpoint scanning: one IP touching 10 or more distinct route templates in 5 minutes. Health, metrics, Swagger, and docs paths are excluded, so someone reading your API docs doesn’t count as a scanner. 20 or more routes is high severity.
- Rare endpoint: a route averaging fewer than 5 calls a day over the past 7 days receives traffic. Routes under
/admin,/internal,/debug, and similar prefixes are always high severity. - Repeated failure: 20 or more 401 or 403 responses to one IP in 10 minutes. 100 or more is high severity. If a 200 shows up from the same IP alongside the failures, the alert flags it as a possible account compromise. That’s where the 03:23 login surfaces.
None of these thresholds is clever, and that’s deliberate. When one fires on something it shouldn’t, you can see which number tripped, and you can raise it for that project or allowlist the source.
Where this approach falls short
A rule you can read is also a rule whose limits you can state. These are the ones we know about:
- Rotating proxies split the actor. If an attacker spreads the same campaign across hundreds of IPs with no shared API key, IP-based grouping sees hundreds of small stories, not one big one.
- Patient attackers reset the clock. An attacker who waits 31 minutes between phases starts a new story. The gap rule is visible, so you can widen it, but any fixed window has an edge someone can sit on.
- Shared egress looks like one actor. A corporate NAT can make many legitimate users look like one IP failing a lot of logins. The fix is an allowlist entry, not a smarter guess.
- Low-and-slow stays below the line. Credential stuffing spread thinly across hours may never cross the per-window threshold in the first place, so there is nothing to correlate.
None of these is hidden inside a score. Each one is a sentence you can read, test against your own traffic, and decide whether it matters for your API.
What changes at 03:25
With correlation, the on-call engineer opens one item instead of four. Roughly:
Recon → Brute Force ·
203.0.113.42· 03:07–03:23 UTC Scanned 23 routes → probed/admin/export/users→ 140 failed logins on/api/auth/login→ 1 successful login Suggested: block the IP, rotate the credentials that were tested, rate-limit the auth endpoints, and review auth logs for successful logins from this IP.
That last action is the investigation. Without the story, it’s the step most likely to get skipped, because nobody connected the 200 to the scan that started 16 minutes earlier.
Per-event alerts tell you something happened. A story tells you what the attacker was trying to do, and how far they got.
When you look back at your last real incident, how many separate alerts did it take before someone realized it was one attacker?