Bot Protection
Verify endpoint-local browser challenges with explicit outage policy.
BotChallengeVerifier is the replaceable provider port. BotProtectionDecision selects skip/verify, expected action, hostname allowlist, and outage behavior.
Provider installation is not enforcement
Named guards skip bot verification by default. Providing CloudflareTurnstile.layer(...) does not modify CoreAuthHttpApiLive or standard operations.
Protect an endpoint
.withPolicy(...) creates an alternative guard; invoke it from an app-owned primitive handler. Exporting it alone changes nothing.
import { BotProtection } from "@effect-auth/core/AbuseProtection";
import {
PasswordGuards,
type PasswordSignInGuardRequest,
} from "@effect-auth/core/HttpApi";
const guard = PasswordGuards.signIn.withPolicy({
botProtection: BotProtection.verify({
action: "password-sign-in",
allowedHostnames: ["app.example.com"],
outageMode: "fail-closed",
}),
});
export const guardPasswordSignIn = (request: PasswordSignInGuardRequest) =>
guard(request);The result is redacted domain input after standard rate limiting, bot verification, and login enrichment. This seam is for a custom primitive handler, which must also preserve authentication continuations, public error mapping, session/cookie commitment, and auditing. There is currently no policy injection option for the standard password preset.
The browser must render/execute the Turnstile widget with the exact action and send { botChallenge: { token } }. Host allowlists contain hostnames only, without scheme, port, or path; matching is normalized to lowercase.
| Guard family | Supported operations |
|---|---|
PasswordGuards | Sign in/up, reset start/verify |
EmailGuards | Verification, OTP, email auth, Magic Link |
IdentityGuards | Authenticated availability |
Combined email start verifies one email-auth proof before creating OTP and Magic Link challenges. Do not verify the token again in both child flows.
Configure Turnstile on Cloudflare
Bind the secret with Alchemy v2:
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
const AuthWorker = Cloudflare.Worker("AuthWorker", {
main: "./src/workers/auth-backend.ts",
env: {
TURNSTILE_SECRET: Config.redacted("TURNSTILE_SECRET"),
},
});Create the runtime Layer from the Worker binding:
import * as CloudflareTurnstile from "@effect-auth/core/CloudflareTurnstile";
import * as Redacted from "effect/Redacted";
export interface BotEnv {
readonly TURNSTILE_SECRET: string;
}
export const makeBotVerifierLive = (env: BotEnv) =>
CloudflareTurnstile.layer({
secret: Redacted.make(env.TURNSTILE_SECRET),
timeoutMs: 2_000,
retry: false,
});The adapter calls Cloudflare's fixed HTTPS siteverify endpoint, validates action/hostname, bounds token and response sizes, redacts the secret, rejects redirects, and applies a timeout. Retry is off by default; when enabled, one retry reuses an idempotency key.
Request ordering
Email-verification start is different: validate session, apply IP/user limit, resolve identity, apply email/principal/challenge limits, then verify the optional proof. Identity availability validates the session before its user-keyed limit. Trusted subjects never come from browser payloads.
| Outage mode | Retryable provider outage | Invalid/missing proof | Configuration failure |
|---|---|---|---|
fail-open | Continue | Reject | Fail safely |
fail-closed | Fail safely | Reject | Fail safely |
Fail-open applies only to errors tagged retryable. HTTP guards map invalid proof to request_rejected and provider/configuration failure to internal_error; never expose provider codes or payloads.
Test exact action/hostname, missing proof, rate-limit short-circuit, malformed/oversized response, timeout, outage modes, combined-email single verification, and deployed edge metadata trust. Never log proof tokens, secrets, remote IPs, or provider payloads.