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

# Login & credential stuffing

> Block account takeover at login. Use Foil to detect credential stuffing, step up inconclusive sessions, and rate-limit by visitor fingerprint, not just IP.

<Info>
  Login is a hot path. A real user will hit it dozens of times a month, so your error budget for false positives is small. The right pattern is a narrow action-time check at password submit — not a page-load probe — paired with a step-up challenge on `inconclusive` rather than a block.
</Info>

## The threat

Credential-stuffing tools replay dumps of leaked `email:password` pairs against login endpoints at high volume. The goal is account takeover: any hit becomes a compromised account. Unlike signup, the attacker isn't trying to create *new* identity — they're trying to prove that an existing identity is theirs. That changes the policy calculus.

The pattern to detect is automated typing and submission: a headless browser driving a form fill, or an HTTP-level script bypassing the UI entirely. Foil's `automation` attribution (Playwright/Puppeteer/Selenium) and `ai-agent` attribution (LLM-driven login) both land here.

Two properties of the login surface shape the integration:

* **Users arrive hot.** They click a link in an email, they open a bookmark, they come back from a timeout. Fingerprint readiness at page load is aspirational, not guaranteed.
* **False positives are expensive.** Locking a human out of their own account is a worse user experience than letting a bot try three times against a non-matching password. The verdict is one input among several — Foil goes on one side of a scale that already has rate limiting and MFA on it.

## The flow

<Steps>
  <Step title="Start Foil on page load">
    Collection begins. Don't block the form on `waitForFingerprint()` — users arriving cold should still be able to submit.
  </Step>

  <Step title="Call getSession() at password submit">
    Not on page load. You want the freshest possible observation set, and you want collection to include the keystroke and mouse signals from the user typing their password.
  </Step>

  <Step title="Verify and fold into your auth decision">
    A `bot` verdict returns the same generic error as a wrong password. An `inconclusive` verdict with an otherwise-valid password triggers a step-up challenge.
  </Step>

  <Step title="Rate-limit by visitor fingerprint, not just IP">
    The verified token carries a `visitor_fingerprint.id`. Use it to apply per-device limits that survive residential-proxy IP rotation.
  </Step>
</Steps>

## Client integration

Call `getSession()` lazily at password submit. Don't await `waitForFingerprint()` — if fingerprinting has resolved by submit, great; if not, Foil still returns a session based on the snapshot and behavioral evidence it has.

```html theme={"dark"}
<script type="module">
  const foilPromise = import("https://cdn.usefoil.com/t.js").then(
    (Foil) =>
      Foil.start({
        publishableKey: "pk_live_your_publishable_key",
      }),
  );

  document.querySelector("#login-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const foil = await foilPromise;
    const { sessionId, sealedToken } = await foil.getSession();

    await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: e.target.email.value,
        password: e.target.password.value,
        foil: { sessionId, sealedToken },
      }),
    });
  });
</script>
```

## Server verification

<CodeGroup>
  ```javascript Node.js theme={"dark"}
  const { safeVerifyFoilToken } = require("@abxy/foil-server");

  app.post("/api/login", async (req, res) => {
    const foilResult = safeVerifyFoilToken(
      req.body.foil.sealedToken,
      process.env.FOIL_SECRET_KEY,
    );

    // Check the password regardless — don't skip the bcrypt compare.
    // A bot should not be able to use timing to distinguish states.
    const passwordMatches = await verifyPassword(req.body.email, req.body.password);

    if (!foilResult.ok || foilResult.data.decision.verdict === "bot") {
      // Generic error — same response shape as wrong password.
      return res.status(401).json({ error: "Invalid credentials" });
    }

    if (!passwordMatches) {
      return res.status(401).json({ error: "Invalid credentials" });
    }

    const verdict = foilResult.data.decision.verdict;
    const visitorId = foilResult.data.visitor_fingerprint?.id;

    if (verdict === "inconclusive") {
      // Password is correct, but we're not confident. Require a second factor.
      const challengeToken = await issueStepUpChallenge(req.body.email, visitorId);
      return res.json({ status: "step_up", challengeToken });
    }

    const session = await issueLoginSession(req.body.email);
    res.json({ status: "ok", session });
  });
  ```

  ```python Python theme={"dark"}
  from foil_server import safe_verify_foil_token
  import os

  @app.post("/api/login")
  def login(request):
      foil_result = safe_verify_foil_token(
          request.json["foil"]["sealedToken"],
          os.environ["FOIL_SECRET_KEY"],
      )
      password_matches = verify_password(
          request.json["email"], request.json["password"]
      )

      if not foil_result.ok or foil_result.data.decision.verdict == "bot":
          return {"error": "Invalid credentials"}, 401

      if not password_matches:
          return {"error": "Invalid credentials"}, 401

      verdict = foil_result.data.decision.verdict
      visitor_id = (foil_result.data.visitor_fingerprint or {}).get("id")

      if verdict == "inconclusive":
          challenge = issue_step_up_challenge(request.json["email"], visitor_id)
          return {"status": "step_up", "challengeToken": challenge}

      return {"status": "ok", "session": issue_login_session(request.json["email"])}
  ```

  ```go Go theme={"dark"}
  import foil "github.com/abxy-labs/foil-server-go"

  func loginHandler(w http.ResponseWriter, r *http.Request) {
      var body struct {
          Email    string `json:"email"`
          Password string `json:"password"`
          Foil struct {
              SealedToken string `json:"sealedToken"`
          } `json:"foil"`
      }
      json.NewDecoder(r.Body).Decode(&body)

      tr := foil.SafeVerifyFoilToken(
          body.Foil.SealedToken,
          os.Getenv("FOIL_SECRET_KEY"),
      )
      passwordOK := verifyPassword(body.Email, body.Password)

      if !tr.OK || tr.Data.Decision.Verdict == "bot" || !passwordOK {
          http.Error(w, "Invalid credentials", 401)
          return
      }

      if tr.Data.Decision.Verdict == "inconclusive" {
          challenge := issueStepUpChallenge(body.Email, tr.Data.VisitorFingerprint.ID)
          json.NewEncoder(w).Encode(map[string]string{
              "status":         "step_up",
              "challengeToken": challenge,
          })
          return
      }

      json.NewEncoder(w).Encode(map[string]any{
          "status":  "ok",
          "session": issueLoginSession(body.Email),
      })
  }
  ```

  ```ruby Ruby theme={"dark"}
  require "foil/server"

  post "/api/login" do
    body = JSON.parse(request.body.read)
    tr = Foil::Server::SealedToken.safe_verify_foil_token(
      body.dig("foil", "sealedToken"),
    )
    password_ok = verify_password(body["email"], body["password"])

    decision = tr[:ok] ? tr[:data][:decision] : nil
    visitor  = tr[:ok] ? tr[:data][:visitor_fingerprint] : nil

    if !tr[:ok] || decision[:verdict] == "bot" || !password_ok
      halt 401, { error: "Invalid credentials" }.to_json
    end

    if decision[:verdict] == "inconclusive"
      challenge = issue_step_up_challenge(body["email"], visitor && visitor[:id])
      { status: "step_up", challengeToken: challenge }.to_json
    else
      { status: "ok", session: issue_login_session(body["email"]) }.to_json
    end
  end
  ```

  ```php PHP theme={"dark"}
  use Foil\Server\SealedToken;

  $body = json_decode(file_get_contents("php://input"), true);
  $tr = SealedToken::safeVerify(
      $body["foil"]["sealedToken"],
      getenv("FOIL_SECRET_KEY"),
  );
  $passwordOk = verifyPassword($body["email"], $body["password"]);

  if (!$tr->ok || $tr->data->decision["verdict"] === "bot" || !$passwordOk) {
      http_response_code(401);
      echo json_encode(["error" => "Invalid credentials"]);
      exit;
  }

  if ($tr->data->decision["verdict"] === "inconclusive") {
      $challenge = issueStepUpChallenge($body["email"], $tr->data->visitor_fingerprint["id"] ?? null);
      echo json_encode(["status" => "step_up", "challengeToken" => $challenge]);
      exit;
  }

  echo json_encode(["status" => "ok", "session" => issueLoginSession($body["email"])]);
  ```

  ```bash cURL theme={"dark"}
  # Durable readback — useful for async audit jobs, not the login hot path.
  curl https://api.usefoil.com/v1/sessions/sid_... \
    -H "Authorization: Bearer sk_live_..."
  ```
</CodeGroup>

## Decisioning policy

| Verdict                            | Recommended action on login                          |
| ---------------------------------- | ---------------------------------------------------- |
| `human` + password matches         | Issue a session.                                     |
| `human` + password mismatch        | Return generic `Invalid credentials`.                |
| `inconclusive` + password matches  | Require a second factor (TOTP, email OTP, WebAuthn). |
| `inconclusive` + password mismatch | Return generic `Invalid credentials`.                |
| `bot`                              | Return generic `Invalid credentials`. Always.        |

Three things to get right:

* **Always check the password.** Skipping the bcrypt compare on a `bot` verdict creates a timing oracle. Run the comparison, throw the result away if Foil blocks.
* **Return identical responses for bot-detected and password-mismatch.** Status code, body shape, timing. Any difference is signal for an attacker automating against your endpoint.
* **Prefer step-up over block on `inconclusive`.** If the password is correct and the verdict is ambiguous, a legitimate user can pass a TOTP prompt. A bot running on a dump of leaked credentials usually can't.

## Rate-limiting by visitor fingerprint

The verified token exposes `visitor_fingerprint.id` — a durable per-device identifier that survives cookie clears, incognito mode, and residential-proxy IP rotation. It's the right key for login attempt counters.

```javascript Node.js theme={"dark"}
const redis = require("redis");
const { safeVerifyFoilToken } = require("@abxy/foil-server");
const client = redis.createClient();

async function checkLoginRateLimit(foilToken) {
  const result = safeVerifyFoilToken(foilToken, process.env.FOIL_SECRET_KEY);
  if (!result.ok) return { allowed: false, reason: "no_token" };

  const visitorId = result.data.visitor_fingerprint?.id;
  if (!visitorId) return { allowed: true };

  const key = `login_attempts:${visitorId}`;
  const attempts = await client.incr(key);
  if (attempts === 1) await client.expire(key, 3600); // 1h window

  if (attempts > 10) {
    return { allowed: false, reason: "rate_limited", visitorId };
  }
  return { allowed: true, visitorId };
}
```

This is complementary to IP-based rate limiting, not a replacement. Run both. IP counters catch volumetric attacks; fingerprint counters catch distributed attacks routed through rotating residential proxies where each request comes from a different IP but the underlying device is the same.

<Note>
  `visitor_fingerprint` is `null` on sessions where Foil couldn't establish a durable ID (typically: hardened privacy browsers, very short sessions). Treat a missing visitor ID as "skip the fingerprint rate-limiter, rely on IP" rather than as a signal to block.
</Note>

## What's next

<CardGroup cols={2}>
  <Card title="Signup protection" icon="user-plus" href="/use-cases/signup">
    Block automated account creation.
  </Card>

  <Card title="Server verification" icon="shield-check" href="/server-verification">
    Reference for the underlying verification primitive.
  </Card>

  <Card title="Going to production" icon="rocket" href="/going-to-production">
    Rollout plan and monitoring.
  </Card>

  <Card title="Verdicts & scoring" icon="gauge" href="/verdicts-and-scoring">
    Understand what `inconclusive` actually means.
  </Card>
</CardGroup>
