Captcha
WIPPluggable bot defense - Turnstile, hCaptcha, reCAPTCHA v3, and a Null verifier for tests. Wire into signin / signup / magic-link routes.
duck-auth ships four built-in captcha verifiers behind a single
Captcha.IVerifier interface so you can switch providers without
touching your flow code. Any verifier instance plugs into the same
host-supplied hook your route handlers call before crossing the
signin / signup / magic-link boundary.
import {
TurnstileVerifier,
HCaptchaVerifier,
RecaptchaV3Verifier,
NullCaptchaVerifier,
} from '@gentleduck/auth/core'
TurnstileVerifier
Cloudflare Turnstile.
const captcha = new TurnstileVerifier({
secret: process.env.TURNSTILE_SECRET!,
})
const result = await captcha.verify({
token: req.body.turnstileToken,
remoteip: req.ip,
})
if (!result.success) {
throw new AuthError('AUTH/CAPTCHA_FAILED', {
detail: result.errorCodes?.join(', '),
})
}
The endpoint override is rarely needed; defaults to
https://challenges.cloudflare.com/turnstile/v0/siteverify. Pass a
custom fetch for tests or to thread the call through a proxy.
HCaptchaVerifier
hCaptcha. Same shape as Turnstile - swap the class, swap the secret.
const captcha = new HCaptchaVerifier({
secret: process.env.HCAPTCHA_SECRET!,
})
Endpoint defaults to https://hcaptcha.com/siteverify.
RecaptchaV3Verifier
Google reCAPTCHA v3. v3 returns a score 0..1; the verifier rejects
scores below minScore (default 0.5).
const captcha = new RecaptchaV3Verifier({
secret: process.env.RECAPTCHA_SECRET!,
minScore: 0.7, // stricter for high-risk flows
expectedAction: 'signin', // bound to your client.execute() action
})
const result = await captcha.verify({
token: req.body.recaptchaToken,
remoteip: req.ip,
})
The verifier also rejects responses whose action claim does not
match expectedAction - protecting against a token minted for a
different page being replayed at signin.
NullCaptchaVerifier
No-op verifier that returns { success: true } for any input. Use
in tests, dev environments, and code paths where you explicitly want
captcha disabled (e.g. M2M endpoints).
const captcha = process.env.NODE_ENV === 'production'
? new TurnstileVerifier({ secret: process.env.TURNSTILE_SECRET! })
: new NullCaptchaVerifier()
strict({ env: 'production' }) does NOT reject NullCaptchaVerifier
- it's the host app's responsibility to wire a real verifier when captcha is policy. Compliance presets add the check separately.
Wiring into a route
The verifiers live above the AuthEngine - call them yourself before invoking the flow. Pattern:
async function signinHandler(req, res) {
const captchaResult = await captcha.verify({
token: req.body.turnstileToken,
remoteip: req.ip,
})
if (!captchaResult.success) {
res.status(400).json({ error: 'captcha failed' })
return
}
const { session } = await auth.flows.signIn({
providerId: 'password',
input: { email: req.body.email, password: req.body.password },
})
res.json({ session })
}
For most apps, this is a 4-line middleware in your framework adapter rather than inline per-route code.
Robustness
Every verifier:
- Caps the supplied
tokenat 4096 chars before the network call so a hostile client cannot force a multi-MB POST to the siteverify endpoint. - Treats any non-JSON or shape-mismatched siteverify response as
failure (returns
{ success: false }rather than throwing). - Rejects truthy non-boolean
successvalues (a buggy provider returning{ success: 'true' }does not satisfy the gate). - Times out network calls via an AbortSignal so a stalled siteverify call cannot stall your signin route.
Bring your own
Implement Captcha.IVerifier:
import type { Captcha } from '@gentleduck/auth/core'
class MyCaptchaVerifier implements Captcha.IVerifier {
readonly id = 'my-captcha'
async verify(input: Captcha.IVerifyInput): Promise<Captcha.IVerifyResult> {
// POST to your provider, parse the response, return { success, errorCodes? }
}
}
Anything that returns the Captcha.IVerifyResult shape composes with
the rest of the library.