Skip to content

Get Started

This guide walks through adding CAPTCHA verification to a public route. You will create a Captcha guard from a provider page, attach it to a route, submit the provider token from the client, and use manual verification when a route needs custom control flow.

Use one provider page to create the signupCaptcha guard:

  • Cloudflare Turnstile creates and maintains Turnstile widgets during deploy, and can adopt an existing Turnstile widget.
  • Google reCAPTCHA uses an existing Google reCAPTCHA widget with a public sitekey and a Layeron secret reference.
Terminal window
import { backend } from "@layeron/core"
import { captcha } from "@layeron/modules"
const app = backend({ project: "shop" })
const signupCaptcha = captcha.turnstile({
name: "signup",
domains: ["app.example.com"],
action: "signup",
})

Add the guard to the route use list.

Terminal window
app.post("/signup", { use: [signupCaptcha] }, async () => {
return { ok: true }
})

At deploy time, Layeron creates or adopts the provider resource and adds Gateway Worker bindings:

Terminal window
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SECRET

Use the CLI after deployment to print the public sitekey for frontend rendering:

Terminal window
layer captcha sitekeys --name signup

At request time, Gateway extracts the CAPTCHA token, verifies it with the provider Siteverify API, validates configured provider claims, and continues to the handler when verification succeeds.

Use automatic protection for endpoints where the entire route should be gated, such as signup, contact forms, password reset requests, and checkout steps. The handler does not need to call Captcha directly in this path.

Render the provider widget on the page and submit the token to your Layeron route. Each provider page lists its default token names.

For JSON requests, send the token in the body or header.

Terminal window
await fetch("/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email,
cfTurnstileResponse: captchaToken,
}),
})

Customize them when your frontend uses different names.

Terminal window
captcha.turnstile({
name: "signup",
tokenFrom: {
header: "x-captcha-token",
json: "captchaToken",
},
})

Use manual verification when the route needs custom control flow, staged form handling, multiple challenge points, or a response format controlled by your handler.

Terminal window
const checkoutCaptcha = captcha.turnstile({
name: "checkout",
domains: ["app.example.com"],
action: "checkout",
failure: {
status: 400,
body: { error: "human_verification_required" },
},
})
app.use(checkoutCaptcha)
app.post("/checkout", async (request) => {
const result = await checkoutCaptcha.verify(request)
if (!result.ok) {
return result.response()
}
return { ok: true }
})

For JSON APIs and SPAs, pass the token explicitly:

Terminal window
app.post("/api/signup", async (request) => {
const body = await request.json()
const result = await signupCaptcha.verifyToken(body.captchaToken, {
request,
action: "signup",
hostname: "app.example.com",
})
if (!result.ok) {
return result.response()
}
return { ok: true }
})

Manual verification returns a structured result:

Terminal window
{
ok: boolean
success: boolean
errorCodes: string[]
hostname?: string
action?: string
cData?: string
score?: number
challengeTs?: string
response(): Response
}

verify(request) extracts the token from the configured tokenFrom sources. verifyToken(token, input) uses the explicit token and still uses request for remote IP, request identity, and route-host validation. Pass hostname, action, cData, or minScore when the handler needs stricter per-call validation than the module defaults.

Configure domains for production routes so Layeron validates the returned provider hostname against an explicit allowlist.

Terminal window
captcha.turnstile({
name: "signup",
domains: ["app.example.com"],
action: "signup",
})

Use distinct Captcha instances for flows with different risk profiles.

Terminal window
const signupCaptcha = captcha.turnstile({ name: "signup", action: "signup" })
const checkoutCaptcha = captcha.turnstile({ name: "checkout", action: "checkout" })
const passwordResetCaptcha = captcha.turnstile({ name: "password-reset", action: "password_reset" })

Recommended production settings:

  • Use domains for public hosts and preview hosts that should accept tokens.
  • Use stable name values so deployment state can track widget ownership.
  • Keep failure.body stable for API clients.
  • Send the client token over HTTPS and verify it once per sensitive action.
  • Treat invalid-input-response and timeout-or-duplicate as retryable user flows on the client.

missing_token means the route did not receive a token in any configured source. Check the client widget render and the tokenFrom names.

hostname_mismatch means the provider returned a hostname outside the configured domain policy. Check domains, the request host, and any preview domain setup.

action_mismatch means the widget action and server action differ. Keep data-action and captcha.turnstile({ action }) or captcha.recaptcha({ action }) aligned.