Skip to main content
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.

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, and they can submit the form within a second of the page loading. getSession() waits for fingerprinting to finish, so the handoff for these users can take a moment longer, but you never need to disable the form.
  • 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

1

Start Foil on page load

Collection begins. You don’t need to disable the form while Foil collects, because getSession() waits for fingerprinting to finish. Users arriving cold can submit right away.
2

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

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

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.

Client integration

Call getSession() lazily at password submit. getSession() waits for fingerprinting to finish before it returns a handoff, so you don’t need to call waitForFingerprint() first.
If the script fails to load or getSession() fails, the page sends the login request without a handoff, so a blocked script doesn’t leave the form unresponsive. The server treats a request without a valid token like a bot verdict, as the next section shows.

Server verification

Decisioning policy

Four 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.
  • Fail closed and reject stale tokens. A request with a missing, invalid, or expired token gets the same generic error as a bot verdict. Don’t accept the login without a token, because an attacker could skip Foil by omitting it. The server SDKs don’t check a token’s age, so reject a token whose decision.evaluated_at is more than a few minutes old. See Verify the token on your server for the check in each language.

Rate-limiting by visitor fingerprint

The verified token exposes visitor_fingerprint.id - a per-device identifier that survives cookie clears, incognito mode, and residential-proxy IP rotation. It’s the right key for login attempt counters.
Node.js
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.
The server SDKs type visitor_fingerprint as nullable, so the example checks for a missing ID. Foil resolves the visitor fingerprint before it issues a token, so a token from the browser SDK carries one. If your code does see a missing ID, skip the fingerprint counter and rely on the IP counter instead of blocking the request.

What’s next

Signup protection

Block automated account creation.

Server verification

Reference for the underlying verification primitive.

Going to production

Rollout plan and monitoring.

Verdicts & scoring

Understand what inconclusive actually means.