Help us shape the future of Go! Take the 2026 Go Developer Survey

turnstile

package module
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 16 Imported by: 0

README

Arandu

Arandu Turnstile

Cloudflare Turnstile in front of an Arandu form: the view component that draws the challenge, the policy the page needs to load it, and the server-side check that decides whether the submission came from a person.

It is a library. It registers no route, owns no table and decides no authorization. An application builds one Verifier, wraps the routes that draw the widget, puts the component in the form and calls Check in the handler.

Install

go get github.com/hyz-is/arandu-turnstile

Create a widget in the Cloudflare dashboard (Turnstile, then Add widget) for the application's hostnames. It gives a site key, which is public, and a secret key, which is not. Keep both in the environment:

TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...

For local development and tests, Cloudflare publishes keys that always pass: turnstile.TestSiteKey and turnstile.TestSecretKey. Never deploy them.

Wire it

The Verifier, once, in bootstrap/app.go, from the application's own config:

captcha, err := turnstile.New(turnstile.Config{
	SiteKey:   cfg.Turnstile.SiteKey,
	SecretKey: cfg.Turnstile.SecretKey,
	Messages: turnstile.Messages{
		Rejected:    "Confirme que você não é um robô e envie o formulário de novo.",
		Unavailable: "Não conseguimos confirmar que você não é um robô. Aguarde um instante e envie de novo.",
	},
})
if err != nil {
	return nil, err
}

The routes whose pages draw the widget, wrapped in the policy that loads it. Pass the policy the rest of the application sends, with the same image origins:

r = r.Group("", turnstile.Headers(middleware.DefaultContentSecurityPolicy()))

turnstile.Headers adds https://challenges.cloudflare.com to script-src and frame-src on those pages and nowhere else, and answers a boosted navigation with HX-Redirect: a document keeps the policy of its first load, so a page with the widget has to be loaded, not swapped in.

Draw it

The component goes inside the form. The handler hands the page the site key:

import turnstile "github.com/hyz-is/arandu-turnstile/components"
<form method="post" action="{{ .StoreURL }}" hx-post="{{ .StoreURL }}">
	@csrf
	...
	{!! turnstile.Widget(turnstile.WidgetProps{
		SiteKey: .TurnstileSiteKey,
		Action:  "signup",
		Page:    .,
	}) !!}
	<button type="submit">Create account</button>
</form>

The widget writes its token into a hidden field named turnstile.FieldName (cf-turnstile-response). A refusal comes back under that field and is drawn below the challenge. After every htmx request its form sends, the widget is renewed, because a token can be spent only once.

Run aru view:build after installing, as for any component.

Check it

In the handler, before anything else is done with the submission:

// challengeRequest is the field the widget writes into the form.
type challengeRequest struct {
	Token string `form:"cf-turnstile-response"` // turnstile.FieldName
}

func (c *AccountController) Store(ctx *http.Context) error {
	var challenge challengeRequest
	if err := ctx.Bind(&challenge); err != nil {
		return err
	}
	if err := captcha.Check(ctx.Ctx(), challenge.Token, ctx.IP(), "signup"); err != nil {
		return err
	}
	// ...
}

The token is read with ctx.Bind into a struct, like every other field: aru doctor flags a field read by hand with ctx.Input. The tag has to be the value of turnstile.FieldName.

Check returns nil, or validation.Errors under turnstile.FieldName, which sends the visitor back to the form with the message. It fails closed: a missing, spent, expired or forged token is refused, and so is a submission made while Cloudflare cannot be reached. A secret key Cloudflare refuses is returned as turnstile.ErrMisconfigured, for the error page and the error report.

Verify is the lower-level call, for a caller that needs the Result or the reason (ErrMissingToken, ErrRejected, ErrUnavailable, ErrMisconfigured).

Layout

doc.go                   the package comment
config.go                Config, the constants and the test keys
verify.go                Verifier, New, Verify and the errors
check.go                 Check, for a form handler
headers.go               Policy and Headers
assets.go                the client behaviour, registered with the view layer
assets/                  arandu-turnstile.js
components/widget.kyse.go  the Widget component, and widget.go compiled from it

Tests

GOWORK=off go test -race ./...

The verification is tested against a stand-in for Cloudflare's endpoint, so the suite makes no call outside the process.

Licence

MIT. See LICENSE.md. Copyright Paulo R. Lima.

Documentation

Overview

Package turnstile puts Cloudflare Turnstile in front of a form: a view component that draws the challenge, the client behaviour that renders it and renews its token, the Content-Security-Policy the page needs to load it, and the server-side verification that decides whether the submission came from a person.

The four pieces are used together, and each one is a line somebody writes:

// bootstrap/app.go: one Verifier for the application.
captcha, err := turnstile.New(turnstile.Config{
	SiteKey:   cfg.Turnstile.SiteKey,
	SecretKey: cfg.Turnstile.SecretKey,
})

// routes: the pages that draw the widget carry the policy that loads it.
r = r.Group("", turnstile.Headers(middleware.DefaultContentSecurityPolicy()))

// view: the component goes inside the form.
{!! turnstile.Widget(turnstile.WidgetProps{
	SiteKey: .TurnstileSiteKey, Action: "signup", Page: .,
}) !!}

// handler: the token is read like any other field, and checked before
// anything else is done.
var challenge struct {
	Token string `form:"cf-turnstile-response"` // turnstile.FieldName
}
if err := ctx.Bind(&challenge); err != nil {
	return err
}
if err := captcha.Check(ctx.Ctx(), challenge.Token, ctx.IP(), "signup"); err != nil {
	return err
}

A rejected token comes back as validation.Errors under FieldName, so the form is drawn again with the message beside the widget, exactly as a field that failed its rule would be.

The verification fails closed. A token that is missing, malformed, spent, expired, solved on another hostname or for another action is refused, and so is a submission made while Cloudflare cannot be reached: a check that let a request through whenever it could not answer is a check an attacker can turn off by making it slow.

Index

Constants

View Source
const (
	// TestSiteKey draws a visible widget that always passes.
	TestSiteKey = "1x00000000000000000000AA"
	// TestSecretKey verifies TestToken as a success.
	TestSecretKey = "1x0000000000000000000000000000000AA"
	// TestSecretKeyFails verifies every token as a failure.
	TestSecretKeyFails = "2x0000000000000000000000000000000AA"
	// TestSecretKeySpent answers every token as already spent.
	TestSecretKeySpent = "3x0000000000000000000000000000000AA"
	// TestToken is the token a widget drawn with a test site key produces.
	TestToken = "XXXX.DUMMY.TOKEN.XXXX"
)

The keys Cloudflare publishes for testing. They work on any hostname, including localhost, and only with each other: a test secret accepts only TestToken, and a production secret refuses it.

They are for development and for test suites. An application configured with TestSecretKey in production accepts every submission.

View Source
const (
	DefaultRejectedMessage    = "Confirm that you are not a robot, then send the form again."
	DefaultUnavailableMessage = "We could not confirm that you are not a robot. Wait a moment and send the form again."
)

The sentences a refused submission is answered with when Config leaves Messages empty.

View Source
const DefaultEndpoint = Origin + "/turnstile/v0/siteverify"

DefaultEndpoint is where a token is verified when Config leaves Endpoint empty.

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout bounds one verification when Config leaves Client nil.

View Source
const FieldName = "cf-turnstile-response"

FieldName is the form field the widget writes its token into, and the field a rejection is reported under.

It is Cloudflare's own default and is not configurable here: the widget, the handler that reads the field and the view that draws the message all name it, and one constant is what keeps the three agreeing.

View Source
const MaxTokenLength = 2048

MaxTokenLength is the longest token Cloudflare issues. A longer value is refused without asking Cloudflare, because it cannot be a token.

View Source
const Origin = "https://challenges.cloudflare.com"

Origin is the one address Cloudflare serves Turnstile from: the script, the frame the challenge is drawn in, and the verification endpoint.

The script is loaded from it directly and never copied to this origin. Cloudflare changes the file without changing its address, and a copy served from anywhere else stops working on the day it does.

View Source
const ScriptAsset = "arandu-turnstile.js"

ScriptAsset is the name the client behaviour is served under, from this origin, by the application's asset handler. Widget writes the script tag that loads it.

Variables

View Source
var (
	// ErrMissingToken is a submission that carried no token: the widget was
	// not solved, or the form was sent by something that never drew it.
	ErrMissingToken = errors.New("turnstile: the submission carried no token")
	// ErrRejected is a token Cloudflare refused, or one it accepted for
	// another hostname or another action.
	ErrRejected = errors.New("turnstile: the token was refused")
	// ErrUnavailable is a verification that could not be completed: Cloudflare
	// did not answer, answered with an error, or answered something that is
	// not a verification.
	ErrUnavailable = errors.New("turnstile: the token could not be verified")
	// ErrMisconfigured is Cloudflare refusing the secret key. It is the
	// operator's to fix, and no visitor can do anything about it.
	ErrMisconfigured = errors.New("turnstile: Cloudflare refused the secret key")
)

The reasons a verification does not succeed. Every error Verify returns wraps exactly one of them, so a caller tells them apart with errors.Is.

Functions

func Headers

Headers is the middleware for the routes whose pages draw the widget. It writes Policy(base) on the answer, replacing the policy SecurityHeaders wrote, so the widening reaches those pages and no other.

base is the policy the application sends everywhere else, built the way the application builds it -- middleware.DefaultContentSecurityPolicy with the same image origins -- so the pages with the widget lose nothing the rest of the application allows.

A boosted navigation is answered with HX-Redirect to the same address. The policy that governs a document is the one its first load carried, and a boosted link swaps the body without loading a document: a page with the widget reached that way would run under the policy of the page the link was on, and Cloudflare's script would be refused. The redirect makes the browser load the page, and the page arrives with its own policy. It costs one round trip, on the boosted navigations that reach these routes and on nothing else.

func Policy

Policy is base widened by what the widget needs: Cloudflare's script, and the frame the challenge is drawn in. Nothing else is added, and no other directive changes.

When base has no frame-src of its own, frames were governed by default-src, so 'self' is kept beside Cloudflare: a frame-src that named only Cloudflare would refuse the application's own frames on the pages that draw the widget.

Types

type Challenge

type Challenge struct {
	// Token is the value the widget wrote into FieldName.
	Token string
	// RemoteIP is the address of the visitor, as the application's proxy
	// configuration decided it. It is a hint to Cloudflare and may be empty;
	// a value that is not an address is not sent.
	RemoteIP string
	// Action, when set, is the action the widget had to be drawn with. A token
	// solved for another action is refused, which is what stops a token taken
	// from the newsletter form from being spent on the sign-up form.
	Action string
}

Challenge is one token to verify, with what it is expected to prove.

type Config

type Config struct {
	// SiteKey identifies the widget. It is public and is written into the page.
	SiteKey string
	// SecretKey authenticates the verification. It never reaches a page, a
	// log line or an error message.
	SecretKey string
	// Hostnames, when set, are the only hostnames a token may have been solved
	// on. Empty accepts the hostnames the widget is configured for in the
	// Cloudflare dashboard, which is where that list belongs; set this when one
	// widget serves several applications and each should accept only its own.
	Hostnames []string
	// Client sends the verification. Nil is a client that gives up after
	// DefaultTimeout.
	Client *http.Client
	// Endpoint is the verification address. Empty is DefaultEndpoint. It has
	// to be https, because the request carries the secret key.
	Endpoint string
	// Messages are the sentences a refused submission is answered with.
	Messages Messages
}

Config is what the application passes when it builds the Verifier.

The keys arrive as values the application read from its own configuration. Nothing here reads the environment: a key read at call time is a key whose absence is discovered by the first visitor rather than at boot.

func (Config) GoString

func (c Config) GoString() string

GoString is String, for the %#v verb.

func (Config) String

func (c Config) String() string

String describes the configuration with the secret key left out, so a Config that reaches a log line or a panic does not carry it there.

func (Config) Validate

func (c Config) Validate() error

Validate reports what the configuration cannot be used with. New calls it, so a key that is missing fails where the application is wired.

type Messages

type Messages struct {
	// Rejected is said when the token was missing or refused: the person has
	// to solve the challenge again.
	Rejected string
	// Unavailable is said when Cloudflare could not be asked: the person did
	// nothing wrong and can send the form again.
	Unavailable string
}

Messages are the sentences a refused submission is answered with, in the language of the application. An empty one is the English default.

type Result

type Result struct {
	// Success is whether Cloudflare accepted the token.
	Success bool
	// ChallengeTS is when the challenge was solved.
	ChallengeTS time.Time
	// Hostname is the hostname of the page the challenge was solved on.
	Hostname string
	// Action and CData are what the widget was drawn with.
	Action string
	CData  string
	// ErrorCodes are Cloudflare's reasons for refusing the token.
	ErrorCodes []string
}

Result is what Cloudflare answered about a token.

type Verifier

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

Verifier checks tokens against Cloudflare. One is built at boot and shared: it holds no state between calls and is safe for concurrent use.

func New

func New(cfg Config) (*Verifier, error)

New returns the Verifier, or the reason it cannot be built.

func (*Verifier) Check

func (v *Verifier) Check(ctx context.Context, token, remoteIP, action string) error

Check verifies the token of one form submission and answers the way a handler returns a failed rule.

It returns nil when the token is good. A token that is missing or refused, and a verification Cloudflare could not complete, return validation.Errors with one message under FieldName, which the router answers by sending the visitor back to the form with the message beside the widget. The two cases carry different messages, because only the first asks the person to do anything differently.

ErrMisconfigured is returned as it is: a secret key Cloudflare refuses is a fault of the deployment, and it belongs on the error page and in the error report rather than in front of a visitor who can do nothing about it.

Verify is the call for a caller that needs the Result or the reason.

func (*Verifier) SiteKey

func (v *Verifier) SiteKey() string

SiteKey is the public key of the widget, for the page that draws it.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, c Challenge) (Result, error)

Verify asks Cloudflare about one token.

A token is spent by asking: Cloudflare answers each token once, and a second verification of the same token is refused as a duplicate. So Verify is called once per submission, by the handler that acts on it, and never by middleware that runs before a decision about whether the request will be acted on at all.

It returns the Result whenever Cloudflare answered, including alongside an error, so a caller can record why a token was refused.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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