captcha

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

github.com/ubgo/auth/plugin/captcha

Refuses chosen operations unless the request carries a captcha token the provider accepts: a bot cannot sign up, sign in or ask for reset links at the rate the rate rules allow a human to. Cloudflare Turnstile, Google reCAPTCHA (v2 and v3), hCaptcha and CaptchaFox are built in; any other provider is a Verifier.

Install

go get github.com/ubgo/auth

The package is in the core module github.com/ubgo/auth, so it brings no extra dependency beyond github.com/ubgo/sqlb; the providers are called with net/http.

Wire it

Taken from examples/captcha:

v := captcha.Turnstile(os.Getenv("CAPTCHA_SECRET"), captcha.WithExpectedAction("auth"))
a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(email.New(email.Login(), email.Recover(), email.WithMailer(m)), password.New(), captcha.New(v)),
)

The page solves the widget and sends the token in the X-Captcha-Response header (captcha.TokenHeader, the header better-auth's clients send, so a frontend written for one works against the other). On every transport (HTTP, GraphQL, an adapter) the plugin reads it before a protected operation runs and asks the provider once; the call is refused when the token is missing, rejected, or could not be checked. A direct Go call (pw.SignIn(ctx, …) from the app's own code) has no browser and is never checked.

captcha.ChallengeOnly() makes the plugin guard nothing always and serve only as the core's rate challenger: an auth.RateChallenge rule asks for the token once its limit is passed, and never before (it cannot be combined with Protect or ProtectPaths). Without it the plugin does both, and a token the challenge accepted is not sent to the guard again.

With no Protect option the plugin guards this list, narrowed at auth.New to the operations the registered plugins provide. auth.New fails with ErrNothingProtected when none of them is registered, and with auth.ErrUnknownHookTarget when an id in Protect is a typo, so a captcha that guards nothing is never wired silently.

Options

Option What it does Default
captcha.New(v, opts...) The plugin around a Verifier required
captcha.Protect(ids...) Replace the default list with these operation ids; every id must be registered the default list
captcha.ProtectPaths(patterns...) Guard every operation whose route matches a pattern, better-auth's endpoints with wildcards: * within one path segment, ** across segments, no wildcard for one exact route ("/password/*", "/oauth/**"). Routes are the operation's own, without the mount prefix. Replaces the default list and adds up with Protect; a pattern matching no registered operation fails auth.New with auth.ErrUnknownHookTarget naming the pattern. captcha.MatchPath(pattern, route) tests one the default list
captcha.WithHeader(name) Read the token from another header X-Captcha-Response
captcha.WithTimeout(d) Bound each verification; zero or negative keeps the default 10 s

Providers, each returning a *SiteVerify that is a Verifier:

Constructor Verifies Siteverify endpoint
captcha.Turnstile(secret, opts...) Cloudflare Turnstile challenges.cloudflare.com/turnstile/v0/siteverify
captcha.ReCAPTCHA(secret, opts...) Google reCAPTCHA v2 and v3; a v3 token carries a score and must reach the threshold, a v2 token is judged by success www.google.com/recaptcha/api/siteverify
captcha.HCaptcha(secret, opts...) hCaptcha api.hcaptcha.com/siteverify
captcha.CaptchaFox(secret, opts...) CaptchaFox (hCaptcha-compatible verdict, no score) api.captchafox.com/siteverify
Provider option What it does Default
captcha.WithExpectedAction(action) Refuse a token the widget minted for another action (reCAPTCHA v3, Turnstile report it) accept any
captcha.WithAllowedHostnames(hosts...) Refuse a token solved on another site, by the hostname the provider reports accept any
captcha.WithSiteKey(key) Send the site key so the provider rejects a token solved under another key (hCaptcha, CaptchaFox) not sent
captcha.WithMinScore(score) The reCAPTCHA v3 score a token must reach, 0 to 1; out of range keeps the default 0.5
captcha.WithEndpoint(url) A mirror, or a fake server in a test; must be absolute the provider's
captcha.WithHTTPClient(c) The client the verification uses http.DefaultClient

The client address (Call.Client.IP, as authhttp derives it) is sent as remoteip, and the secret is checked at auth.New: an empty one fails with ErrNoSecret.

Another provider, or a bot verdict without a token, is a Verifier:

Verifier · plugin/captcha/captcha.go:120-122

type Verifier interface {
	Verify(ctx context.Context, token, remoteIP string) error
}

Return nil to let the call through, captcha.ErrFailed (or an error wrapping it) to refuse the token, and any other error when no verdict could be had. captcha.VerifierFunc adapts a function.

Routes and Go API

The plugin registers no operation; it is one auth.OperationHook:

Plugin.OperationHooks · plugin/captcha/captcha.go:342-365

func (p *Plugin) OperationHooks() []auth.OperationHook {
	if p.challengeOnly {
		return nil
	}
	if len(p.patterns) > 0 {
		p.protected = append(p.protected, p.resolvePatterns()...)
	}
	if !p.explicit {
		known := map[auth.EndpointID]bool{}
		for _, id := range p.core.DisabledOperations() {
			known[id] = true
		}
		narrowed := make([]auth.EndpointID, 0, len(p.protected))
		for _, id := range p.protected {
			if _, ok := p.core.Operation(id); ok || known[id] {
				narrowed = append(narrowed, id)
			}
		}
		if len(narrowed) > 0 {
			p.protected = narrowed
		}
	}
	return []auth.OperationHook{{Match: slices.Clone(p.protected), Before: p.before}}
}
Function Use
(*Plugin).Protected() The operation ids guarded, settled at auth.New
(*SiteVerify).Verify(ctx, token, remoteIP) Verify one token outside the pipeline, in a custom flow
(*SiteVerify).Provider() turnstile, recaptcha, hcaptcha or captchafox, for logs

Tables and columns

None. The plugin stores nothing.

Errors

Error variable Code HTTP When
ErrMissing captcha.missing 400 A protected transport call sent no token
ErrFailed captcha.failed 403 The provider rejected the token: expired, used, solved elsewhere, another action, or a score below the threshold. One code for every reason, so a bot cannot learn which check it tripped; the reason is in the log
ErrUnavailable captcha.unavailable 503 The provider could not be reached, answered a non-200 status, timed out, or returned something that is not a verdict. The call is refused: a provider outage must not be an open door
ErrNoVerifier (plain error, no code) n/a auth.New with captcha.New(nil)
ErrNothingProtected (plain error, no code) n/a auth.New when none of the default operations' plugins is registered, or Protect() / ProtectPaths() was given nothing
ErrNoSecret (plain error, no code) n/a auth.New with a built-in provider and an empty secret

Security notes

Property Pinned by
A protected call without a token is refused before the operation runs and before any provider call TestHook_HTTP, TestCaptcha_GuardsSignUpAndSignIn
A rejected token, a wrong action, a wrong hostname and a low score are one error for the client TestSiteVerify_Judgement
A provider outage, a bad status, a non-JSON answer and a timeout fail closed TestHook_HTTP, TestHook_Timeout
The default list guards only operations that exist, and auth.New refuses a plugin that would guard nothing or an empty Match (which would guard everything) TestInit_DefaultListNarrowsToRegisteredPlugins, TestInit_RegisteredPluginWithoutDefaultOperations, TestNew_Refusals
The real password routes are refused without a token on every engine TestCaptcha_GuardsPasswordRoutes (integration)
Tests never call a real provider: every verdict comes from a fake siteverify server every test in the package and the example

Limits: a direct Go call is never checked, by design; the check is a hook, so it costs one provider round-trip per protected call and sits after the rate limit and origin check in the pipeline, which means a flood is still stopped by the rate rules first.

Example

Documentation

Overview

Package captcha refuses chosen operations unless the request carries a captcha token a provider accepts, so a bot cannot sign up, sign in or ask for a reset link at the rate the rate rules allow a human to.

The plugin is one operation hook. The client solves the challenge in the browser and sends the provider's token in the TokenHeader header; the hook reads it, asks the provider's siteverify endpoint once, and refuses the call before the operation runs when the token is missing, rejected or cannot be checked. Cloudflare Turnstile, Google reCAPTCHA (v2 and v3) and hCaptcha speak the same siteverify protocol and are built in; any other provider is a Verifier.

A direct Go call has no headers and no browser, so it is never checked: the app's own code is not the bot the captcha is for. Every transport call — HTTP, GraphQL, an adapter — is.

Index

Constants

View Source
const (
	TurnstileEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
	ReCAPTCHAEndpoint = "https://www.google.com/recaptcha/api/siteverify"
	HCaptchaEndpoint  = "https://api.hcaptcha.com/siteverify"
	// CaptchaFoxEndpoint speaks the hCaptcha-compatible siteverify protocol:
	// the same form fields, success and hostname in the verdict, no score.
	CaptchaFoxEndpoint = "https://api.captchafox.com/siteverify"
)

Siteverify endpoints of the built-in providers. Each takes a form POST of secret, response and remoteip and answers JSON with success, hostname, action, score and error-codes; WithEndpoint points at a mirror or a test server.

View Source
const AnnotationVerified = "captcha.verified"

AnnotationVerified is the auth.Call annotation the plugin sets once the call's token was accepted. A captcha token is single-use at the provider, so a call whose token already passed a rate challenge must not send it again to the operation guard, which would see it rejected as a replay; the guard reads this instead. The value is "true", never the token.

View Source
const DefaultMinScore = 0.5

DefaultMinScore is the reCAPTCHA v3 score a token must reach. Google's own recommended starting point: 0.5 splits "likely a bot" from "likely human" on its 0 to 1 scale, and a lower value lets more bots through than it recovers humans.

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout bounds the siteverify call. Ten seconds is long enough for a provider having a slow day and short enough that a provider outage fails requests instead of holding every connection open until the client gives up.

View Source
const PluginName = "captcha"

PluginName is the plugin's permanent identity.

View Source
const TokenHeader = "X-Captcha-Response"

TokenHeader is the request header the client sends the provider's token in. It is the header better-auth's clients already use, so a frontend written for one works against the other unchanged; WithHeader renames it.

Variables

View Source
var (
	// ErrMissing refuses a protected call that sent no token.
	ErrMissing = auth.NewError("captcha.missing", http.StatusBadRequest, "Complete the captcha and try again.")
	// ErrFailed refuses a token the provider rejected: expired, already
	// used, solved on another site, minted for another action, or scored
	// below the threshold. The client gets one answer for all of them so a
	// bot cannot learn which check it tripped.
	ErrFailed = auth.NewError("captcha.failed", http.StatusForbidden, "The captcha could not be verified. Try again.")
	// ErrUnavailable fails closed when the provider cannot be reached or
	// answers something that is not a verdict: an unreachable provider must
	// not become an open door.
	ErrUnavailable = auth.NewError("captcha.unavailable", http.StatusServiceUnavailable, "The captcha service is not available. Try again in a moment.")

	// ErrNoVerifier fails auth.New when New was given no Verifier: a captcha
	// plugin that cannot verify would refuse every protected call.
	ErrNoVerifier = errors.New("captcha: pass a Verifier such as captcha.Turnstile(secret)")
	// ErrNothingProtected fails auth.New when no protected operation is
	// registered: a captcha plugin that guards nothing is a configuration
	// mistake, usually a plugin list without the plugin it was meant for.
	ErrNothingProtected = errors.New("captcha: none of the protected operations is registered; pass captcha.Protect with the ids to guard")
	// ErrNoSecret fails the provider constructors' Validate when the secret
	// is empty, because every provider answers "invalid-input-secret" for it
	// and the symptom would be every sign-in failing in production.
	ErrNoSecret = errors.New("captcha: the provider secret key is empty")
)

Errors. The first three carry stable codes a client checks for; the message of each is the same whatever the operation and whoever the account is.

View Source
var DefaultProtected = []auth.EndpointID{
	"password.sign-up", "password.sign-in", "password.forgot", "password.reset",
	"email.send-code", "email.sign-in-code",
	"phone.send-code", "phone.sign-in",
	"magiclink.send",
}

Default protected operations: every way an anonymous request can create an account, prove a password, or make the app send a message. The ids are spelled out because their plugins live in other modules (password) or would make this plugin depend on every identifier plugin; the ones whose plugin is not registered are dropped at auth.New, so the default list is safe in any plugin set. Protect replaces it.

Functions

func MatchPath

func MatchPath(pattern, route string) bool

MatchPath reports whether route matches a ProtectPaths pattern, for a page or a test that wants to know what a pattern guards before wiring it.

Types

type Option

type Option func(*Plugin)

Option configures the plugin.

func ChallengeOnly

func ChallengeOnly() Option

ChallengeOnly makes the plugin guard no operation on every call and serve only as the core's rate challenger: a call is asked for a captcha when an auth.RateChallenge rule is over its limit, and never before. Without it the plugin does both, guarding its protected operations always and passing rate challenges anywhere. It cannot be combined with Protect or ProtectPaths, which would say the opposite.

func Protect

func Protect(ids ...auth.EndpointID) Option

Protect replaces DefaultProtected with the operations to guard. Unlike the default list, every id named here must be registered, so a typo fails auth.New (auth.ErrUnknownHookTarget) instead of leaving an operation open.

func ProtectPaths

func ProtectPaths(patterns ...string) Option

ProtectPaths guards every operation whose route matches one of patterns, better-auth's endpoints option with wildcards: "*" matches within one path segment and "**" across segments, so "/password/*" guards /password/sign-in and /password/sign-up but not /oauth/google/start, and "/oauth/**" guards every OAuth route. A pattern with no wildcard is one exact route. Routes are the operation's own (Operation.Route, "/password/ sign-in"), without authhttp's mount prefix.

Like Protect it replaces DefaultProtected, and the two add up when both are given. The safety Protect has stays: a pattern that matches no registered operation fails auth.New with auth.ErrUnknownHookTarget naming the pattern, so a typo or a renamed route can never leave a form open in silence. It is matched once, at auth.New.

func WithHeader

func WithHeader(name string) Option

WithHeader renames the header the token arrives in, for a frontend that already sends it under another name.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds each verification; see DefaultTimeout. A zero or negative value keeps the default, because a captcha with no timeout is a captcha that can hang every protected request.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the captcha hook. Build it with New.

func New

func New(v Verifier, opts ...Option) *Plugin

New builds the plugin around v. auth.New fails with ErrNoVerifier for a nil v and with ErrNothingProtected when no protected operation exists.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init narrows the default list to the ids whose plugin is registered (an id is "<plugin>.<name>", so the prefix is the plugin) and refuses a plugin set in which none is: that is the "forgot the password plugin" mistake, and it must fail here, loudly, not guard nothing. An explicit list is kept whole and auth.New checks each id.

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) OperationHooks

func (p *Plugin) OperationHooks() []auth.OperationHook

OperationHooks is the one hook: verify before the protected operations.

auth.New asks for the hooks once every operation is registered, so the default list is narrowed here to the ids that exist (or were switched off by WithDisabledOperations): a plugin that is registered but, like a custom one reusing the name, lacks one of the operations does not fail New. When nothing would remain, the list is handed over whole so New refuses it with auth.ErrUnknownHookTarget rather than a hook with an empty Match, which would guard every operation.

func (*Plugin) PassChallenge

func (p *Plugin) PassChallenge(ctx context.Context, c *auth.Call) error

PassChallenge is auth.RateChallenger: a call over an auth.RateChallenge rule passes with a token the verifier accepts. A Go call passes, as it does the operation guard; a call without a token is auth.ErrChallengeRequired (428), so the client knows to show the widget and send the call again; a rejected token is ErrFailed and an unreachable provider ErrUnavailable, as for a protected operation.

func (*Plugin) Protected

func (p *Plugin) Protected() []auth.EndpointID

Protected lists the operations the plugin guards, settled at auth.New, for a page or a test that reports them.

func (*Plugin) Validate

func (p *Plugin) Validate(cfg *auth.Config) error

Validate refuses a plugin with no verifier, and asks a provider built here to check its own configuration.

type ProviderOption

type ProviderOption func(*SiteVerify)

ProviderOption configures a built-in provider.

func WithAllowedHostnames

func WithAllowedHostnames(hosts ...string) ProviderOption

WithAllowedHostnames rejects a token solved on a site other than the ones listed, compared exactly against the hostname the provider reports.

func WithEndpoint

func WithEndpoint(u string) ProviderOption

WithEndpoint replaces the provider's siteverify URL: a regional mirror, or a fake server in a test. It must be absolute.

func WithExpectedAction

func WithExpectedAction(action string) ProviderOption

WithExpectedAction rejects a token the widget minted for another action (reCAPTCHA v3 and Turnstile report it), so a token solved on the newsletter form cannot be replayed at sign-in.

func WithHTTPClient

func WithHTTPClient(c *http.Client) ProviderOption

WithHTTPClient replaces the client the verification uses, for a proxy or a test transport. The plugin's timeout still applies through the context.

func WithMinScore

func WithMinScore(score float64) ProviderOption

WithMinScore sets the score a reCAPTCHA v3 token must reach; see DefaultMinScore. Out of the 0 to 1 range it keeps the default.

func WithSiteKey

func WithSiteKey(key string) ProviderOption

WithSiteKey sends the site key with the verification, so the provider rejects a token solved under another key of the same account. hCaptcha and CaptchaFox support it; the others ignore the field.

type SiteVerify

type SiteVerify struct {
	// contains filtered or unexported fields
}

SiteVerify is a Verifier over the siteverify protocol: Turnstile, reCAPTCHA, hCaptcha and CaptchaFox differ only in endpoint and in which optional fields they return. Build one with Turnstile, ReCAPTCHA, HCaptcha or CaptchaFox.

func CaptchaFox

func CaptchaFox(secret string, opts ...ProviderOption) *SiteVerify

CaptchaFox verifies CaptchaFox tokens with the site's secret key. The verdict carries no score, so a token is judged by success, hostname and the site key.

func HCaptcha

func HCaptcha(secret string, opts ...ProviderOption) *SiteVerify

HCaptcha verifies hCaptcha tokens with the site's secret key.

func ReCAPTCHA

func ReCAPTCHA(secret string, opts ...ProviderOption) *SiteVerify

ReCAPTCHA verifies Google reCAPTCHA tokens, v2 and v3, with the site's secret key. A v3 token carries a score and must reach WithMinScore; a v2 token carries none and is judged by the provider's success flag.

func Turnstile

func Turnstile(secret string, opts ...ProviderOption) *SiteVerify

Turnstile verifies Cloudflare Turnstile tokens with the site's secret key.

func (*SiteVerify) Provider

func (s *SiteVerify) Provider() string

Provider names the built-in provider, for logs and pages.

func (*SiteVerify) Validate

func (s *SiteVerify) Validate(*auth.Config) error

Validate refuses an empty secret and an endpoint that is not an absolute URL, at auth.New rather than at the first sign-in.

func (*SiteVerify) Verify

func (s *SiteVerify) Verify(ctx context.Context, token, remoteIP string) error

Verify posts the token to the siteverify endpoint and applies the hostname, action and score checks to the verdict. Transport and decoding failures are returned as they are, for the plugin to fail closed on; a rejection is ErrFailed with the provider's reason wrapped for the log.

type Verifier

type Verifier interface {
	Verify(ctx context.Context, token, remoteIP string) error
}

Verifier checks one token. It returns nil for a token the provider accepts, ErrFailed (or an error wrapping it) for one it rejects, and any other error when no verdict could be had; the plugin reports that as ErrUnavailable and refuses the call. remoteIP is the client address, "" when unknown, for providers that score it.

type VerifierFunc

type VerifierFunc func(ctx context.Context, token, remoteIP string) error

VerifierFunc adapts a function to Verifier.

func (VerifierFunc) Verify

func (f VerifierFunc) Verify(ctx context.Context, token, remoteIP string) error

Verify calls f.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL