> ## 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.

# Verdicts

> What allow, challenge, and deny mean, and exactly how to handle each in your app and at the edge.

Every `/trace` call returns a `verdict`. There are exactly three. Your code (and the Cloudflare Worker) switches on this value to enforce the outcome.

```json theme={null}
{
  "trace_id": "tr_...",
  "verdict": "allow",
  ...
}
```

## The three verdicts

| Verdict     | Meaning                                | Where it's enforced                     |
| ----------- | -------------------------------------- | --------------------------------------- |
| `allow`     | No policy matched (nothing suspicious) | Proceed normally                        |
| `challenge` | A policy matched with a soft action    | Step-up in your app (MFA, CAPTCHA, OTP) |
| `deny`      | A policy matched with a hard block     | Block the request (403 or redirect)     |

`allow` is the default. It means every rule in your policies evaluated the signals and nothing triggered. There is no `block` value. The correct term is `deny`.

## allow

No rule matched. The user passed every condition in every policy. Proceed normally: issue a session, complete the transaction, send the OTP.

```typescript theme={null}
if (trace.verdict === "allow") {
  return issueSession(user);
}
```

## challenge

A rule matched and its action is `challenge`. Add friction, but don't hard-block. Use this for situations where you want the user to prove they're human before you let them continue: a new device on a known account, a VPN login, a slightly suspicious email domain.

`challenge` means "make them work for it." The specific mechanism is up to you: MFA, CAPTCHA, email OTP, SMS verification.

### Handling challenge in your app

The canonical pattern is a `requires_challenge` flag in your response body that your frontend interprets:

```typescript TypeScript (Node) theme={null}
switch (trace.verdict) {
  case "allow":
    return issueSession(user);

  case "challenge":
    // Don't issue a session yet. Tell the frontend to show step-up.
    return res.status(200).json({
      requires_challenge: true,
      challenge_type: "mfa",   // or "captcha", "otp" — your choice
    });

  case "deny":
    return res.status(403).json({ error: "Access denied" });
}
```

Your frontend reads `requires_challenge` and redirects to your MFA or CAPTCHA flow. After the user completes step-up, call `/trace` again with the same signals plus `status: "succeeded"` if they passed. This second trace records the real outcome and re-evaluates against your policies.

<Tip>
  After the user completes step-up, send a second `/trace` call with `status: "succeeded"`. This updates the velocity counters and lets status-dependent rules (like "succeeded login from new country") evaluate correctly.
</Tip>

### Challenge types

<AccordionGroup>
  <Accordion title="MFA (authenticator app, hardware key)">
    Best for account-takeover signals (new device, impossible travel, new country). The user already has an authenticator enrolled. Redirect to your existing MFA flow.
  </Accordion>

  <Accordion title="CAPTCHA">
    Best for bot/automation signals such as `device.is_headless`, `device.automation_detected`, and high velocity. A CAPTCHA distinguishes a real human from a script. Use any provider (hCaptcha, reCAPTCHA, Turnstile).
  </Accordion>

  <Accordion title="Email or SMS OTP">
    Best for new-device or low-trust scenarios where the user doesn't have MFA enrolled. Sends a one-time code to the address or phone number on the account.
  </Accordion>
</AccordionGroup>

## deny

A rule matched and its action is `deny`. Hard block: don't issue a session, don't process the transaction, don't send the OTP. Return an error.

```typescript theme={null}
if (trace.verdict === "deny") {
  return res.status(403).json({ error: "Access denied" });
}
```

## Edge behavior: what the Worker does

The Cloudflare Worker enforces verdicts differently from your backend, because it sits in front of your app and can't render your MFA flow.

| Verdict     | Edge behavior                               | In-app behavior                             |
| ----------- | ------------------------------------------- | ------------------------------------------- |
| `allow`     | Forward to origin                           | Proceed normally                            |
| `challenge` | **Forward to origin**                       | Return `requires_challenge`, handle step-up |
| `deny`      | **Block here** (return configured response) | Return 403                                  |

The critical difference: **at the edge, only `deny` blocks**. `challenge` is always forwarded to your origin. The Worker cannot render your MFA page or CAPTCHA (that lives in your app), so `challenge` enforcement is always the in-app call's job.

<Warning>
  If you rely solely on the edge (no in-app `/trace` call), a `challenge` verdict will reach your origin as a normal forwarded request with no enforcement. To actually enforce challenge, you need the in-app call. See [Server-side integration](/guides/server-side).
</Warning>

This is intentional and correct. Think of it as division of responsibilities:

* The edge handles `deny` (bots, Tor, known-bad IPs, disposable emails): hard blocks before your app sees a single byte.
* Your app handles `challenge` (step-up friction for real users who look slightly suspicious).

### Configuring the deny response at the edge

In Enforce mode, you configure what the Worker returns on `deny`. The default is a `403` with an empty body. You can override it per route rule:

| Override type      | Use it when                                 |
| ------------------ | ------------------------------------------- |
| Custom status code | You want a different HTTP status (e.g. 401) |
| 302 redirect URL   | You want to send blocked users to a page    |
| Custom HTML body   | The route serves a browser page             |
| Custom JSON body   | The route is an API/XHR endpoint            |

## When a policy is triggered

When `verdict` is `challenge` or `deny`, the response includes a `policy` object telling you exactly which rule fired:

```json theme={null}
{
  "trace_id": "tr_...",
  "verdict": "deny",
  "policy": {
    "id": "pol_...",
    "name": "Block credential stuffing",
    "action": "deny",
    "rule_id": "rul_...",
    "rule_name": "High failed-login velocity from IP"
  },
  "signals": {
    "decision": {
      "rule_id": "rul_...",
      "rule_name": "High failed-login velocity from IP",
      "logic": "AND",
      "matched_conditions": []
    }
  }
}
```

Use `policy.rule_name` for logging. Avoid showing it to the user, as it reveals your detection logic.

## When no rule matches

When `verdict` is `allow`, the `policy` object is absent from the response. `signals_complete: true` means all enrichment resolved. If `signals_complete: false`, some async enrichment was still pending and the verdict was made on partial data. For high-stakes decisions, retry after \~500 ms to get the warm-cache result.

## Full verdict handling pattern

```typescript TypeScript (Node) theme={null}
const trace = 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: loginSucceeded ? "succeeded" : "failed",
    subject_user_id: user.id,
    email: user.email,
    ip: clientIp,
    device_token: req.headers["x-tn-device-token"],
  }),
}).then((r) => r.json());

switch (trace.verdict) {
  case "allow":
    // Nothing matched. Issue the session.
    return issueSession(user);

  case "challenge":
    // A rule matched with a soft action. Add friction.
    // Don't issue a session until step-up completes.
    return res.status(200).json({
      requires_challenge: true,
      challenge_type: determineChallengeType(trace),
    });

  case "deny":
    // A rule matched with a hard block.
    return res.status(403).json({ error: "Access denied" });
}
```

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

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

match trace["verdict"]:
    case "allow":
        return issue_session(user)
    case "challenge":
        return {"requires_challenge": True}
    case "deny":
        return Response({"error": "Access denied"}, status=403)
```

## Where to go next

<Columns cols={2}>
  <Card title="Server-side integration" icon="server" href="/guides/server-side">
    The full /trace request and response contract: every field, getting the client IP, and handling signals\_complete.
  </Card>

  <Card title="Rules and policies" icon="list-check" href="/guides/rules">
    Define the conditions that trigger challenge or deny: fields, operators, logic, and ordering.
  </Card>
</Columns>
