Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 44 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,53 @@ Small Go client for [hCaptcha Siteverify](https://docs.hcaptcha.com/#verify-the-
go get github.com/hCaptcha/hcaptcha-go/hcaptcha
```

Requires Go 1.24 or later.
Requires Go 1.24 or later. Use a supported, patched Go toolchain in production.

## Use

Create a client once with an hCaptcha service secret, then reuse it across handlers. Keep the secret only in server-side configuration.
This minimal local server uses `HCAPTCHA_SECRET` and `HCAPTCHA_SITEKEY`. Create the verifier at startup and reuse it across handlers. Exit before serving requests if either value is missing.

```go
package main

import (
"log"
"net/http"
"os"
"time"

hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha"
)

var verifier, err = hcaptcha.New("server-side-secret")

func protected(w http.ResponseWriter, r *http.Request) {
func main() {
verifier, err := hcaptcha.New(os.Getenv("HCAPTCHA_SECRET"))
if err != nil {
http.Error(w, "verification unavailable", http.StatusInternalServerError)
return
log.Fatalf("hcaptcha init failed: %v", err)
}
siteKey := os.Getenv("HCAPTCHA_SITEKEY")
if siteKey == "" {
log.Fatal("HCAPTCHA_SITEKEY is required")
}
http.HandleFunc("POST /protected", func(w http.ResponseWriter, r *http.Request) {
protected(verifier, siteKey, w, r)
})
server := &http.Server{Addr: "127.0.0.1:8080", ReadHeaderTimeout: 5 * time.Second}
log.Fatal(server.ListenAndServe())
}

func protected(verifier *hcaptcha.Client, siteKey string, w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
token := r.PostForm.Get("h-captcha-response")
if token == "" {
http.Error(w, "token is required", http.StatusBadRequest)
return
}
result, err := verifier.VerifyRequest(r.Context(), hcaptcha.Request{
Token: r.FormValue("h-captcha-response"),
SiteKey: "expected-sitekey-uuid", // Bind token to this sitekey.
Token: token,
SiteKey: siteKey,
})
if err != nil {
http.Error(w, "verification unavailable", http.StatusBadGateway)
Expand All @@ -53,51 +71,43 @@ func protected(w http.ResponseWriter, r *http.Request) {

Use `Verify(token)` for the minimal case. `VerifyContext(ctx, token)` and `VerifyRequest(ctx, request)` propagate handler cancellation. `Request.Token` is required; `RemoteIP` and `SiteKey` are optional parameters sent only when non-empty.

`Result` is `map[string]any`, retaining all fields returned by Siteverify. Treat a transport or decode `error` separately from an unsuccessful response, and fail closed in both cases. Check `success` before protected work. Inspect `error-codes` for operational logging; do not expose raw verification details to users.
`New` uses a one-second HTTP timeout per attempt; use `NewWithHTTPClient` to configure it. `Request.MaxRetries` sets extra attempts after transport errors or HTTP 429/5xx. The default `0` makes one attempt; `2` allows three. Retry delays start at 100 ms, double up to 1 s, and stop on context cancellation.

## Browser integration
[Production tokens are single-use](https://docs.hcaptcha.com/#verify-the-user-response-server-side). The first POST may succeed at hCaptcha even if your server times out before receiving the reply. Retrying the same token can then return `already-seen-response`; treat it as failure and obtain a fresh token.

This package receives the token generated by a browser or mobile integration. Add the hCaptcha widget, submit its `h-captcha-response` field to your backend, then call this package from that backend.
`Result` decodes the Siteverify JSON object into `map[string]any`. `VerifyRequest` returns an error for non-2xx HTTP status, transport failure, or invalid JSON. A well-formed 2xx response with `success: false` has no Go error. Deny the protected action unless `result["success"] == true`. Inspect `error-codes` for operational logging; do not expose raw verification details to users.

```html
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<div class="h-captcha" data-sitekey="your-public-sitekey"></div>
```
## Browser integration

This package receives the token generated by a browser or mobile integration. Submit the widget's `h-captcha-response` field to your backend, then call this package from that backend. For the standalone server above, post to `/protected` and use the value of `HCAPTCHA_SITEKEY` as the widget sitekey. The runnable React/Vite example below uses `VITE_HCAPTCHA_SITEKEY` and posts to `/api/protected`, which Vite proxies to the backend's `/protected` route.

Use the [Developer Guide](https://docs.hcaptcha.com/) for the complete browser-to-server flow and the [JavaScript configuration reference](https://docs.hcaptcha.com/configuration) for explicit rendering, callbacks, themes, and client-side errors.

## Security and operations

- Call Siteverify from the backend with a form-encoded POST. Do not expose the secret or accept a client-side success claim.
- Verify tokens promptly and exactly once. hCaptcha tokens are short-lived and single-use.
- Verify tokens promptly. hCaptcha tokens are short-lived and single-use.
- Pass the expected `SiteKey` to prevent a token issued for another sitekey from being accepted. `hostname` is informational, not an authentication control.
- Send `RemoteIP` when available, using an IP derived from a trusted-proxy policy. Do not trust forwarding headers from arbitrary clients.
- Configure the hCaptcha domain allowlist and keep the secret in a secret manager or environment variable. [Rotate a leaked secret](https://docs.hcaptcha.com/#rotating-your-siteverify-secret) immediately.
- Enable the hCaptcha domain allowlist when available. Keep the secret in a secret manager or environment variable; [rotate a leaked secret](https://docs.hcaptcha.com/#rotating-your-siteverify-secret) immediately.

See hCaptcha’s [server-side verification guidance](https://docs.hcaptcha.com/#verify-the-user-response-server-side), [domain allowlist configuration](https://docs.hcaptcha.com/configuration#domain-allowlist), and [Siteverify error-code reference](https://docs.hcaptcha.com/#siteverify-error-codes-table).


## Example and testing

Run the included React/Vite browser and Go backend example:
From the repository root, run package tests:

```sh
cd example
cp .env.local.example .env.local
npm install
./run.sh
go test ./...
```

The sample env uses [hCaptcha's public test keys](https://docs.hcaptcha.com/#integration-testing-test-keys),
which skip visual challenges. To use your own keys locally, follow
[the example's local hostname setup](example/README.md#run). hCaptcha's
[local development guide](https://docs.hcaptcha.com/#local-development)
lists `localhost` and `127.0.0.1` as unsupported hostnames. In this
example, a real-key challenge can still pass there after several checkbox
attempts, but that flow is unreliable.

Run package tests with:
Run the included React/Vite browser and Go backend example:

```sh
go test ./...
cd example
cp -n .env.local.example .env.local
npm ci
./run.sh
```

The sample env uses [hCaptcha's public test keys](https://docs.hcaptcha.com/#integration-testing-test-keys), which skip visual challenges. For real keys, follow [the example's development hostname setup](example/README.md#run); hCaptcha does not support `localhost` or `127.0.0.1` as hostnames.
17 changes: 7 additions & 10 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ remain same-origin.

```sh
cd example
cp .env.local.example .env.local
npm install
cp -n .env.local.example .env.local
npm ci
./run.sh
```

hCaptcha's [local development guide](https://docs.hcaptcha.com/#local-development)
lists `localhost` and `127.0.0.1` as unsupported hostnames. On a loopback URL,
this example can still show a challenge and pass Siteverify, though users may
need to click the checkbox a few times. The hostname warning makes that flow
unreliable. To solve challenges locally without the warning:
lists `localhost` and `127.0.0.1` as unsupported hostnames. For real keys, use a
development hostname:

1. Choose a development hostname under a domain you control, such as
`captcha-dev.example.com` (replace this placeholder with your own hostname).
Expand All @@ -42,11 +40,10 @@ to `ALLOWED_HOSTS` and, when enabled, the sitekey's domain allowlist. If hCaptch
reports [`network-error`](https://docs.hcaptcha.com/configuration#error-codes)
on the mapped hostname, inspect browser requests and blockers.

The backend binds to `127.0.0.1:8080`. It accepts `X-Forwarded-For` only from a
loopback peer (the Vite proxy), sends that IP and the expected sitekey to
The backend binds to `127.0.0.1:8080`, sends the expected sitekey to
[Siteverify](https://docs.hcaptcha.com/#verify-the-user-response-server-side),
and returns the full Siteverify result for inspection. Do not copy
that diagnostic response behavior into a production endpoint.
and returns the full Siteverify result for inspection. Do not copy that
diagnostic response behavior into a production endpoint.

## Expected checks

Expand Down
25 changes: 3 additions & 22 deletions example/backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"log"
"net"
"net/http"
"os"
"strings"
Expand Down Expand Up @@ -63,16 +62,15 @@ func (app application) protected(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid form"})
return
}
token := strings.TrimSpace(r.FormValue("h-captcha-response"))
token := strings.TrimSpace(r.PostForm.Get("h-captcha-response"))
if token == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "valid token is required"})
return
}

result, err := app.verifier.VerifyRequest(r.Context(), hcaptcha.Request{
Token: token,
RemoteIP: clientIP(r),
SiteKey: app.siteKey,
Token: token,
SiteKey: app.siteKey,
})
if err != nil {
log.Printf("siteverify call failed: %v", err)
Expand All @@ -89,23 +87,6 @@ func (app application) protected(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"siteverify": result})
}

func clientIP(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return ""
}
peerIP := net.ParseIP(peer)
if peerIP == nil || !peerIP.IsLoopback() {
return peer
}

forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0])
if net.ParseIP(forwarded) != nil {
return forwarded
}
return peer
}

func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
Expand Down
13 changes: 10 additions & 3 deletions example/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ set -a
. ./.env.local
set +a

backend_dir=$(mktemp -d)
backend_pid=
cleanup() {
kill "$backend_pid" 2>/dev/null || true
if [ -n "$backend_pid" ]; then
kill "$backend_pid" 2>/dev/null || true
wait "$backend_pid" 2>/dev/null || true
fi
rm -rf "$backend_dir"
}
trap cleanup EXIT INT TERM
trap cleanup EXIT

go run ./backend &
go build -o "$backend_dir/backend" ./backend
"$backend_dir/backend" &
backend_pid=$!
npm run dev
8 changes: 5 additions & 3 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export function App() {
<h1>hCaptcha Go integration</h1>
<p>
Complete the challenge, then send the token through the Vite proxy to
the Go backend. The response below is the package&apos;s unmodified
Siteverify result.
the Go backend. The response below includes the full Siteverify result.
</p>
{!siteKey && <p className="error">VITE_HCAPTCHA_SITEKEY is missing.</p>}
{widgetError && <p className="error">{widgetError}</p>}
Expand All @@ -48,7 +47,10 @@ export function App() {
<HCaptcha
ref={captcha}
sitekey={siteKey}
onVerify={setToken}
onVerify={(value) => {
setWidgetError("");
setToken(value);
}}
onExpire={() => setToken("")}
onError={(error) => {
setToken("");
Expand Down
5 changes: 3 additions & 2 deletions hcaptcha/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package hcaptcha_test

import (
"context"
"log"
"os"

hcaptcha "github.com/hCaptcha/hcaptcha-go/hcaptcha"
Expand All @@ -12,7 +13,7 @@ func ExampleClient_Verify() {
token := "response token from the application request"
verifier, err := hcaptcha.New(secret)
if err != nil {
return
log.Fatalf("hcaptcha init failed: %v", err)
}

result, err := verifier.Verify(token)
Expand All @@ -26,7 +27,7 @@ func ExampleClient_Verify() {
func ExampleClient_VerifyRequest() {
verifier, err := hcaptcha.New(os.Getenv("HCAPTCHA_SECRET"))
if err != nil {
return
log.Fatalf("hcaptcha init failed: %v", err)
}
result, err := verifier.VerifyRequest(context.Background(), hcaptcha.Request{
Token: "response token from the application request",
Expand Down
56 changes: 46 additions & 10 deletions hcaptcha/hcaptcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
Expand All @@ -15,6 +16,9 @@ import (
const (
siteverifyEndpoint = "https://api.hcaptcha.com/siteverify"
defaultTimeout = time.Second
initialRetryDelay = 100 * time.Millisecond
maxRetryDelay = time.Second
maxErrorBodyBytes = 2 << 10
)

// Result contains the Siteverify response. The basic response includes success
Expand All @@ -35,6 +39,11 @@ type Request struct {
// Recommended.
RemoteIP string
SiteKey string

// MaxRetries is the number of extra attempts after a transport error or HTTP 429/5xx.
// Zero means no retries. Siteverify may verify a token even if this client gets an error.
// Retrying that token can fail because tokens are single-use.
MaxRetries int
}

// Client verifies tokens using the secret passed to New. A Client is safe to
Expand Down Expand Up @@ -91,10 +100,30 @@ func (client *Client) VerifyRequest(ctx context.Context, request Request) (Resul
if request.Token == "" {
return nil, errors.New("hcaptcha: token is required")
}
if request.MaxRetries < 0 {
return nil, errors.New("hcaptcha: max retries must not be negative")
}
if ctx == nil {
ctx = context.Background()
}
form := url.Values{"secret": {client.secret}, "response": {request.Token}}
setString(form, "remoteip", request.RemoteIP)
setString(form, "sitekey", request.SiteKey)
return client.call(ctx, client.endpoint, form)
delay := initialRetryDelay
for attempt := 0; ; attempt++ {
result, retryable, err := client.call(ctx, client.endpoint, form)
if err == nil || !retryable || attempt >= request.MaxRetries {
return result, err
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
delay = min(delay*2, maxRetryDelay)
}
}

func setString(form url.Values, name, value string) {
Expand All @@ -103,26 +132,33 @@ func setString(form url.Values, name, value string) {
}
}

func (client *Client) call(ctx context.Context, endpoint string, form url.Values) (Result, error) {
if ctx == nil {
ctx = context.Background()
}
func (client *Client) call(ctx context.Context, endpoint string, form url.Values) (Result, bool, error) {
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
if err != nil {
return nil, fmt.Errorf("hcaptcha: create request: %w", err)
return nil, false, fmt.Errorf("hcaptcha: create request: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpRequest.Header.Set("Accept", "application/json")

response, err := client.httpClient.Do(httpRequest)
if err != nil {
return nil, fmt.Errorf("hcaptcha: call siteverify: %w", err)
return nil, ctx.Err() == nil, fmt.Errorf("hcaptcha: call siteverify: %w", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.CopyN(io.Discard, response.Body, maxErrorBodyBytes+1)
status := response.StatusCode
retryable := status == http.StatusTooManyRequests || status >= 500
return nil, retryable, fmt.Errorf("hcaptcha: siteverify returned HTTP %d", status)
}

result := Result{}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("hcaptcha: decode siteverify response: %w", err)
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&result); err != nil {
return nil, false, fmt.Errorf("hcaptcha: decode siteverify response: %w", err)
}
if err := decoder.Decode(new(json.RawMessage)); err != io.EOF {
return nil, false, errors.New("hcaptcha: siteverify response has trailing data")
}
return result, nil
return result, false, nil
}
Loading
Loading