[ Forms & abuse ]
@sdxc/captcha
Turnstile, hCaptcha and reCAPTCHA verification, with middleware and widgets
- Installs with
- @sdxc/result@sdxc/validate
- Depends on
remix- Used by
- uptime, auth-saas
- Source
- packages/captcha
Provider-neutral CAPTCHA verification: one Captcha contract with Turnstile, hCaptcha and
reCAPTCHA implementations, router middleware, widgets and an in-memory provider for tests.
Every 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/ui; every verification returns an
@sdxc/result value. Both install alongside this
package.
Usage
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() {
// Reached only once the token verified; a failure was answered with a 403.
return new Response("Welcome");
},
});
Render the widget
import type { Handle } from "remix/ui";
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>
);
}
Verify without the middleware
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"
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" });
API
@sdxc/captcha
Captcha— the interface a provider implements:field, the form field its widget writes, andverify(token, { remoteIp? }), which resolves toResult<Captcha.Verification, CaptchaError>. Single-use providers consume the token, so call it once per submission.Captcha.Verification— what the provider confirmed:hostname?,action?,score?(0bot to1human) andchallengedAt?. A field the provider does not report stays unset.Captcha.ErrorCode— one ofmissing-token,rejected,expired,low-score,unavailable,action-mismatchorhostname-mismatch.CaptchaError— the failure every provider returns:codeto branch on, andproviderCodeswith the provider's own codes verbatim for logs.
@sdxc/captcha/middleware
captcha(provider, options?) verifies provider.field on every request of the route it is
installed on, so install it on the action that accepts the submission. It reads the form parsed
by remix's formData() middleware when present, and otherwise a clone of the request, leaving
the body readable for the handler. 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)— resolves the visitor's address; defaults to theCF-Connecting-IPheader.onFailure(error, ctx)— return aResponseto refuse, ornullto continue with the failure published. Defaults to a plain-text 403.
The outcome is published as ctx.captcha (CaptchaOutcome, a
Result<Captcha.Verification, CaptchaError>) and under the CaptchaKey context key.
@sdxc/captcha/turnstile
new Turnstile({ secretKey, field?, timeout? }) verifies against Cloudflare's
siteverify.
field defaults to cf-turnstile-response; timeout to 10 seconds. It reports hostname,
action and solve time.
@sdxc/captcha/turnstile/ui
<TurnstileWidget siteKey /> renders the .cf-turnstile container and Cloudflare's loader.
Optional props: action, cData, theme (auto | light | dark), size (normal |
flexible | compact), appearance (always | execute | interaction-only), language,
field (match Turnstile's) and nonce for the loader script.
@sdxc/captcha/hcaptcha
new HCaptcha({ secretKey, siteKey?, maxRiskScore?, field?, timeout? }) verifies against
hCaptcha's siteverify.
siteKey makes hCaptcha refuse a token solved for another site. hCaptcha Enterprise scores
risk (1 is a threat), so the verification reports 1 - risk as score, and maxRiskScore,
in hCaptcha's own terms, fails a riskier token with low-score. field defaults to
h-captcha-response.
@sdxc/captcha/hcaptcha/ui
<HCaptchaWidget siteKey /> renders the .h-captcha container and hCaptcha's loader. Optional
props: theme (light | dark), size (normal | compact), tabIndex, language and
nonce.
@sdxc/captcha/recaptcha
new ReCaptcha({ secretKey, minScore?, field?, timeout? }) verifies v2 and v3 tokens against
Google's siteverify. A v3 answer
reports its score and action; a score under minScore (default 0.5) fails with
low-score. A v2 answer carries no score and passes on success alone. Hold a v3 token to
the form's action with the middleware's action option. field defaults to
g-recaptcha-response.
@sdxc/captcha/recaptcha/ui
<ReCaptchaWidget siteKey /> renders the v2 checkbox: the .g-recaptcha container and Google's
loader. Optional props: theme (light | dark), size (normal | compact), tabIndex,
language and nonce. A v3 key has no widget; the page calls grecaptcha.execute() and writes
the token into a g-recaptcha-response field itself.
@sdxc/captcha/memory
new MemoryCaptcha({ field?, verification?, unknownTokens? }) is a scriptable provider for
tests. A call is answered by the next queued outcome, then by the token's own rule, then by the
default: every non-empty token passes with verification, or is rejected under
unknownTokens: "reject".
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.
An empty token is missing-token and never consumes a queued answer. field defaults to
captcha-response.
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 { 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) {
// ctx.captcha is a failure with code "unavailable" when the provider was down.
return signIn(ctx);
},
});
Pattern: Escalate a low reCAPTCHA v3 score
v3 runs invisibly; a low-score answer is the moment to show a v2 checkbox instead of
refusing outright.
import { captcha } from "@sdxc/captcha/middleware";
import { ReCaptcha } from "@sdxc/captcha/recaptcha";
import { createRouter } from "remix/router";
let recaptcha = new ReCaptcha({ secretKey: RECAPTCHA_V3_SECRET, minScore: 0.5 });
let router = createRouter();
router.post("/comment", {
middleware: [
captcha(recaptcha, {
action: "comment",
onFailure: (error) =>
error.code === "low-score" ? null : new Response(null, { status: 403 }),
}),
],
handler(ctx) {
if (ctx.captcha.status === "failure") return renderWithCheckbox(ctx);
return saveComment(ctx);
},
});
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" });
});
Pattern: Implementing Captcha for another provider
An implementation owns three things: the widget's field name, the call (or local check) that
verifies a token, and the mapping of the provider's answer onto Captcha.Verification and
Captcha.ErrorCode. Report only what the provider confirms, map token problems to rejected
or expired, and map everything the visitor cannot fix — a bad key, a provider error, a
network failure — to unavailable.
import type { Captcha } from "@sdxc/captcha";
import type { Result } from "@sdxc/result";
import { CaptchaError } from "@sdxc/captcha";
import { failure, success } from "@sdxc/result";
/** Friendly Captcha v2, verified with an API key header. */
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(); // validate the shape before trusting it
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 verified locally fits the same contract: an ALTCHA proof of
work is checked with an HMAC key and no network call, so its implementation reads the altcha
field, ignores remoteIp, and reports no hostname, action or score — which is why the
middleware treats an expected action it cannot confirm as action-mismatch.