[ Forms & abuse ]
@sdxc/captcha
Turnstile, hCaptcha and reCAPTCHA verification, with middleware and widgets
- Installs with
- @sdxc/get-client-ip@sdxc/result@sdxc/validate
- Used by
- uptime, auth-saas
- Source
- packages/captcha
Turnstile, hCaptcha and reCAPTCHA verification, with middleware and widgets.
Every provider implements one Captcha contract, so the middleware, the widgets and an
in-memory provider for tests work the same over any of them. A failure carries a neutral
code that separates what the visitor fixes (rejected, expired, low-score) from what the
provider broke (unavailable).
Installation
npm add @sdxc/captcha
The middleware runs on the remix router and the widgets
render with remix/component; every verification answers a Result from
@sdxc/result.
Usage
Verify a token
import { Turnstile } from "@sdxc/captcha/turnstile";
import { isFailure } from "@sdxc/result";
let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let result = await turnstile.verify(token, { remoteIp: "203.0.113.7" });
if (isFailure(result)) return result.error.code; // "rejected", "expired", "unavailable", …
result.data.hostname; // "example.com"
Tokens are single-use, so verify each submission once.
Guard a form route
import { captcha } from "@sdxc/captcha/middleware";
import { Turnstile } from "@sdxc/captcha/turnstile";
import { createRouter } from "remix/router";
let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let router = createRouter();
router.post("/sign-up", {
middleware: [captcha(turnstile, { action: "sign-up" })],
handler: () => new Response("Welcome"),
});
The middleware reads turnstile.field from the submitted form, verifies it with the visitor's
CF-Connecting-IP, and answers a failure with a 403. The handler still reads the body.
Render the widget
import type { Handle } from "remix/component";
import { TurnstileWidget } from "@sdxc/captcha/turnstile/ui";
function SignUpForm(handle: Handle<{ siteKey: string }>) {
return () => (
<form method="post" action="/sign-up">
<input name="email" type="email" />
<TurnstileWidget siteKey={handle.props.siteKey} action="sign-up" theme="auto" />
<button type="submit">Sign up</button>
</form>
);
}
Switch providers
import { HCaptcha } from "@sdxc/captcha/hcaptcha";
import { captcha } from "@sdxc/captcha/middleware";
import { ReCaptcha } from "@sdxc/captcha/recaptcha";
captcha(new HCaptcha({ secretKey: HCAPTCHA_SECRET, siteKey: HCAPTCHA_SITE_KEY }));
captcha(new ReCaptcha({ secretKey: RECAPTCHA_SECRET, minScore: 0.7 }), { action: "sign_up" });
Pair each with its widget from @sdxc/captcha/hcaptcha/ui or @sdxc/captcha/recaptcha/ui.
API
Captcha
The interface a provider implements: field, the form field its widget writes, and
verify(token, { remoteIp? }), which answers Result<Captcha.Verification, CaptchaError>.
A field of the verification the provider does not report stays unset.
new CaptchaError(code, providerCodes?)
The failure every provider answers: code to branch on, and providerCodes with the
provider's own codes verbatim, for logs.
captcha(provider, options?) from ./middleware
Verifies provider.field on every request of the route it is installed on, and publishes the
outcome as ctx.captcha. It reuses the form parsed by remix's formData() middleware when
present; a missing field or a non-form body is missing-token.
action/hostname: expected values; a verification reporting another one, or none, fails withaction-mismatch/hostname-mismatch.remoteIp(request): the visitor's address. Defaults to theCF-Connecting-IPheader when it holds a valid address.onFailure(error, ctx): answer aResponseto refuse, ornullto reach the handler with the failure onctx.captcha. Defaults to a plain-text 403.
CaptchaKey from ./middleware
The context key holding the same outcome as ctx.captcha, for code that reads it with
ctx.get(CaptchaKey).
new Turnstile({ secretKey, field?, timeout? }) from ./turnstile
Verifies with Cloudflare's
siteverify
and reports hostname, action and solve time. field defaults to cf-turnstile-response,
timeout to 10 seconds.
new HCaptcha({ secretKey, siteKey?, maxRiskScore?, field?, timeout? }) from ./hcaptcha
Verifies with hCaptcha's siteverify. siteKey refuses a token
solved for another site. On Enterprise plans the verification's score is 1 - risk, and a
risk above maxRiskScore (hCaptcha's own scale, 1 is a threat) fails with low-score.
field defaults to h-captcha-response.
new ReCaptcha({ secretKey, minScore?, field?, timeout? }) from ./recaptcha
Verifies v2 and v3 tokens with
Google's siteverify. A v3 score under
minScore (default 0.5) fails with low-score; a v2 answer has no score and passes on
success alone. field defaults to g-recaptcha-response.
new MemoryCaptcha({ field?, verification?, unknownTokens? }) from ./memory
A scriptable provider for tests. A call takes the next queued outcome, then the token's own
rule, then the default: pass with verification, or rejected under
unknownTokens: "reject". An empty token is missing-token and leaves the queue alone.
field defaults to captcha-response.
passNext(verification?)/failNext(code, providerCodes?): queue the next answer.accept(token, verification?)/reject(token, code, providerCodes?): answer one token the same way every time.calls/last: every recorded{ token, remoteIp? }.reset(): forget calls, queued answers and rules.
Widgets
Each renders the provider's container followed by its loader script; nonce sets the script's
CSP nonce.
<TurnstileWidget siteKey />from./turnstile/ui:action,cData,theme(auto|light|dark),size(normal|flexible|compact),appearance(always|execute|interaction-only),language,field(match the server's) andnonce.<HCaptchaWidget siteKey />from./hcaptcha/ui:theme(light|dark),size(normal|compact),tabIndex,languageandnonce.<ReCaptchaWidget siteKey />from./recaptcha/ui: the v2 checkbox, with the same props asHCaptchaWidget. A v3 page callsgrecaptcha.execute()and writes the token into ag-recaptcha-responsefield itself.
Types
Captcha.Verification:hostname?,action?,score?(0bot to1human) andchallengedAt?.Captcha.VerifyOptions:{ remoteIp? }.Captcha.ErrorCode:missing-token,rejected,expired,low-score,unavailable,action-mismatchorhostname-mismatch.CaptchaOutcome:Result<Captcha.Verification, CaptchaError>, the type ofctx.captcha.CaptchaMiddlewareOptions,Turnstile.Options,HCaptcha.Options,ReCaptcha.Options,MemoryCaptcha.OptionsandMemoryCaptcha.Call: the option and record shapes above.TurnstileWidget.Props,HCaptchaWidget.Props,ReCaptchaWidget.Props: the widget props.
Pattern: Fail open when the provider is down
A sign-in keeps working through a provider outage while still refusing a bad token.
import { captcha } from "@sdxc/captcha/middleware";
import { Turnstile } from "@sdxc/captcha/turnstile";
import { isFailure } from "@sdxc/result";
import { createRouter } from "remix/router";
let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let router = createRouter();
router.post("/sign-in", {
middleware: [
captcha(turnstile, {
onFailure: (error) =>
error.code === "unavailable"
? null
: new Response("Solve the challenge again", { status: 400 }),
}),
],
handler(ctx) {
let unverified = isFailure(ctx.captcha); // true only when the provider was unavailable
return signIn(ctx, { unverified });
},
});
Pattern: Escalate a low reCAPTCHA v3 score
v3 runs invisibly; a low-score answer is the moment to show the v2 checkbox instead of
refusing outright.
import type { Handle } from "remix/component";
import { captcha } from "@sdxc/captcha/middleware";
import { ReCaptcha } from "@sdxc/captcha/recaptcha";
import { ReCaptchaWidget } from "@sdxc/captcha/recaptcha/ui";
import { isFailure } from "@sdxc/result";
import { createRouter } from "remix/router";
let invisible = new ReCaptcha({ secretKey: RECAPTCHA_V3_SECRET, minScore: 0.5 });
let checkbox = new ReCaptcha({ secretKey: RECAPTCHA_V2_SECRET });
let router = createRouter();
router.post("/comment", {
middleware: [
captcha(invisible, {
action: "comment",
onFailure: (error) =>
error.code === "low-score" ? null : new Response(null, { status: 403 }),
}),
],
handler(ctx) {
if (isFailure(ctx.captcha)) return renderPage(<CheckboxForm siteKey={RECAPTCHA_V2_SITE_KEY} />);
return saveComment(ctx);
},
});
router.post("/comment/checked", {
middleware: [captcha(checkbox)],
handler: (ctx) => saveComment(ctx),
});
function CheckboxForm(handle: Handle<{ siteKey: string }>) {
return () => (
<form method="post" action="/comment/checked">
<textarea name="body" />
<ReCaptchaWidget siteKey={handle.props.siteKey} />
<button type="submit">Post</button>
</form>
);
}
v2 and v3 keys are separate, so the checkbox posts to a route verified with the v2 secret; a v2 answer has no score and passes on success alone.
Pattern: Hold a token to its form and site
A token solved on one form or domain fails on another, and the visitor's address comes from the header your proxy sets.
import { captcha } from "@sdxc/captcha/middleware";
import { Turnstile } from "@sdxc/captcha/turnstile";
import { formData } from "remix/middleware/form-data";
import { createRouter } from "remix/router";
let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY, timeout: 5_000 });
let router = createRouter({ middleware: [formData()] });
router.post("/contact", {
middleware: [
captcha(turnstile, {
action: "contact",
hostname: "www.example.com",
remoteIp: (request) => request.headers.get("X-Real-IP"),
}),
],
handler(ctx) {
return sendMessage(ctx.get(FormData));
},
});
Pattern: Testing a guarded route
MemoryCaptcha stands in for the real provider, so a test chooses the outcome and asserts on
what was verified.
import { MemoryCaptcha } from "@sdxc/captcha/memory";
import { captcha } from "@sdxc/captcha/middleware";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";
test("refuses a sign-up the provider rejected", async () => {
let provider = new MemoryCaptcha().failNext("rejected");
let router = createRouter();
router.post("/sign-up", { middleware: [captcha(provider)], handler: () => new Response("ok") });
let response = await router.fetch("https://example.com/sign-up", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"CF-Connecting-IP": "203.0.113.7",
},
body: "captcha-response=token",
});
expect(response.status).toBe(403);
expect(provider.last).toEqual({ token: "token", remoteIp: "203.0.113.7" });
});
test("accepts only the known token", async () => {
let provider = new MemoryCaptcha({ unknownTokens: "reject" }).accept("good", { action: "x" });
expect(await provider.verify("good")).toEqual({ status: "success", data: { action: "x" } });
expect((await provider.verify("bad")).status).toBe("failure");
provider.reset();
});
Pattern: Implementing Captcha for another provider
An implementation names its widget's field, verifies a token, and maps the answer onto
Captcha.Verification and Captcha.ErrorCode: token problems are rejected or expired, and
anything the visitor cannot fix (a bad key, a provider error, a network failure) is
unavailable.
import type { Captcha } from "@sdxc/captcha";
import type { Result } from "@sdxc/result";
import { CaptchaError } from "@sdxc/captcha";
import { failure, success } from "@sdxc/result";
class FriendlyCaptcha implements Captcha {
readonly field = "frc-captcha-response";
constructor(private apiKey: string) {}
async verify(token: string): Promise<Result<Captcha.Verification, CaptchaError>> {
if (token === "") return failure(new CaptchaError("missing-token"));
let response: Response;
try {
response = await fetch("https://global.frcapi.com/api/v2/captcha/siteverify", {
method: "POST",
headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ response: token }),
});
} catch {
return failure(new CaptchaError("unavailable"));
}
if (!response.ok) return failure(new CaptchaError("unavailable", [`http-${response.status}`]));
let answer = await response.json();
if (answer.success) return success({ challengedAt: new Date(answer.data.challenge.timestamp) });
let code = answer.error.error_code;
if (code === "response_timeout" || code === "response_duplicate") {
return failure(new CaptchaError("expired", [code]));
}
return failure(new CaptchaError("rejected", [code]));
}
}
A provider that reports no action fails the middleware's action option with
action-mismatch, so leave action unset for it.