How to Investigate an Account Takeover by Asking Questions
A step-by-step account takeover investigation in plain English: the ten questions to ask, where each answer lives in your logs, traces and security events, and what to check before you call it closed.
How to Investigate an Account Takeover by Asking Questions
A user writes in: she can't sign in, her password stopped working this afternoon, and she thinks someone else got into her account. Everything you need to know is already in your telemetry. The hard part is that it's spread across tables, and no single request looks like a break-in.
This guide turns the investigation into a list of questions you can ask in plain English, in order, each one building on the last. Every answer quoted here comes from a real, scripted incident in our lab app, so each one was checked against what actually happened. The video above walks through the same investigation in seven minutes, and Academy Episode 10 has the full transcripts.
Why account takeovers are hard to investigate
An account takeover leaves evidence in four places:
| Evidence | What it knows |
|---|---|
| Requests (traces) | method, path, status, client IP, user agent, timing |
| Your app's log lines | whatever your code writes: an export, a payment, a state change |
| Errors | what broke, with the stack |
| Security events | sign-ins, failed sign-ins, password changes, invites, permission checks |
They don't share one key. Security events know the user but not the request. Requests know the address but not the user. So an investigation is a chain: the user leads to an address, the address to its requests, the requests to the data they touched. Each question needs the answer to the one before it, which is why a single dashboard rarely tells the story.
The ten questions
Ask them in this order. The second column is where the answer lives; the third is what it showed in our run.
| # | Question | Where it lives | What it showed |
|---|---|---|---|
| 1 | What happened to this account? Sign-ins, failures and account changes, oldest first | security events | A normal sign-in, 5 failures from 5 addresses, a sign-in from a new network, then a password change and an invite 26 s later |
| 2 | Is the sign-in that worked new? Had the account seen that address or browser before? Did that address ever fail? | security events | Never seen; that address never failed a sign-in anywhere. First try, real password |
| 3 | Is the noise part of something bigger? Failed sign-ins across the app, by user agent | security events | One user agent: 120 failures, 40 addresses, 6 accounts, 0 successes |
| 4 | What did that address do? Every request in order, and how long | traces | 40 requests in 26 seconds |
| 5 | Did it reach anything it shouldn't? Permission checks by route and reason | security events | 3 admin attempts, all denied; 19 note IDs not found; 7 granted |
| 6 | Whose data did it read? | security events (object and owner IDs) | 7 notes, all the victim's own |
| 7 | What left? | your own log lines, joined to requests by trace ID | 3 exports of the same 7 notes |
| 8 | What did they leave behind? | security events + traces | An org and a pending admin invitation to an outside address |
| 9 | Did any defence fire? | security events | No: the wave stayed under every limit |
| 10 | Was anyone else hit? | security events | The 5 other targets: no new sign-in, no changes |
Questions 1 to 3 tell you how they got in. Questions 4 to 7 tell you what they did. Questions 8 to 10 tell you what's still open, and they're the ones a rushed response skips.
How they got in: questions 1 to 3
Start from the one thing you know, the user. Ask for her whole account history, oldest first, and name the columns you want:
A customer with user id d265e1af-55ab-40e0-9eea-86d7e00dc31e says she can't sign in and thinks someone else got
into her account. Show her sign-ins, failed sign-ins and account changes (password, role, invites), oldest first:
time, event, IP, user agent and reason.
Twelve rows gave the shape of it: five failed sign-ins from five different addresses, then a successful one from a new network on Linux and Firefox, then, 26 seconds later from that same address, a password change and an invitation. Then the user herself, three times, failing with the password that used to work.
The obvious story is "the failures finally worked". Questions 2 and 3 test it:
That successful sign-in from the new address: had this account ever been used from that IP or that browser
before it? And did that IP ever fail a sign-in, on any account?
Those five failed sign-ins on her account: were they part of something bigger? Across the whole app, group
failed sign-ins by user agent: attempts, distinct IPs, distinct accounts, first and last time, and how many
sign-ins succeeded with that user agent.
The address that got in had never failed a sign-in, anywhere. The failures were a credential-stuffing wave: one Windows Chrome user agent, 120 failed sign-ins from 40 addresses against 6 accounts in two and a half minutes, and zero successes. The loud attack wasn't the one that got in. The real one came from somewhere else, with the right password on the first try. That changes the response: the telemetry can't say where the password came from, but most likely from outside your app (reuse, phishing or malware), so the user needs to change it everywhere she used it.
What they did: questions 4 to 7
Now pivot from the user to the address:
List every request from that new address, in order: time, method, route, status. How long did it all take?
Forty requests in 26 seconds: sign in, look around, try three admin pages (all 403), walk note IDs 734 to 759 one after another, export three times, change the password, create an org and an invite. That pace is a script, not a person clicking.
In our run the route column came back empty, because this app's spans don't fill it. One follow-up fixed it: "The route column came back empty. Show the request path instead (http.target), same order." Saying what's wrong is usually all it takes.
The permission checks answer question 5:
| route | decision | reason | checks |
|---|---|---|---|
/api/notes/:id | deny | not_found | 19 |
/api/notes/:id | allow | 7 | |
/api/admin/users | deny | role_denied | 1 |
/api/security | deny | role_denied | 1 |
["/api/admin/users/:id","/api/admin/users/:id/role"] | deny | role_denied | 1 |
The 19 "not found" answers are by design: the app answers "not found" for a note that isn't yours, so it never confirms that someone else's note exists. The 7 granted reads were all the victim's own notes (question 6), because the permission events carry the object and owner IDs.
Question 7 needs two sources. The app writes one JSON log line per export, with the user and the note count but no IP. The chat joined each line to the request that wrote it by trace ID: three exports of the same 7 notes, all from the attacker's address, plus one colleague's export of their own notes from their own address. Her notes left. Nobody else's did.
What's still open: questions 8 to 10
This is where we handed the investigation to a coding agent. Claude Code, with the SecureNow MCP server, read the conversation so far, then asked 13 more questions in the same thread. The most important one followed the invite:
- The way back in. From the victim's account, the attacker created an org and a pending admin invitation to an outside address. The app returns the invite link to whoever creates it, so the attacker has it. A password reset doesn't touch it.
- Sessions and devices. A session issued at the password change, and a device-trust cookie set at the attacker's sign-in.
- No defence fired. The stuffing wave made 3 attempts per address against a limit of 15, and at most 24 per account against a lockout at 30.
- Nobody else. The other five targeted accounts had no new sign-in and no changes.
It also checked the chat's own summaries against their rows and found three that didn't match, including one that said 10 denials where the rows showed 3. Then it wrote the incident report, with seven actions in order and the evidence for each. The first: revoke the invite and delete the org, before resetting anything.
Before you close it: what a password reset doesn't undo
Question 8 is the one most responses skip. After any account takeover, look for what the attacker created or changed, not only what they read:
- invitations, new members and role changes, in every org or team the account belongs to
- active sessions, and remember-me or device-trust cookies
- the recovery email or phone, and any MFA device added
- API keys, personal access tokens and webhooks
- OAuth grants and linked social sign-ins
- notification and forwarding settings
If your app records these as security events (see Plug SecureNow into your app for track()), each one is a single question away.
Asking the questions in SecureNow
The same conversation works from three places, and each one sees what the others asked.
Dashboard. Open Forensics, pick the app and the environment, and ask. Every answer shows its SQL, its rows and What this shows. Trace IDs in a result open the request they came from, and Save to library keeps a query for the next incident.
Terminal.
npx securenow forensics conversations
npx securenow forensics chat --conversation <id> "What else did that address do?"
npx securenow forensics chat --app <app-key> --env production "<first question of a new investigation>"
Coding agent (MCP).
claude mcp add securenow -- npx securenow mcp
Then give the agent the conversation ID and what you need answered. It continues the same conversation with securenow_forensics_chat (passing conversationId), and can read it first with securenow_forensics_conversation_get.
Every query only reads: only SELECT runs, scoped to the one app and environment you picked.
Check the rows
Each answer comes with a written summary, made from the rows the query returned. It's usually right, and it saves you from decoding a table at midnight. But it's a reading of the rows, and in our run three of them misstated something. Before you act on a number, glance at the rows under it. When they disagree, the rows win.
Try it
The whole incident is reproducible: the lab, the incident script and the agent prompt are on the tutorial/ep10-forensics branch, and the Academy episode has the step-by-step walkthrough with the real terminal output. To catch the stuffing wave before anyone has to investigate it, see Detecting credential stuffing in Express in 30 lines.
Next time something looks wrong, don't start by decoding a dashboard. Ask the question you have. Then ask the next one.
Frequently Asked Questions
Where does the evidence of an account takeover live?
In four places that don't share one key: request traces (address, path, status, user agent), the log lines your own code writes, errors, and security events such as sign-ins, failed sign-ins, password changes, invites and permission checks. Events know the user but not the request, and requests know the address but not the user, so an investigation chains from one to the other.
Do I need to write SQL to investigate?
No. SecureNow's forensics chat turns a plain-English question into one read-only SELECT query, scoped to one app and one environment, shows it to you, runs it and writes what the rows show. Every follow-up can refer to earlier answers, such as 'that address'.
Can I continue the same investigation from the terminal or a coding agent?
Yes. securenow forensics conversations lists the conversations, securenow forensics chat --conversation <id> continues one from the terminal, and the securenow_forensics_chat MCP tool continues it from an agent such as Claude Code when you pass the conversationId.
Can the forensics chat change data?
No. Only SELECT queries run, and each one is checked before it runs to stay inside the app and environment you picked.
Should I trust the written summary of each answer?
Read it, then check it against the rows under it. The summary is written from the rows, but it can misstate a count: in our run it said 10 denials where the rows showed 3. When they disagree, the rows win.
Is resetting the password enough after an account takeover?
Often not. Check what the attacker created or changed that a reset doesn't undo: invitations and new members, sessions and remember-me or device cookies, the recovery email or phone, MFA devices, API keys and tokens, and OAuth grants. In our run, the way back in was a pending admin invitation to an outside address.
Recommended reading
One login, 100 rotating IPs: a 10-per-minute-per-IP limit let 275 of 275 requests through. How to spot rotation in your traffic and what to key your limits on.
Oct 27Add rate limits by route and method, counted per IP, per API key, per session cookie or globally, written in plain English and shared across servers with Redis.
Oct 27Failed-login spikes, 404 bursts, one IP trying many usernames: how to tell if your web app is under attack, confirm it in your logs, and act in hour one.
Oct 26