Skip to main content

Captcha

WIP

Verifying Cloudflare Turnstile, hCaptcha and reCAPTCHA v3 tokens through auth.captcha, and where to put the check

auth.captcha verifies a captcha token server-side. Configuring one makes it available and does nothing else: no flow or route calls it, so you put the check in front of whatever needs it.

import { authTurnstileVerifier } from '@gentleduck/auth/core'

const auth = createAuth({
  // ...
  captcha: authTurnstileVerifier({ secret: process.env.TURNSTILE_SECRET ?? '', expectedHostname: 'app.example.com' }),
})

app.post('/auth/signup', async (req, res) => {
  const solved = await auth.captcha.verify({ token: req.body.captchaToken, remoteIp: req.ip })
  if (!solved.success) throw new AuthError('AUTH_INVALID_PARAMETERS', { detail: 'captcha' })
  // ... create the account
})
VerifierExtra options
authTurnstileVerifierexpectedAction
authHCaptchaVerifiernone; hCaptcha echoes no action
authRecaptchaV3VerifierminScore (0.5 by default), expectedAction
authNullCaptchaVerifierPasses everything, for tests. It refuses to construct under NODE_ENV=production.

Every siteverify verifier takes secret, expectedHostname (one or a list), maxChallengeAgeMs (5 minutes by default), timeoutMs (5 seconds), and endpoint to override the provider URL, which must be a public https host.

The result

verify never throws. A rejected token, a timeout, a network failure and an unreadable response all answer { success: false, errorCodes }, so a captcha outage cannot turn sign-up into a 500. errorCodes mixes the provider's own codes with duck-auth's, such as timeout, hostname-mismatch, action-mismatch, challenge-expired and score-too-low. The result also reports score, hostname, action and challengeTs, for a caller that wants its own rule.

Without a captcha in the config, auth.captcha fails every call with captcha-not-configured rather than being undefined.

strict({ env: 'production' }) refuses the null verifier and a plain-http endpoint.