Skip to main content
Before you turn enforcement on in production, confirm two things: your integration fires on bot traffic and it doesn’t fire on real users. This page walks through the patterns we recommend.

Test vs live keys

Foil issues separate test and live key pairs.
  • Test keys are prefixed pk_test_* / sk_test_*; live keys are pk_live_* / sk_live_*.
  • Sessions created with test keys are tagged in the dashboard so you can filter integration traffic out of real metrics.
  • Test and live keys are not interchangeable. A sealed token issued under a test publishable key cannot be verified with a live secret key — verification fails locally with a FoilTokenVerificationError. Keep the environments fully separate.
  • Everything else — verdicts, scoring, phases, fingerprinting — behaves identically across environments.

What verdicts to expect

An unmodified automation runner should land on bot every time. Stealth variants still land on bot but with lower confidence, and real user traffic should land on human.
RunnerExpected decision.verdictNotes
Playwright (default Chromium)bot, high risk_scorewebdriver_detected fires deterministically.
Puppeteer (default)bot, high risk_scoreSame.
Selenium / WebDriverbot, high risk_scoreSame.
playwright-stealth, puppeteer-extra-stealthbot, lower risk_scoreattribution.bot.facets.concealment_style typically stealth.
Headless Chromium via --headless=new with no automation librarybotEnvironment probes catch it; no behavioral signal needed.
Real browser with real user interaction before submithuman, phase: "behavioral"Behavioral signals accumulate once the user moves and types.
If you’re seeing consistently different verdicts than these, something is off in the integration — usually the sealed token isn’t reaching the verifier, or getSession() is being called against a different Foil.start() instance than the one your page initialized.

Runnable Playwright example

This asserts end-to-end that a bot gets blocked at signup:
import { test, expect, chromium } from '@playwright/test';

test('bot traffic is blocked at signup', async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  await page.goto('http://localhost:3000/signup');
  await page.fill('input[name="email"]', 'bot@example.com');
  await page.fill('input[name="password"]', 'correct-horse-battery-staple');

  const [response] = await Promise.all([
    page.waitForResponse('**/api/signup'),
    page.click('button[type="submit"]'),
  ]);

  expect(response.status()).toBe(403);
  await browser.close();
});
Run it with npx playwright test against your local dev server. If the assertion passes, your block path is wired correctly. Flip it to expect(response.status()).toBe(200) and run against your staging build to verify the allow path for manually-driven sessions. For Puppeteer, Selenium, or WebDriverIO, the pattern is the same — launch, navigate, submit, assert the response. Foil detects them all.

Forcing an inconclusive verdict

inconclusive is Foil saying “not enough signal either way.” The easiest way to produce one on purpose is to call getSession() immediately on page load, before any user interaction:
const foil = await Foil.start({ publishableKey: 'pk_test_...' });
const { sealedToken } = await foil.getSession();
// verify server-side — usually:
//   decision.verdict === "inconclusive"
//   decision.phase === "snapshot"
//   decision.is_provisional === true
Use this to test whatever challenge flow you serve on ambiguous sessions — CAPTCHA, email verification, step-up auth. If your integration treats inconclusive identically to human, you’re leaving coverage on the table; see Verdicts & scoring for the recommended policy split.

Debugging common issues

SymptomCauseFix
getSession() rejects with runtime.unavailableMethod called before start() resolved, or after destroy()Await the start() promise before any client method. See Browser SDK → API reference.
start() rejects with runtime.bootstrap_failedCSP blocks cdn.usefoil.com or wasm-eval, or the CDN load failsSee Content Security Policy.
Sealed token verification fails locally with no clear reasonMismatched environment — test-key token verified with a live secret key (or vice versa)Confirm pk_* and sk_* come from the same organization and environment.
Handoff verifies locally but requests to api.usefoil.com return auth.origin_not_allowedThe publishable key’s allowed-origin list doesn’t include your dev hostAdd http://localhost:3000 (or your dev origin) to the key’s allowed origins in the dashboard, or use an unrestricted test key.
Server receives sealedToken: undefinedRequest-body shape mismatchConfirm the browser sends JSON with a foil: { sessionId, sealedToken } field, and that your body parser runs before your verification middleware.
Most real test sessions score as inconclusivePage flow calls getSession() before any interaction accumulatesCall getSession() at action time (right before the fetch), not on page load. See Browser SDK → Best practices.

What’s next