A feedback form can receive automated submissions even when every field passes validation. CAPTCHA adds a provider-issued browser token that your server verifies before accepting the feedback. An accepted token is an abuse signal, not proof of identity or permission; it does not replace CSRF protection.
Arkoren ships vendor-local verifiers and optional route middleware. There is no core CAPTCHA contract, and the two providers’ tokens are not interchangeable.
Provider Choice
Choose Cloudflare Turnstile for an accepted-or-rejected token check. Choose Google
reCAPTCHA when your application needs a risk assessment and owns the decision to
accept, deny, or require more verification. A valid Google token can have a low
score: direct Verify returns that assessment successfully. Only the optional
middleware applies a configured minimum score automatically.
Compatibility Matrix
This comparison covers Arkoren’s server-side adapters, not every vendor feature or a guarantee that a visitor is human.
| Provider | Hostname Allowlist | Expected Action | Risk Score | Route Middleware | Middleware Score Policy |
|---|---|---|---|---|---|
| Cloudflare Turnstile | Yes | Yes | No | Yes | No |
| Google reCAPTCHA | Yes | Yes | Yes | Yes |
Both adapters require an allowlist from trusted deployment configuration. Supply
bare hostnames, not URLs, wildcards, or a list derived from the incoming Host
header. Neither implicitly trusts subdomains. Turnstile compares case-sensitively;
Google compares case-insensitively.
Choose an action literal on the server, such as feedback, and use it in the
browser integration too. Empty ExpectedAction or middleware Action skips the
local action comparison. Do not copy the expected value from the submitted body.
Create one verifier during application setup, handle construction errors before mounting routes, and reuse it concurrently. Neither verifier has lifecycle or readiness hooks. Keep secrets and API credentials on the server; browser site keys are public.
Cloudflare Turnstile
Create a Turnstile widget for your deployment’s hostnames. Store its secret in server configuration and use its public site key in the browser. From your application module, install:
go get arkoren.dev/driver/cloudflare
Server Setup
Import arkoren.dev/driver/cloudflare/cloudflarecaptcha. With secret loaded
from server configuration, construct the verifier:
verifier, err := cloudflarecaptcha.New(secret, []string{"app.example.com"})
Construction validates local settings without network I/O. A blank secret, empty
allowlist, or invalid hostname returns ErrConfiguration; handle err before
using verifier. Verification uses Turnstile’s HTTPS Siteverify endpoint.
The default verification deadline is ten seconds, including response reading.
If feedback needs a shorter budget, import time and use this alternative
construction:
verifier, err := cloudflarecaptcha.New(secret, []string{"app.example.com"},
func(connection *cloudflarecaptcha.Builder) {
connection.Timeout(2 * time.Second)
})
An earlier caller deadline still wins. Zero restores ten seconds; negative
timeouts fail construction. Builder.HTTPClient can borrow an existing
*http.Client: construction snapshots its fields, disables redirects in the
snapshot, and retains its transport and cookie jar without modifying your client.
A shorter native timeout still applies. Configure shared objects before
concurrent use; the verifier closes neither client nor transport.
Verification makes one attempt and does not send Siteverify’s optional
idempotency key. Do not add a retrying transport: a timeout can mean the token
was consumed. Empty tokens or tokens over 2,048 bytes reject before I/O. Nil
error means provider success, a nonzero challenge timestamp, an allowed hostname,
and a matching action when requested. Returned CData remains untrusted,
potentially sensitive client-originated metadata, not a permission claim or risk score.
Formatting a verifier value or pointer with %v, %+v, or %#v emits
[REDACTED Turnstile verifier] without traversing its borrowed objects. This is
not a general sanitization boundary for reflection, serialization, invalid format
verbs, or native objects, and does not apply to the Google verifier. There is no
explicit secret-zeroization operation.
Browser Token
Define the token callback before loading the widget script. Replace
TURNSTILE_SITE_KEY with your public key:
<script>
function feedbackToken(token) {
// Attach token as captcha_token to the pending feedback submission.
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="TURNSTILE_SITE_KEY"
data-action="feedback" data-callback="feedbackToken"></div>
The callback receives a credential, not verification success. Attach it to your
submission and wait for the server’s result. Clear stored tokens on widget error
or expiry and reset the widget before another attempt. Traditional forms instead
receive a cf-turnstile-response field; decode that representation explicitly
rather than treating it as JSON.
Turnstile tokens expire after five minutes and are single-use. Continue with JSON verification, or choose header middleware instead; do not verify the same token in both places.
For constructor and evidence details, see the Cloudflare CAPTCHA API reference.
Google reCAPTCHA
Create a Google Cloud project, enable the reCAPTCHA Enterprise API, and create a score-based website key for your hostnames. Use that project and site key on the server. Install:
go get arkoren.dev/driver/gcp
Server Setup
Configure Application Default Credentials (ADC) for assessment creation, with
roles/recaptchaenterprise.agent on the project. Import
arkoren.dev/driver/gcp/gcpcaptcha. With the setup context ctx, trusted
projectID, and public siteKey, construct the verifier:
verifier, err := gcpcaptcha.New(ctx, projectID, siteKey, []string{"app.example.com"})
Construction validates settings and loads credentials; it does not create an
assessment or perform readiness checks. ADC discovery can do credential-loading
I/O, and its synchronous discovery call cannot be interrupted by ctx. The
constructor checks cancellation around discovery and uses ctx for quota-project
lookup, but does not retain it. Later native token refresh uses each verification
request’s bounded context; asynchronous background refresh is disabled.
If you use a restricted server API key instead of ADC, pass it in this alternative
construction. apiKey is not the public browser site key:
verifier, err := gcpcaptcha.New(ctx, projectID, siteKey, []string{"app.example.com"},
func(access *gcpcaptcha.Builder) {
access.APIKey(apiKey)
})
This bypasses ADC and sends the key as X-Goog-Api-Key, never in the URL. Keep
the key and credential-bearing construction draft out of logs.
Borrowed Clients
If your application already owns an authenticated *http.Client named client,
select it instead of ADC:
verifier, err := gcpcaptcha.New(ctx, projectID, siteKey, []string{"app.example.com"},
func(access *gcpcaptcha.Builder) {
access.HTTPClient(client)
})
A nonnil client bypasses ADC completely. Without APIKey, its transport must
authenticate requests. If you intentionally configure both, the adapter adds
the API-key header without removing transport authentication. A nil client
restores normal ADC/API-key selection.
Construction snapshots the client, discards its jar, and disables redirects. Its transport remains borrowed and is never closed. Positive client timeouts shorter than ten seconds are retained; zero, negative, or longer timeouts become ten seconds. Each verification, including ADC token refresh, also has a ten-second context limit and honors an earlier caller deadline. Custom transports must be concurrency-safe, honor cancellation, and never retry tokens. The builder has no timeout or score-threshold setting; use a shorter caller context and choose score policy separately.
This adapter uses projects.assessments.create (CreateAssessment), not legacy
Siteverify or a shared-secret flow. It makes one attempt. Empty tokens and tokens
over 64 KiB reject locally; that size is an application guard, not a documented
Google maximum. Successful assessments expose Score, Name, and reason/evidence
fields. They do not authenticate a visitor or expose other Fraud Defense APIs.
Browser Token
Load the Enterprise script with your public score-based site key:
<script src="https://www.google.com/recaptcha/enterprise.js?render=RECAPTCHA_SITE_KEY"></script>
In your existing feedback-submit callback, execute the matching action. Replace the key here with the same value:
grecaptcha.enterprise.ready(async () => {
const token = await grecaptcha.enterprise.execute("RECAPTCHA_SITE_KEY", {
action: "feedback",
});
// Attach token as captcha_token to this feedback submission.
});
Handle script-load and execution errors in your UI; do not submit as though verification succeeded. Generate the token at the interaction, not when the page first opens. Google tokens expire after two minutes and can be assessed only once. Obtain a fresh token after expiry or a failed submission. Continue with JSON verification or the alternative header middleware.
For authentication and assessment fields, see the Google CAPTCHA API reference.
JSON Verification
For a token inside JSON, let the handler decode the body once and verify the token
before its effect. Apply body limits,
content-type checks, and
rate limiting before external verification;
request.JSON does not enforce Content-Type itself. See
middleware ordering when
placing those checks.
Use one provider branch below, not both. Neither adapter retries automatically, and custom HTTP transports must not replay a verification. If a response is lost, the token might already be spent: stop the effect and ask for a fresh token on a later attempt. A fresh token does not deduplicate feedback or any other business operation; application-owned idempotency remains separate.
Map Failures
Direct Verify returns plain classified errors, not HTTP problems. Before
registering a Turnstile handler, map them with
arkoren.dev/core/http/middleware/errormap, arkoren.dev/foundation/problem, and
net/http. Here routes is your existing router:
routes.Use(errormap.Middleware(
errormap.Map(cloudflarecaptcha.ErrRejected, problem.Forbidden()),
errormap.Map(cloudflarecaptcha.ErrUnavailable, problem.New(func(details *problem.Builder) {
details.Status(http.StatusServiceUnavailable)
})),
errormap.Map(cloudflarecaptcha.ErrConfiguration, problem.InternalServerError()),
))
For Google, replace the three sentinel qualifiers with gcpcaptcha. Rejection
then returns 403, provider unavailability 503, and configuration failure
500, without publishing provider diagnostics. Without mapping, a plain rejection
gets the router’s generic 500, not 403. Cancellation and deadline causes remain
identifiable with errors.Is; they are operational failures, not bad tokens.
Both adapters keep native diagnostics out of their Error() text. Original
causes remain inspectable with errors.Is and errors.As, however, and can contain
confidential transport or provider data. Do not dump unwrapped causes or assume
arbitrary formatting and serialization of wrappers sanitizes them.
Decode Input
Define the feedback request separately from provider input. JSON tags describe the browser’s representation, not verification policy:
type FeedbackInput struct {
Message string `json:"message"`
CAPTCHAToken string `json:"captcha_token"`
}
With request imported from arkoren.dev/core/http/request and incoming the
handler’s *http.Request, decode once:
input, err := request.JSON[FeedbackInput](incoming)
if err != nil {
return err
}
Validate the message using your application’s input rules before verification.
Keep CAPTCHAToken out of request-body logging, traces, and validation diagnostics.
Verify Turnstile
Using the Turnstile verifier from setup, require the server-owned feedback
action and stop on every verification error:
_, err = verifier.Verify(incoming.Context(), cloudflarecaptcha.Input{
Token: input.CAPTCHAToken,
ExpectedAction: "feedback",
})
if err != nil {
return err
}
// Accept the validated feedback here.
Only nil error permits the feedback operation to proceed. The hostname check always applies; the action check applies because you supplied a nonempty action.
Evaluate Scores
For Google, choose a finite feedbackMinScore in [0, 1] from your site’s
observations and validate it when loading server configuration. There is no
universal safe threshold. Higher scores indicate lower assessed risk. Replace
the Turnstile block with:
assessment, err := verifier.Verify(incoming.Context(), gcpcaptcha.Input{
Token: input.CAPTCHAToken,
ExpectedAction: "feedback",
})
if err != nil {
return err
}
if assessment.Score < feedbackMinScore {
return problem.Forbidden()
}
// Accept the validated feedback here.
The operation runs only after token verification and score policy succeed. Explicit zero accepts every valid score, but still requires valid token evidence, an allowed hostname, the requested action, and a present risk-analysis message. An omitted or null score inside a present message means zero under ProtoJSON; a positive threshold denies it as policy, not a verification outage. A missing risk-analysis message instead fails verification.
For login, direct verification can support a step-up decision instead of a flat
denial. After checking credentials and verifying once with ExpectedAction: "login",
a low score can start an application-owned pending factor challenge.
That branch must not create an authenticated session. Verify the extra factor
on a separate endpoint before signing in. A high score never replaces factors
your authentication policy always requires.
Optional Input.RemoteIP is a netip.Addr; supply it only from your trusted
client-address resolver, never directly from arbitrary forwarded headers. Google
also accepts optional Input.UserAgent. Sending these fields discloses visitor
data to the provider; omit them unless your privacy policy permits and needs
that disclosure. The examples omit both.
Header Middleware
If the token travels separately in a header, use middleware instead of handler verification. It extracts and verifies once, then calls the handler only on success. Neither adapter automatically parses JSON or forms, stores evidence in request context, or infers client IP; Google middleware also infers no user agent. Use direct verification when you need an assessment for a step-up decision.
Keep body limits, content-type checks, and rate budgets outside CAPTCHA middleware.
For cross-origin browser requests, CORS must permit
the token header. With net/http imported, define a body-preserving extractor:
extract := func(incoming *http.Request) (string, error) {
return incoming.Header.Get("X-CAPTCHA-Token"), nil
}
For an existing Turnstile verifier and application handler feedback, mount:
routes.With(cloudflarecaptcha.Middleware(verifier, extract,
func(policy *cloudflarecaptcha.MiddlewareBuilder) {
policy.Action("feedback")
})).Post("/feedback", feedback)
For Google, use this alternative and explicitly configure the minimum score:
routes.With(gcpcaptcha.Middleware(verifier, extract,
func(policy *gcpcaptcha.MiddlewareBuilder) {
policy.Action("feedback")
policy.MinScore(feedbackMinScore)
})).Post("/feedback", feedback)
The handler must not verify that token again. Google requires a finite minimum
in [0, 1]; omission is a configuration error, not a default threshold.
Both middleware adapters return safe HTTP problems: rejection is 403, including
a Google score below the minimum; provider unavailability is 503; configuration
failure is 500. Ordinary extractor errors reject with 403. Extractor errors
wrapping cancellation, deadline, or unavailability return 503 without adding a
token-rejection identity. Configuration takes precedence over other classes.
Original causes remain inspectable, but their text is not rendered to the client.
Turnstile panics during wiring for a nil verifier, extractor, next handler, or
construction callback. A zero verifier instead fails requests with configuration
errors. Google reports a nil verifier/extractor or missing/invalid score policy
as 500 on requests without extracting or verifying. Nil construction callbacks
panic in both adapters. These are configuration faults, not permission to bypass
the check.
Rejection Tests
Inject a controlled *http.Client through each provider’s Builder.HTTPClient
for deterministic responses. Google tests with a supplied client bypass ADC;
they do not verify live credentials or IAM permissions. Neither adapter has a
memory driver or local emulator. A stub proves application decisions, not a
genuine browser challenge.
Test accepted and missing tokens, rejection, hostname/action mismatches,
unavailability, and cancellation. For Google, include the exact score boundary,
a valid score below it, ProtoJSON’s zero score, and missing middleware MinScore.
Assert that every failure leaves the feedback effect untouched. If login uses a
step-up branch, assert that it creates only a pending challenge, never a session.
Keep real-browser and live-provider checks separate from deterministic tests.
For complete contracts, see the Cloudflare CAPTCHA API reference and Google CAPTCHA API reference.