> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tracenow.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Stop account takeover

> Detect credential stuffing, brute force, and suspicious logins at the edge and in your app, without adding friction for real users.

Account takeover (ATO) is the most damaging fraud vector for most products. Attackers buy credential lists, stuff them against your login endpoint, and then monetize whatever they find inside: stored payment methods, loyalty points, personal data. The attack is quiet by design: no new accounts, no unusual emails, just valid credentials moving through your normal login flow.

Stopping ATO requires two layers. The Cloudflare Worker catches bots, Tor, and stuffing bursts at the edge. The in-app `/trace` call (one backend call after authentication) unlocks the per-user signals that detect a real account accessed from a new country, a new device, or an impossible location. **You need both to fully close the gap.**

## Which event this protects

ATO maps to the `login` event. The Worker fires it on every POST to your login route. The in-app call fires it again after your authentication handler runs, this time with the confirmed `subject_user_id` and real `status`.

## How it works

### Edge layer (no code)

The Worker fires on every POST to your login route:

1. Reads the submitted email and IP.
2. Attaches the device fingerprint from the `x-tn-device-token` header.
3. Calls `/trace` with `event: login, status: attempted`.
4. Evaluates your ATO policy against velocity and device/IP signals.
5. `deny` → blocks at the edge. `challenge` / `allow` → forwarded to origin.

At the edge, `subject_user_id` is null because no one is authenticated yet. User-keyed signals (`velocity.user.*`, `is_new_for_user`, `is_new_country_for_user`) fall back to the submitted email. That's enough to catch stuffing and bots, but not enough to catch a legitimate-looking login from a stolen password.

### In-app layer (one backend call)

After your authentication handler confirms the password and issues a session, send a second `/trace` with the real `subject_user_id` and confirmed `status`. This call unlocks the full per-user signal set. The edge trace is already in the system, so this merges the history correctly.

Act on the verdict immediately: `challenge` means require step-up (MFA, CAPTCHA); `deny` means block the session.

### Signals used

| Signal                           | Layer                           | Why it matters                                                               |
| -------------------------------- | ------------------------------- | ---------------------------------------------------------------------------- |
| `velocity.ip.failed_count_1h`    | Edge                            | Failed logins per IP; the primary stuffing signal                            |
| `event.status`                   | Edge + In-app                   | Combine with velocity to distinguish stuffing from slowdowns                 |
| `velocity.user.failed_count_1h`  | Edge (email) / In-app (user ID) | Brute-forcing a single account                                               |
| `device.is_headless`             | Edge                            | Headless browser = no human                                                  |
| `device.automation_detected`     | Edge                            | WebDriver / Puppeteer fingerprint                                            |
| `ip.is_tor`                      | Edge                            | Tor exit nodes hide the attacker's origin                                    |
| `ip.is_proxy`                    | Edge                            | Open proxies are standard ATO infrastructure                                 |
| `ip.is_vpn`                      | Edge                            | VPNs combined with other signals narrow to suspicious logins                 |
| `ip.is_new_country_for_user`     | In-app                          | This account has never logged in from this country before                    |
| `ip.impossible_travel`           | In-app                          | Login from two locations physically impossible in the elapsed time           |
| `velocity.user.distinct_ips_24h` | In-app                          | One account from many IPs; indicates distributed stuffing or account sharing |
| `device.is_new_for_user`         | In-app                          | First time this device has been seen for this user                           |
| `device.timezone_ip_mismatch`    | Edge + In-app                   | Device timezone doesn't match the IP's geo; a proxy or VPN indicator         |

See [Signals](/concepts/signals) for the full field reference.

## Set it up

<Tip>
  **Fastest setup:** enable the pre-built [Account takeover policy templates](/guides/policy-templates)
  in Monitor mode instead of building these rules by hand. The new-device, new-country, brute-force,
  and impossible-travel templates reach full accuracy once you add the in-app call in step 4.
</Tip>

<Steps>
  <Step title="Connect Cloudflare (if you haven't already)">
    In the dashboard under **Integration**, paste a Cloudflare API token with **Workers Scripts: Edit**
    and **Zone → DNS: Read** scopes. tracenow validates the token and lists your zones.
  </Step>

  <Step title="Add a route rule for your login endpoint">
    Under **Integration → Routes**, add a rule:

    * **Route pattern:** e.g. `yourdomain.com/api/auth/login` or `yourdomain.com/login`
    * **Method:** `POST`
    * **Event:** `login`
    * **Response mapping:** map HTTP `200` → `succeeded`, `401` / `403` → `failed`
    * **Mode:** start with `Monitor`

    The response mapping is how failed-login velocity works: the Worker reads your origin's status code and fires an outcome trace with the real `succeeded`/`failed` status.
  </Step>

  <Step title="Enable the Account takeover policies">
    Under **Guard**, enable the pre-built [Account takeover policy templates](/guides/policy-templates) for the `login` event in **Monitor** mode. To customize or add your own rules, see [Rules](/guides/rules).
  </Step>

  <Step title="Add the in-app call for full per-user coverage">
    In your authentication handler, after confirming credentials and resolving the user, call `/trace` with the real `subject_user_id` and confirmed `status`. See [Going further](#going-further) below.

    This is what unlocks new-device, new-country, impossible-travel, and distinct-IP signals.
  </Step>

  <Step title="Switch to Enforce mode">
    After reviewing Monitor traces, go to **Integration → Routes**, change the login route to `Enforce`, and click **Deploy**.

    <Note>
      In Enforce mode, only `deny` verdicts are enforced at the edge. `challenge` verdicts flow to your origin; enforce them in your authentication handler via the in-app call verdict.
    </Note>
  </Step>
</Steps>

## Rules

You don't build these by hand. The pre-built [Account takeover policy templates](/guides/policy-templates) cover these patterns and enable in one click. See [Rules](/guides/rules) to customize them or add your own.

## What a caught attempt looks like

A stuffing wave produces many traces like this in the event feed:

A suspicious successful login (new country) looks different:

```json theme={null}
{
  "trace_id": "tr_01hz...",
  "verdict": "deny",
  "event_type": "login",
  "status": "failed",
  "signals": {
    "ip": {
      "is_tor": false,
      "is_vpn": false,
      "is_proxy": false,
      "geo": { "country": "Russia", "country_code": "RU" }
    },
    "device": {
      "is_headless": false,
      "automation_detected": false,
      "timezone_ip_mismatch": true
    },
    "velocity": {
      "ip": { "trace_count_1h": 94, "failed_count_1h": 87 },
      "user": { "failed_count_1h": 0, "distinct_ips_24h": 1 }
    },
    "decision": {
      "rule_name": "Block credential stuffing",
      "logic": "AND",
      "matched_conditions": []
    }
  },
  "policy": {
    "name": "Account takeover",
    "action": "deny",
    "rule_name": "Block credential stuffing"
  }
}
```

## Going further

The edge layer stops bots and stuffing without per-user history. The in-app call is what makes tracenow aware of this specific user's normal behavior: their usual countries, devices, and login patterns.

Add this call to your authentication handler **after** you've confirmed the user's identity:

<CodeGroup>
  ```typescript TypeScript theme={null}
  // After verifying credentials and resolving user.id
  const result = await fetch("https://api.tracenow.io/trace", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TRACENOW_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event: "login",
      status: "succeeded",          // or "failed" if auth failed
      ip: req.ip,
      email: user.email,
      device_token: req.headers["x-tn-device-token"], // injected by the Worker
      subject_user_id: user.id,     // the real user ID — this is the key addition
    }),
  });

  const { verdict } = await result.json();

  if (verdict === "deny") {
    return res.status(403).json({ error: "Login blocked" });
  }

  if (verdict === "challenge") {
    return res.status(200).json({ requires_mfa: true });
  }

  // verdict === "allow" — issue session normally
  return issueSession(user);
  ```

  ```python Python theme={null}
  import httpx, os

  result = httpx.post(
    "https://api.tracenow.io/trace",
    headers={"Authorization": f"Bearer {os.environ['TRACENOW_SECRET_KEY']}"},
    json={
      "event": "login",
      "status": "succeeded",
      "ip": request.remote_addr,
      "email": user.email,
      "device_token": request.headers.get("x-tn-device-token"),
      "subject_user_id": str(user.id),
    },
  ).json()

  if result["verdict"] == "deny":
      abort(403)
  elif result["verdict"] == "challenge":
      return {"requires_mfa": True}
  ```
</CodeGroup>

<Tip>
  The `device_token` is already attached to the request as `x-tn-device-token` by the Cloudflare Worker. No frontend work needed; just forward the header.
</Tip>

Without `subject_user_id`, signals like `ip.is_new_country_for_user`, `ip.impossible_travel`, `device.is_new_for_user`, and `velocity.user.distinct_ips_24h` have no per-user history to compare against. With it, tracenow builds a behavioral baseline per account from the first login.

See [server-side integration](/guides/server-side) for the full request and response reference.
