@arraypress/captcha
v2.0.0
Published
Unified CAPTCHA verification — Turnstile, reCAPTCHA, and hCaptcha. Provider selection via settings, optional decryption hook for secrets at rest, fail-open when 'none' is configured, fail-closed on misconfiguration. Zero dependencies.
Maintainers
Readme
@arraypress/captcha
Unified CAPTCHA verification across Cloudflare Turnstile, Google reCAPTCHA v2/v3, and hCaptcha. Settings-driven provider selection, optional decryption hook for secrets at rest, fail-open on 'none' / fail-closed on misconfiguration. Zero dependencies.
v2.0.0 (breaking):
@arraypress/turnstileand@arraypress/recaptchaare no longer separate peer packages — the verifier code lives inside this package as internal providers. hCaptcha support added. Drop theturnstile+recaptchadep lines from yourpackage.jsonwhen upgrading; importers continue to work via theverifyTurnstile/verifyRecaptchare-exports on this package.
Install
npm install @arraypress/captchaUsage
import { createCaptcha } from '@arraypress/captcha';
const captcha = createCaptcha({
getSettings: () => getAllSettings(db),
decrypt: (stored) => decryptSetting(stored, tokenSecret), // optional
});
// On a protected form endpoint:
app.post('/api/magic-link', async (c) => {
const body = await c.req.json();
const ok = await captcha.verify(captcha.extractToken(body), {
ip: getClientIP(c),
});
if (!ok) return c.json({ error: 'Invalid CAPTCHA' }, 400);
// ...
});
// On the public endpoint the frontend reads to mount its widget:
app.get('/api/public/captcha-config', async (c) => {
return c.json(await captcha.getPublicConfig());
});Settings keys read
| Key | Purpose |
|---|---|
| captcha_provider | 'none' / 'turnstile' / 'recaptcha' / 'hcaptcha' |
| turnstile_site_key, turnstile_secret_key | When provider = turnstile |
| recaptcha_site_key, recaptcha_secret_key | When provider = recaptcha |
| hcaptcha_site_key, hcaptcha_secret_key | When provider = hcaptcha |
Secret keys may be plaintext OR ciphertext — the decrypt hook is applied on every read. Site keys are never sensitive and are never passed through decrypt.
Semantics
| State | verify() | getPublicConfig() |
|---|---|---|
| captcha_provider = 'none' | true (fail-open — opt-in) | null |
| Provider configured, secret blank | false (fail-closed — misconfig) | null if siteKey also blank |
| Provider configured, token missing | false | { provider, siteKey } |
| Provider rejects the token | false | — |
| Provider accepts the token | true | — |
Fail-open on 'none' is intentional: fresh deployments don't require a CAPTCHA. Fail-closed on missing-secret is also intentional: the admin thinks they're protected, so a silent accept is worse than a hard reject.
Token extraction
extractCaptchaToken(body) reads from the first field present, in order:
cf-turnstile-response(Turnstile widget native)g-recaptcha-response(reCAPTCHA widget native)h-captcha-response(hCaptcha widget native)turnstileToken/recaptchaToken/hcaptchaToken/captchaToken(common JSON aliases)
Direct provider verification
If you don't want the settings-driven gateway — for example you only ever use one provider and keep the secret in an env var — the per-provider verify functions are also exported:
import { verifyTurnstile, verifyRecaptcha, verifyHcaptcha } from '@arraypress/captcha';
const ok = await verifyTurnstile(token, env.TURNSTILE_SECRET, { remoteip });
// reCAPTCHA v3 with score threshold:
const ok3 = await verifyRecaptcha(token, env.RECAPTCHA_SECRET, {
scoreThreshold: 0.7,
expectedAction: 'checkout',
});Each provider also has a verify<Provider>Detailed variant that returns the raw siteverify response (useful for logging error codes, score values, etc.).
License
MIT
