HeCAPTe #
Humane, embeddable Cost-Asymmetric Proof-of-work Turing exam

HeCAPTe (/ˈhek.æp.ti/ | HEK-ap-tee) is a privacy-respecting, stateless CAPTCHA. Instead of tracking user behavior or making people transcribe warped text, it has the user's browser solve a computational puzzle. That proof-of-work makes mass requests expensive for bots while staying cheap for legitimate users.
The name unpacks like this:
- Humane: No interaction, no accessibility barrier. The browser solves the puzzle; the user never sees it. Nobody's time gets spent training image-recognition models or fighting a CAPTCHA's audio alternative.
- Embeddable: One small Go binary, a few static files, and an SQLite database. The cheapest VPS runs it fine.
- Cost-Asymmetric: Solving a puzzle takes a few seconds of client CPU; checking the solution takes the server milliseconds. You set the difficulty yourself, and the admin settings page has a benchmark that times every preset on the device you run it on.
- Proof-of-work: The puzzle is Equihash, a memory-hard algorithm that resists GPU and ASIC shortcuts. Electricity and hardware are the toll.
- Turing exam: Not quite a Turing test. A solved puzzle does not prove a human is present, and was never meant to. That original CAPTCHA vision of proving humanity is dead: audio and image CAPTCHAs fold to OCR, and the rest fall to solver services paying underpaid workers fractions of a penny per answer.
The user-facing endpoints (/challenge and /verify) never read the client IP address and store nothing about a user. The only IP use is the rate limiter on the admin login, guarding the operator's own password (see No User Tracking).
Don't try to barricade the door against bots and only trip up humans. HeCAPTe makes one human request free, and a dozen bot requests infeasible.
Built with 💖 by Kat Suricata.
If you are new to HeCAPTe, read the sections in the order that matches your goal:
- I want to run it. Read Prerequisites, Installation, and Configuration, then the run mode that matches you: Running Locally, Running in Production, or Running on Cloudron.
- I want to protect my form with it. Install the server, then read Embedding in Your Application.
- I want to operate it long-term. Read Replay Protection and High Availability, Request Filtering, and Observability.
- I want to port it into another project. Read API Reference. Then read the Go packages for tokens, crypto, and db.
Is HeCAPTe right for you? #
HeCAPTe is not a universal solution, and this section tries to be honest about that. The short version: it makes casual spam uneconomical, it never tracks your users, and it will not stop a determined attacker.
How it compares #
| reCAPTCHA / Turnstile | HeCAPTe | |
|---|---|---|
| What it proves | "Probably human" (behavioral signals, browser fingerprinting) | "Someone paid CPU" (proof of work) |
| User data | Flows to Google or Cloudflare; your visitors' browsing habits feed the incumbent's models | Never leaves your server; the public endpoints do not read the client IP |
| User experience | Checkboxes, image grids, invisible scoring | Fully invisible; the browser solves a puzzle while the user reads the form |
| Accessibility | A known barrier; audio challenges are a documented failure mode | No interaction, so no barrier |
| Ops burden | None (someone else's servers) | One Go binary plus SQLite; a cheap VPS runs it |
| Cost to you | Free tier, then paid; the real price is the data | Free; you pay your own hosting |
| Cost to a spammer | Solver services: fractions of a penny per solve, staffed by underpaid workers | Client CPU at scale: real electricity and hardware per attempt |
| Stops a determined attacker | Mostly yes, via device reputation and network effects the incumbents have and you don't | No. Honest answer. |
The honest framing: the incumbents stopped being "prove you're human" systems years ago and became "prove you're a tracked, monetizable user" systems. HeCAPTe refuses that trade. What it offers instead is a toll booth, and toll booths only work when the toll exceeds the profit.
Threat model, plainly #
HeCAPTe's security rests on cost asymmetry: solving a puzzle costs a few seconds of client CPU (measured at roughly 0.5s native at the recommended preset, several-fold slower in the browser's WASM sandbox), while verifying one costs the server sub-millisecond time. This asymmetry is real and it holds, but only against adversaries for whom CPU is a cost.
- What it defeats: scripted comment/registration floods, drive-by scanners, form spammers running on thin margins. If a spam run nets pennies per thousand submissions, a seconds-long CPU toll per submission breaks the business model.
- What it does not defeat: a botnet operator with idle CPUs (they pay the toll with other people's electricity), a state actor, or anyone whose payoff per submission exceeds the electricity cost. Client-side proof of work cannot solve this. Neither, candidly, can much else at this price point, but the incumbents' device-reputation moat genuinely is stronger against this class.
- What the extra machinery is for: the module handshake and worker attestation are explicitly not secrets. They tax someone reimplementing the wire format, they do not block a headless browser running the real WASM. The real defense is the cost asymmetry plus the request filter, which makes known-scanner traffic solve harder puzzles before it fails.
Known limitations:
- Proof of work, not proof of person. A solved puzzle means a computer computed, nothing more.
- The admin panel has one account. No multi-user admin, no roles. It is meant for the person running the server.
- 60-second challenge window. A solver that cannot finish in 60 seconds needs a lower preset. Very low-end devices on the
highpreset will time out; the settings-page benchmark exists so you can check before shipping. - No behavioral signal. If stopping sophisticated bots is a hard requirement, layer HeCAPTe with rate limiting per credential, or accept that you need an incumbent's network effect.
Yes, if you run a small-to-medium site with a form that attracts spam, you don't want to hand your users to Google or Cloudflare, and you're fine with a few seconds of client CPU per submission.
Maybe not, if your adversaries will cheerfully burn CPU at scale, or if you're protecting something valuable enough to attract them. HeCAPTe raises the cost of spam. It assumes a profit motive.
Table of Contents #
- Request Filtering
- No User Tracking
- Replay Protection and High Availability
- Observability
- API Reference
- Usage
- Ports & Integrations
- Error Handling
- Project Layout
- Internationalization (i18n)
- License
Overview #
HeCAPTe implements a stateless, privacy-preserving proof-of-work system that uses the Equihash algorithm. It has four main components:
- Challenge Server (
cmd/server/main.go): HTTP server that generates signed challenges and verifies solutions - WASM Solver (
cmd/wasm-solver/main.go): WebAssembly module that solves Equihash puzzles in the browser - Admin Interface (
internal/admin/): Web-based management for site keys and configuration - Internationalization (
internal/i18n/,locales/): Multi-language support via go-i18n with embedded TOML translation files
How It Works #
- Challenge Request: The client requests a challenge from
/challenge?site_key=... - Challenge Generation: The server generates a random salt and picks the nonce search window of the solver. The server signs the salt, the difficulty parameters (N, K), the timestamp, the nonce window, the solver generation, and the page origin. The request filter can raise the difficulty or flag the challenge at this point.
- WASM Solver: The client uses the WebAssembly module to solve the Equihash puzzle inside the signed nonce window. The module echoes its compiled-in solver generation and a module handshake digest with the result.
- Verification: The client sends the solved proof to your backend. Your backend sends the proof to
/verifywithAuthorization: Bearer <secret_key>. The server checks the HMAC signature in constant time. Then it checks the nonce form, the nonce window, and the solver generation. Then it verifies the Equihash proof and the module handshake. The server records the salt in the replay store, so one proof can pass exactly one time. - Result: If the solution is valid, the server returns a success status. If the solution is invalid or expired, the server returns an error.
Site Key and Secret Key #
Each site has two keys. The two keys are different values. Do not mix them.
| Value | Also called | Who uses it | Where you send it |
|---|---|---|---|
site_key |
Public key | Anyone. You embed it in your page JavaScript. | To /challenge and to /verify. |
secret_key |
Private key | Your backend only. Never in a page or a browser. | As the Authorization: Bearer credential to /verify. |
The browser sends the public site_key to /challenge. Your backend sends the same site_key and the secret_key to /verify. HeCAPTe also uses the secret key to sign challenges and to check their signatures.
Key Features #
- Stateless verification: No server-side session storage for challenges
- Authenticated verification:
POST /verifyrequires the secret key of the site as a bearer credential. The endpoint is server-to-server only. - Replay protection: A shared store (SQLite or Redis) records each used salt. A proof verifies exactly once, across restarts and across instances. Salts are random values; they carry no user data.
- Observability: Optional Prometheus metrics endpoint, a cheap
/healthzliveness probe, and structured JSON logs with per-site labels - CORS support: Per-site origin validation on the public challenge endpoint
- Request filtering: A stateless port of the 8G Firewall signatures. Scanner traffic pays a harder puzzle or fails after solving. The filter tracks nothing.
- Server-picked nonce window: Each challenge names the range of nonces the solver must search. A bot fleet cannot split one fixed search space between its workers, and a solver cannot shop for an easy region.
- Solver generation binding: Every challenge names the solver generation it was minted for. A stale
solver.wasmfails at verification, not at page load. - Solver module handshake: Each solve result carries a digest of the challenge fields and a constant compiled into the module. A solver that copies the wire format without the real module fails verification after it pays the solve cost.
- Worker attestation: The solver worker reports type-check facts about its environment: a real Worker, a working WebAssembly engine, and the identity of the loaded module. These facts are environment checks, not fingerprints.
- Origin binding: The page origin rides the challenge signature. A challenge minted for one page cannot be cashed from another.
- HTTP mechanics: A method allowlist, header caps, framing sanity, and payload placement rules discard malformed requests on every route, before routing. Smuggling shapes aimed at the challenge endpoint take a deny-flagged puzzle instead. The layer keeps no state.
- Header-structure nudges: Incoherent fetch metadata (a missing
Accept, contradictorySec-Fetch-*values, an origin-violatingReferer) raises the difficulty on the challenge endpoint. It never blocks a request. - No user tracking: The public endpoints never read the client IP address. The database has no column that can hold one. See No User Tracking.
- WebAuthn integration: Passkey-based admin authentication
- Equihash algorithm: Memory-hard proof-of-work resistant to GPU/ASIC optimization
- Internationalization: Built-in i18n via go-i18n with Accept-Language detection and embedded TOML translation files
Prerequisites #
- Go: Version 1.25.5 or later
- Build Tools: Git to clone the repository. You do not need other tools. The Go toolchain builds everything.
- Browser: Modern web browser with WebAssembly support
- Environment:
ADMIN_LOGIN_POW_SECRET: Required. Secret string for signing the admin login PoW challenges. The server fails to start without this env var.
Installation #
Run a prebuilt release binary, or build from source. Both layouts put the hecapte binary next to the web/static assets it serves, the WASM solver included.
Option 1: Prebuilt Release #
Every release publishes at katsuricata.tngl.io/hecapte: one directory per version with a hecapte-linux-amd64.tar.gz, a hecapte-linux-arm64.tar.gz, a source tarball, and a SHA256SUMS.txt checksum file. A binary tarball contains the hecapte server binary, the web/static assets, and the license.
# substitute the version and the architecture you want
curl -LO https://katsuricata.tngl.io/hecapte/v4.0.1/hecapte-linux-amd64.tar.gz
curl -LO https://katsuricata.tngl.io/hecapte/v4.0.1/SHA256SUMS.txt
sha256sum --check --ignore-missing SHA256SUMS.txt
tar -xzf hecapte-linux-amd64.tar.gz
cd hecapte-4.0.1-linux-amd64
Start the server from that directory:
export ADMIN_LOGIN_POW_SECRET=$(openssl rand -hex 32)
./hecapte --host example.com --port 8080 --db hecapte.db
The hecapte-<version>-source.tar.gz archive is a snapshot of the tagged commit, for builders who do not want to clone the repository.
Option 2: Build from Source #
-
Clone the repository:
git clone git@tangled.org:did:plc:pomgtubhgnuew7wmpwgk7dzd cd HeCAPTe -
Download dependencies:
go mod download -
Build the WebAssembly Client:
cd cmd/wasm-solver GOOS=js GOARCH=wasm go build -o ../../web/static/solver.wasm ./... cd ../.. -
Build the server binary:
go build -o hecapte ./cmd/server
Note: The server reads static assets (solver.wasm, worker.js, wasm_exec.js, style.css) from the web/static directory, relative to the working directory. Start the server from the repository root. If you deploy the binary alone, copy the web/static directory next to it.
Next steps: set the environment from Configuration, start the server for Running Locally, then open the Admin Dashboard and create a site.
Solver Version Check #
solver.wasm is a build artifact, and git ignores it. A deploy that forgets the build step serves the old solver without an error. Three surfaces make the served solver visible:
- The startup log line
solver wasm asset readycarries the full SHA-256 digest of the file, in hex. - The Instance card on the admin dashboard shows the first 12 digits of the digest and the file size.
- The
hecapte_build_infometric carries the same 12 digits in itssolver_wasm_sha256label. An empty label means that the server cannot read the file at startup.
To check a deploy, compare the startup digest with the digest of your build:
sha256sum web/static/solver.wasm
If the values differ, rebuild the solver and restart the server. The server hashes the file once at startup, so a rebuild on disk takes effect only after a restart.
If the file is missing or unreadable at startup, the server logs an ERROR and keeps running. The challenge and verify endpoints work, but no browser can solve a challenge — this includes the admin login proof-of-work.
The server also sends Cache-Control: no-cache with solver.wasm, worker.js, and wasm_exec.js. The browser revalidates these files on each page load. As a result, the new solver reaches the browser on the first load after a restart.
Configuration #
Environment Variables #
| Variable | Required | Description |
|---|---|---|
ADMIN_LOGIN_POW_SECRET |
Yes | Random secret key for signing the admin login PoW challenges. The key must be cryptographically random, and you must keep it secure. |
PUBLIC_ORIGIN |
No | Public origin (https://captcha.example.com) visitors use to reach this instance. Fixes the WebAuthn relying-party configuration and the Secure cookie flag at startup and stops trusting client-controlled headers. Uses --primary-domain/CLOUDRON_APP_DOMAIN as a fallback. |
TRUST_PROXY |
No | Set to 1 only when a sanitizing reverse proxy (Cloudron, nginx, Caddy, Traefik) terminates TLS in front of this instance, so the X-Forwarded-Proto: https header it sets or strips can be trusted. Cloudron's start.sh exports this automatically. |
REDIS_URL |
No | Redis URL (redis:// or rediss://) for the shared replay-protection store. If instances on different hosts must reject the same replay, set this. Without it, the server uses the SQLite used_salts table. See Replay Protection and High Availability. |
CLOUDRON_REDIS_URL |
No | Fallback for REDIS_URL. The server reads it when REDIS_URL is not set. A Cloudron package that declares the redis addon receives this variable. |
CLOUDRON_APP_DOMAIN |
No | Primary domain for Cloudron deployments. The server uses it as a fallback when you do not set --primary-domain. |
CLOUDRON_ALIAS_DOMAINS |
No | Comma-separated alias domains for Cloudron multi-domain WebAuthn configurations. |
METRICS_TOKEN |
No | Bearer token that enables the Prometheus metrics endpoint GET /metrics. Without this variable the endpoint returns 404. See Observability. |
LOG_LEVEL |
No | Minimum log level: debug, info (the default), warn, or error. |
LOG_FORMAT |
No | Log output format: json (the default) or text. Use text to read logs by hand. |
Generating a secure secret:
export ADMIN_LOGIN_POW_SECRET=$(openssl rand -hex 32)
Command-Line Flags #
| Flag | Default | Description |
|---|---|---|
--host |
"localhost" |
Hostname to bind to |
--port |
"8080" |
Port to listen on |
--db |
"hecapte.db" |
Path to SQLite database file |
--primary-domain |
"" |
Canonical domain for multi-domain deployments. The flag falls back to the CLOUDRON_APP_DOMAIN env var. |
--tls-cert |
"" |
Path to the TLS certificate. It is required for non-localhost, and it is not available in the Cloudron build. |
--tls-key |
"" |
Path to the TLS private key. It is required for non-localhost, and it is not available in the Cloudron build. |
Equihash Parameters #
The admin settings page (/admin/settings) sets the global default difficulty for all sites. The defaults are defined in internal/admin/config.go:
| Parameter | Default | Description |
|---|---|---|
equihash.n |
80 |
Collision bit width |
equihash.k |
4 |
Number of reduction steps |
Admin login uses a lower difficulty (N=60, K=4) for responsiveness. A per-IP rate limit and the serialized Argon2id comparison bound the effective password-guess rate.
Scope note: this rate limiter binds the admin login only. The public endpoints (/challenge and /verify) never look at the client IP address. The limiter keys on the operator's own login traffic and keeps its buckets in process memory, so no IP address ever reaches the database. See No User Tracking.
A failed password attempt does not consume the proof of work. The server checks the password first and claims the salt only after the password matches. A typing error does not cost a solve. The browser still solves a fresh challenge for each submit. A resubmitted proof fails before the password check. The rejection costs one Equihash verification instead of one Argon2id comparison.
Security presets (selectable from the admin settings page):
| Preset | N | K | Description |
|---|---|---|---|
low |
60 | 3 | Fast solve time, with minimal resource requirements. Stops the lightest spam only. |
recommended |
80 | 4 | Default — good balance of security and performance. A solve stays in the seconds range on typical hardware. |
high |
108 | 5 | Strong memory-hardness, slower on low-end devices. The ceiling of automatic escalation. |
The server enforces a difficulty floor. It rejects every challenge with n below 60, at issue time and at verification time. This rule applies even if a site configuration is tampered with outside the admin UI. The floor keeps the cost asymmetry of the proof of work intact.
The low preset deters only light spam. The server logs a warning at startup and at settings-save when the global preset or a site override uses low. The dashboard flags the affected sites.
Per-Site Difficulty #
Each site can use a different security preset. Edit a site on the admin dashboard and select a value in the Difficulty field:
- Global default: The site uses the difficulty from the admin settings page. If you change the global preset, these sites follow the change.
- Low, Recommended, or High: The site always uses this preset. A change of the global preset does not affect the site.
The server stores the per-site preset in the difficulty_preset column of the sites table. A NULL value marks the global default. New challenges for the site use the new preset immediately.
Benchmarking on Real Devices #
The settings page has a Solve-Time Benchmark card. Click Run Benchmark to measure the solve time of every preset on the device you hold. To measure another device, log in on that device and run the benchmark there.
The benchmark runs the WebAssembly solver in a Web Worker, the same code your visitors run. It sends nothing to the server and stores nothing. The benchmark solves each preset three times with a fresh random challenge. The table shows every run and the median time.
A challenge expires 60 seconds after issue, so one run stops at 60 seconds. The table then marks the preset over 60 s, and the benchmark skips the slower presets. If a median comes near the 60-second limit on the slowest device you care about, use a lower preset for that site.
The results vanish when you reload the page. To keep a record, note the medians, or take a screenshot.
Security Configuration #
- Local development: Runs on HTTP with no TLS requirement
- Production: Requires TLS 1.3 minimum with HSTS headers
- Response headers: Both builds set
X-Content-Type-Options: nosniff,frame-ancestors 'none'(CSP),X-Frame-Options: DENY, andReferrer-Policy: same-originon every response. The templates use inline scripts, so a full Content-Security-Policy would need per-response nonces and is not set. - Reverse proxy detection: The
X-Forwarded-Proto: httpsheader is only honored when the instance runs behind a trusted, sanitizing reverse proxy (TRUST_PROXY=1). Cloudron'sstart.shsets this value. Direct TLS connections always count as secure. SetPUBLIC_ORIGIN(or--primary-domain/CLOUDRON_APP_DOMAIN) so cookies are markedSecureand WebAuthn origins use the operator-declared origin instead of client-controlled headers. In direct deployments never expose the server on plain HTTP and trust its forwarding headers. If a request arrives withX-Forwarded-Protoset butTRUST_PROXYis unset, the server logs a one-time warning, because this state usually means a misconfigured reverse proxy. - CORS: Configured per-site via
allowed_origins(comma-separated or*). The check guardsGET /challenge, the one browser-facing endpoint.POST /verifyignores theOriginheader and answers no preflight. The secret-key credential replaces it. - Session: 24-hour duration with secure, HttpOnly, SameSite=Strict cookies
- CSRF Protection: All admin endpoints require CSRF token (cookie + form/header)
- Password Policy: Minimum 60 bits of entropy (with
go-password-validator) - WebAuthn Session: 10-minute timeout for registration/login ceremonies
- WebAuthn Reauth: 2-minute timeout for passkey reauth tokens (used for password changes). The tokens live in process memory only. A server restart deletes them. Then you must re-authenticate with the passkey.
- Argon2id: Password hashing with 128 MB memory, 3 iterations, parallelism 1, 32-byte key
No User Tracking #
HeCAPTe's design goal is that embedding it into a service exposes nothing about that service's users. The verification path holds to it:
- The public endpoints
/challengeand/verify— the routes that serve end users of sites that embed HeCAPTe — have no rate limiter and no per-client state. Their handlers never read the client IP address. Verification is a pure function of the challenge data the client sends back. Nothing looks at the address, so nothing can leak it. - The database has no column that could hold one. It stores sites, admin credentials, admin sessions, and used challenge salts. Salts are random values; they carry no user data.
- The request filter and the mechanics layer keep no state. A request's verdict travels inside the signed challenge, not in any store.
- The solver attestation holds environment type checks: a real Worker, a working WebAssembly engine, and the module identity. The values are identical for every honest user. The server checks the handshake and stores nothing.
- Metrics carry no client identity, and attacker-controlled values never become label values.
Two deliberate, operator-facing exceptions exist, and neither touches end users or persistent state:
- Admin login rate limiting. The login, setup, and challenge routes under
/adminuse a per-IP token bucket to bound the password-guess rate on the operator's own credentials. The buckets live in process memory. They never reach the database, the replay store, or a metric label, and a restart clears them. - Access logging. The request logger writes a
client_ipfield into each log line (see Structured Logs), the way any web server writes an access log. The log goes where the operator sends it, and nowhere else.
Request Filtering #
HeCAPTe embeds a port of the 8G Firewall signature list (vendored release v1.5, 2025-09). The request filter reads the request in hand, judges it, and discards the request data. It keeps no counters, no cache, and no memory across requests.
The filter is always on. It has no configuration options.
HTTP Mechanics #
A mechanics layer runs before routing and before the request logger. It applies four rules to every route, not only the two public endpoints. Each rule reads one request and discards it. Nothing crosses requests.
- Method allowlist. The server answers four verbs:
GET,HEAD,POST, andOPTIONS. Any other verb gets405 Method Not Allowedwith anAllowheader. This one rule covers the whole REQUEST METHOD section of 8G without a signature list. - Framing sanity. A request carries one body framing: either a
Content-Length, or exactly onechunkedtransfer-encoding entry. Conflicts and unknown codings are the request-smuggling shape, and they get400 Bad Request. The Go HTTP parser also refuses most of these shapes first. - Header caps. A request carries at most 64 distinct header names. The request line plus all names and values must fit in 32 KiB. A violation gets
431 Request Header Fields Too Large. The HTTP server uses the same 32 KiB budget while it parses, in both builds. An oversized block never reaches the router. - Payload placement. A body is legal on
POSTonly. A query string is illegal onPOST. A violation gets400 Bad Request, on every route on the server.
A rejection runs no site lookup, no credential check, and no body parse. Rejections carry one short text per status, so the layer leaks nothing rule-specific. The layer sits before the request logger, so a malformed flood cannot flood the log.
One exception applies, and only on GET /challenge. A request with a framing, budget, or placement violation on that one route is not refused. The route answers a challenge that looks ordinary, and the deny flag rides inside the signed payload. The client solves the puzzle as usual. Then POST /verify returns the standard 403 failure. A scanner that probes for request smuggling pays a full Equihash solve before it learns the answer. Every other route, and POST or OPTIONS on /challenge itself, keeps the immediate rejection. These amplified answers count under the mechanics_denied result label of hecapte_challenge_requests_total, so an operator can watch the smuggling traffic.
What the Port Matches #
Two of the six 8G sections carry over as signature lists. The request-method section is covered by a rule instead:
- User agent. Exploit scanners (
sqlmap,masscan,nikto), web shells (c99shell), and junk text such as control characters and percent escapes. - Cookie header. Angle brackets and
%00,%0A,%0Descapes. The apostrophe and%27get the gentle handling instead, because names carry apostrophes. - Request method.
CONNECT,DEBUG,MOVE,TRACE, andTRACKhave no legitimate target on this server. The mechanics method allowlist answers them with405 Method Not Allowedon every route, along with every other verb outside the four the server speaks.
The other three sections do not apply:
- Query string and request URI. These sections list attacks against PHP stacks. HeCAPTe has two public endpoints with one fully known shape each. The request-shape allowlist rejects that attack class by construction.
- Remote host. This section does reverse-DNS lookups against a list of hosting providers. That is IP reputation by proxy: stale data, collective punishment of innocent users, and a DNS lookup in the request path. HeCAPTe does not use it.
What a Match Does #
A match never blocks the request. It changes the challenge. Two tiers exist:
- Deny tier. Exploit scanners, web shells, and injection-shaped headers get a challenge that looks ordinary. Its signed payload carries the deny flag (
flags: 1). The client solves the puzzle as usual. ThenPOST /verifyreturns the standard403failure. A scanner gets no fast answer, and a solving fleet pays the puzzle cost before it learns the verdict. - Escalate tier. HTTP client libraries (
curl,libwww-perl,Go-http-client), crawlers, download managers, and rare devices can appear on legitimate traffic too. A match raises the difficulty one preset step (low, then recommended, then high). The high preset is the ceiling. The difficulty never moves below the value the site configured.
The verdict travels inside the signed challenge. The signature covers the flags value, so the client cannot clear the flag. Verification stays a pure function of the data the client sends back. Nothing about the request crosses requests.
A flagged verification records the reason request_flagged in hecapte_verify_failures_total and in the structured log line. The filter itself logs nothing at match time.
Header Structure Nudges #
The challenge endpoint also reads the structure of the fetch metadata, not its content. Three rules apply. Each rule that matches raises the difficulty by one preset step. The steps stack, and the high preset is the ceiling. The verdict never flags a challenge and never refuses a request. A curl client or an unusual browser stays fully functional. It works a little harder.
- A missing or empty
Acceptheader is one step. A non-interactive client usually sends nothing. A presentAcceptis never penalized:*/*is the browserfetch()and XHR default, the ordinary shape of a real browser, not a bot tell. - A
Sec-Fetch-*set that cannot be true is one step. For example aSec-Fetch-Mode: navigatewith an XHR destination, or a same-site claim that the page origin contradicts. - A
Refererthat contradicts the validatedOriginis one step. This is the embedding shape of a cross-site solver farm.
Each check is a pure function of the request in hand. No rule adds state. A request that carries no headers at all takes only the first step and stays solvable at the signed terms.
Request Shape #
The two public endpoints have fully known shapes. HeCAPTe rejects everything outside them. Two rules per endpoint replace the QUERY STRING and REQUEST URI sections of 8G, and they catch every scan those sections describe.
GET /challenge accepts:
- The methods
GETandOPTIONS. The router answers other methods with405 Method Not Allowed. - Exactly one
site_keyquery parameter with one non-empty value. - No request body.
POST /verify accepts:
- The methods
POSTandOPTIONS. The OPTIONS handler answersAllow: POST, OPTIONSand sends no CORS headers, so a browser preflight still fails. - No query string.
- A body of
application/json(acharsetparameter is fine), 64 KiB at most. - Exactly one JSON document with the fixed key schema (
site_key,data, and the optional top-levelorigin). Unknown keys fail at decode time.
A violation gets 400 Bad Request and a short error word. The check runs before the site lookup, the credential check, and the body parse. It reads only request metadata, keeps no state, and answers every violation identically. The mechanics layer runs earlier still, on every route.
Limits of the Filter #
The filter stops drive-by scanner traffic and exploit probes at near-zero false-positive cost. It does nothing against a bot that sends well-formed requests. The proof of work prices those.
Updating the Signature Table #
The signatures compile into the binary from a vendored copy of 8G-Firewall.txt. No blocklist file is necessary at runtime. To update to a new upstream release:
- Download the new release from perishablepress.com.
- Save it over
internal/waf/8G-Firewall.txt. - Run
go generate ./internal/waf. - Review the diff of
internal/waf/signatures_gen.go. - Check the deny-tier lists in
internal/waf/waf.go. New upstream atoms join the gentle escalate tier until you promote them. - Run
go test ./internal/waf/... ./cmd/server.
Do not edit internal/waf/signatures_gen.go by hand. The Go regular-expression engine rejects lookbehind and oversized repeat counts. The generator skips or rewrites every atom it cannot keep, and it lists each one in the header comment of the generated file.
Replay Protection and High Availability #
Each challenge carries a random salt. When a proof verifies, the server records the salt in a replay-protection store until the challenge expires (60 seconds). The server rejects a second proof that uses the same salt. The store is the only state the verification path keeps. It is fully server-side, so the verification endpoint stays stateless for callers. A salt is a random value: it is not derived from the client, and it carries no user data and no IP address.
The Default Store: SQLite #
The default store is the used_salts table in the same SQLite database as the site keys. The server opens its database in WAL journal mode with a busy timeout. Two processes on one host can then use the same file at the same time. A background sweep deletes expired salts every 5 minutes. No configuration is necessary.
This default gives you three properties:
- Restarts keep the protection. Used salts survive a process restart. A proof that was valid just before the restart stays used until it expires.
- Zero-downtime deploys work. The new process and the old process share the database file during the cut-over window.
- Several replicas work on one host. Every instance points at the same
--dbpath. All instances share site keys and used salts.
CAUTION: Do not put the SQLite database on NFS or on another network filesystem. File locks do not travel across NFS, so two hosts that mount the same file can corrupt it. If the instances run on two hosts, use the Redis store that follows.
The Shared Store: Redis #
Set REDIS_URL to select a Redis store instead of SQLite:
export REDIS_URL=redis://captcha-redis.internal:6379/3
# or TLS:
export REDIS_URL=rediss://:password@captcha-redis.internal:6380/3
Each verified salt becomes one Redis key with a TTL of the remaining challenge lifetime. Redis deletes the key at expiry, so there is no sweep job. A claim is one atomic SET ... NX EX command, so concurrent replicas accept one salt exactly once. The keys carry the prefix hecapte:used:, so one Redis database can hold data for other applications. Cloudron packages that declare the redis addon can rely on CLOUDRON_REDIS_URL: the server reads it when REDIS_URL is not set.
The server pings Redis at startup. If the URL is wrong or the server does not answer, startup aborts with a clear error. A quiet fall-back to a local store reopens the replay window while the logs claim redundancy. The abort makes the fault visible instead.
Failure Behavior: Fail Closed #
If the replay store cannot answer (disk error or Redis outage), the server rejects the verification. The protected form fails with the same status as an invalid proof, and the server log records the wrapped store error. This direction is deliberate. A fail-open design turns every store outage into an unbounded replay window: an attacker who can break the store can replay proofs. Fail closed turns an outage into a short, visible verification outage instead, with at most a 60-second proof lifetime at stake.
Clocks #
Synchronize the clocks of every instance with NTP, and keep the Redis host on the same discipline. The server computes both expiries from local wall-clock seconds within a 60-second window. Two hosts a few seconds apart are fine. A host minutes ahead starts rejecting challenges too early.
Zero-Downtime Deployment Recipe #
- Give the new instance access to the same replay store as the old one. Use the same
--dbfile on one host, or the sameREDIS_URLfor several hosts. - Start the new instance. Check the startup log for the message
replay protection store selected. Make sure that thestorefield names the store you expect. - Check the startup log for the message
solver wasm asset ready. Make sure that thesha256field matches the digest of the solver you built (see Solver Version Check). - Shift traffic to the new instance.
- Stop the old instance.
During steps 4 and 5, both instances share the used salts. The other instance rejects a proof that the first one verified.
Cloudron #
Cloudron runs one container per app, so the SQLite default covers every Cloudron case. The database lives in /app/data, which persists across restarts and updates, and the platform includes it in backups. An app restart or an app update keeps the replay protection of every in-flight challenge. The package does not need the Redis addon.
Observability #
HeCAPTe exposes three read-only signals: a liveness endpoint, a metrics endpoint, and structured logs. They never mint a challenge, verify a proof, or change the request flow of the protected service.
Liveness: /healthz #
GET /healthz returns 200 OK with the body ok. HEAD requests work as well. The handler reads no database and runs no challenge code. Use this endpoint for load balancer checks, uptime monitors, and Kubernetes probes. On Cloudron, the platform health check uses this endpoint instead of the demo page.
Metrics: /metrics #
GET /metrics returns counters in the Prometheus text format. HeCAPTe generates the format itself, so the endpoint adds no dependency.
The endpoint is disabled by default. Set the METRICS_TOKEN environment variable to enable it. A disabled endpoint returns 404 Not Found. An enabled endpoint requires the header Authorization: Bearer <METRICS_TOKEN> on each request. A request without the token returns 401 Unauthorized.
CAUTION: Do not serve
/metricsover plain HTTP. A bearer token on a plain connection leaks, and the leak exposes your site inventory and traffic volumes.
Metric List #
| Metric | Type | Labels | Content |
|---|---|---|---|
hecapte_challenge_requests_total |
counter | site, result |
Requests to GET /challenge. result is issued, unknown_site, origin_not_allowed, bad_request, internal_error, or mechanics_denied. |
hecapte_verify_requests_total |
counter | site, result |
Requests to POST /verify. result is ok, failed, unknown_site, unauthorized, or bad_request. |
hecapte_verify_failures_total |
counter | site, reason |
Rejected proofs by failure reason. The reason table follows. |
hecapte_build_info |
gauge | version, solver_wasm_sha256 |
Constant 1. The labels carry the server version and the first 12 digits of the solver.wasm SHA-256 digest. An empty solver_wasm_sha256 means that the server cannot read the file at startup. |
hecapte_uptime_seconds |
gauge | — | Seconds since the server process started. |
The site label carries the public site key, the same value that the admin dashboard shows. A request that matches no registered site gets an empty site label. An attacker who sends garbage site keys cannot create new counter series. The cardinality stays bounded: the number of registered sites times the fixed result and reason vocabularies. The build-info labels hold one value each per process.
Verify-Failure Reasons #
The reason label of hecapte_verify_failures_total uses these values:
reason |
Meaning |
|---|---|
invalid_signature |
The proof signature does not match the challenge data. The usual cause: the backend omitted the top-level origin on /verify (or sent one that differs from the page origin the challenge was minted on). |
challenge_expired |
The challenge is older than 60 seconds, or the timestamp is in the future. |
replay |
The salt was already used. |
missing_nonce |
The solution carries no nonce. |
nonce_too_large |
The nonce exceeds the fixed 4-byte width. |
nonce_not_canonical |
The nonce is not in the canonical form (8 lowercase hex digits). |
nonce_outside_window |
The nonce lies outside the window the challenge signed. |
solver_version_mismatch |
The solution echoes a solver generation other than the one of this server. |
module_handshake_mismatch |
The module handshake of the proof does not match the recomputed value. The proof was valid, and the handshake made it fail. |
invalid_salt_format |
The salt is not valid hex. |
invalid_nonce_format |
The nonce is not valid hex. |
invalid_pow_params |
The difficulty parameters fall below the difficulty floor. |
invalid_pow |
The Equihash solution is wrong. |
request_flagged |
The challenge carried the deny flag of the request filter. The proof was valid, and the flag made it fail. |
replay_store_error |
The replay store could not answer, so the proof was rejected (fail closed). |
replay_store_unconfigured |
The verifier received a nil replay store. |
unknown |
An error outside the mapped set. |
Example Queries #
Verify-failure rate per site, in failures per second over five minutes:
sum by (site) (rate(hecapte_verify_requests_total{result="failed"}[5m]))
Verify-failure share per site, from 0 to 1:
sum by (site) (rate(hecapte_verify_requests_total{result="failed"}[5m]))
/
sum by (site) (rate(hecapte_verify_requests_total[5m]))
Read the failure rates as an early warning. A rising replay or invalid_pow rate suggests an attack. A rising challenge_expired rate suggests solvers that cannot finish in 60 seconds.
Alert on a missing or unreadable solver asset:
hecapte_build_info{solver_wasm_sha256=""} == 1
Scrape Configuration #
A minimal Prometheus scrape configuration for a token-protected endpoint:
scrape_configs:
- job_name: hecapte
scrape_interval: 30s
bearer_token: "<the METRICS_TOKEN value>"
static_configs:
- targets: ["captcha.example.com:8080"]
Structured Logs #
HeCAPTe writes structured logs through the log/slog package of Go. The default format is JSON, one object per line. Set LOG_FORMAT=text for a human-readable format. Set LOG_LEVEL to debug, info (the default), warn, or error.
Request Records #
Each HTTP request produces one log line. A response with a 5xx status logs at ERROR. Other responses log at INFO. The fields of a request line:
| Field | Content |
|---|---|
msg |
http request |
request_id |
The request ID. The response carries the same value in the X-Request-Id header, so a user who reports an error can quote the ID. |
method, path |
The HTTP method and the request path. |
status, bytes, duration_ms |
The response status, the response size, and the request duration in milliseconds. |
client_ip |
The client address. The value honors TRUST_PROXY the same way as the rate limiter. A log line is the only place an address is recorded, and the log stays under operator control. The address never reaches the database, the replay store, or a metric label (see No User Tracking). |
The /healthz and /metrics endpoints produce no request lines. An uptime monitor that polls /healthz every few seconds cannot flood the log.
Site Labels #
Log lines that concern one site carry two labels:
| Field | Content |
|---|---|
site_name |
The name you gave the site on the admin dashboard. Use this label to see which site is failing at a glance. |
site_key_hash |
The SHA-256 hex digest of the site key. To correlate a line with a site, run printf %s "SITE_KEY" | sha256sum for each of your keys. |
The raw site key never appears in a log line.
The Verification-Failure Line #
A rejected proof logs a WARN line with the same reason code as the hecapte_verify_failures_total metric, plus the wrapped error:
{"time":"2026-08-08T15:07:43Z","level":"WARN","msg":"verification failed","site_name":"Contact form","site_key_hash":"3eb1bd439947eb762998e566ccc2e099c791118b2f40579cc4f7da2b5061b7f9","reason":"replay","request_id":"hecate/YCmM6jjFqB-000008","error":"challenge already used"}
A top-failing-sites report no longer needs an awk script. With JSON logs, one jq pipeline is sufficient:
grep '"msg":"verification failed"' /var/log/hecapte.log | jq -r .site_name | sort | uniq -c | sort -rn
Log Level Guide #
| Level | Content |
|---|---|
WARN |
Verification failures, rate-limit rejections, a difficulty preset with weak deterrence, and configuration mistakes worth attention. |
ERROR |
Responses with a 5xx status and internal failures, for example a failed template, a failed session sweep, or a missing or unreadable solver.wasm at startup. |
DEBUG |
Adds a verification succeeded line for each accepted proof. Enable this level per incident. The line volume matches your solve rate. |
API Reference #
HTTP Endpoints #
Challenge Endpoint #
GET /challenge?site_key={site_key}
Generates a new Equihash challenge for the specified site. The difficulty parameters come from the security preset of the site. If the site has no preset, the challenge uses the global default from the admin settings page.
Request Headers:
Origin: Required for CORS validation
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
site_key |
string | Yes | Public site identifier |
A real site key is 32 lowercase hex characters. Hex needs no percent-encoding. If the key comes from user input, encode the value with encodeURIComponent. A raw value that contains & or = adds a second query parameter, and the request fails with 400 Bad Request.
Response (200 OK):
{
"salt": "hex_encoded_random_bytes",
"ts": 1234567890,
"diff": {
"n": 80,
"k": 4
},
"sig": "base64_hmac_signature",
"flags": 0,
"nonce_min": 2413984768,
"nonce_max": 2414984768,
"sv": 1
}
The flags value carries the verdict of the request filter. The signature covers it. Send it back to /verify unchanged inside data. The filter can also raise diff by one preset step on suspicious requests.
The nonce_min and nonce_max values name the nonce search window as a half-open interval [nonce_min, nonce_max). The server picks a fresh random window for each challenge, and the signature covers both bounds. The solver must search inside the window and echo both values back. A nonce outside the window fails verification.
The sv value is the solver generation of the server. The shipped WASM solver echoes its own compiled-in generation in the result. Copy that value back, or copy the challenge value. A solver that echoes a different generation fails verification. This mechanism turns a stale cached solver into a clear failure instead of a silent drift.
The Origin header of this request rides the signature. The value never appears in the response. If your visitors solve in a browser and your backend knows its page origin, send that origin as the origin field of /verify. See the verify endpoint.
The nonce must come back in the canonical form of the solver: exactly 8 lowercase hexadecimal digits (one uint32). Empty, uppercase, or odd-length forms fail before the Equihash check.
Error Responses:
400 Bad Request: The request shape violates the allowlist — an extra query parameter, a body, or a missing, empty, or doubledsite_key403 Forbidden: Invalid site key or origin not allowed
Verify Endpoint #
POST /verify
Verifies the solution that a client submits for an Equihash challenge. This endpoint is server-to-server. Your backend calls it. A browser never has the credential that the endpoint requires.
Authentication:
Each request must carry the Authorization: Bearer <secret_key> header. The secret key belongs to the site that site_key in the body names. The server compares the value in constant time.
If the header is missing, if the scheme is not Bearer, or if the secret key is wrong, the request gets 401 Unauthorized. The response carries a WWW-Authenticate: Bearer realm="verify" header. The rejection happens before proof verification. An unauthenticated request cannot spend verification CPU and cannot write to the replay store.
Keep the secret key on your backend. Put it in a configuration file or an environment variable. Never put it in a page, a script, or a repository.
Request Headers:
Authorization: Bearer <secret_key>: Required. The server-to-server credential of the site.Content-Type: application/json
The endpoint answers no CORS preflight and sends no CORS headers. OPTIONS gets a bare Allow: POST, OPTIONS answer, so a browser preflight fails for lack of Access-Control-Allow-Origin. The Origin header has no effect on this endpoint. The allowed_origins list guards GET /challenge only.
Request Body:
The shape rules are strict: no query string, Content-Type: application/json (a charset parameter is fine), one JSON document, and no keys outside the schema below. The body must be 64 KiB or smaller. The server returns 400 Bad Request for any violation of these rules and for invalid JSON. The check runs before the credential check. See Request Shape.
Send the public site_key in the body. Use the same value that the browser sends to /challenge. The Authorization header carries the secret key of the same site.
A challenge expires 60 seconds after issue. If the solver or the user can take more than 60 seconds, request a new challenge and try again. Do not send an expired challenge. The server accepts each salt once. A replay gets 403 Forbidden.
{
"site_key": "public_site_key",
"origin": "https://app.example.com",
"data": {
"nonce": "00bc614e",
"salt": "original_salt",
"ts": 1234567890,
"diff": { "n": 80, "k": 4 },
"sig": "original_signature",
"sol": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"flags": 0,
"nonce_min": 2413984768,
"nonce_max": 2414984768,
"sv": 1,
"hs": "base64_module_handshake"
}
}
Send every challenge field back unchanged. This rule includes flags, nonce_min, nonce_max, and sv, even when the values are 0. The server derives the expected signature over them. A flagged challenge fails verification after a correct proof: the filter taxes the solve, not the request.
The hs field is optional in 4.0.0. It carries the module handshake of the WASM solver: a digest of the signed challenge fields and a constant compiled into both binaries. The server recomputes the value. A wrong value fails after the Equihash proof with the ordinary 403, and a missing field fails nothing: old clients and server-side solvers keep working, and the replay store still gates them. The constant ships in the source and in solver.wasm, so the handshake is a tax on reimplementation work, not a secret. It raises the cost of copying the wire format without the real module. The value attests that the module ran. It carries no information about the user.
The origin field is optional. It names the page origin on which the challenge was minted: the value of the Origin header that the browser sent to /challenge. The signature covers that value. A mismatch fails as an invalid signature. Omit the field, or send an empty string, to match a challenge that was fetched without an Origin header. This is the shape a server-to-server integration sends by default, and it keeps working. Browsers always send the header on a cross-origin fetch, so browser-solved challenges bind to the page that minted them. A backend that serves a browser page and omits origin here fails every browser-minted proof as invalid signature.
CAUTION: A 4.0.0 client that sends
originagainst a server older than 4.0.0 gets a400 Bad Request, because the old schema rejects the unknown key. Upgrade the server first, or omit the field until the server runs 4.0.0.
Response (200 OK):
{ "status": "ok" }
Error Response (403 Forbidden):
{ "status": "fail", "error": "verification failed" }
Other Error Responses:
400 Bad Request: Invalid JSON, or a body over 64 KiB401 Unauthorized: TheAuthorizationheader is missing, malformed, or carries a wrong secret key403 Forbidden(plain textinvalid request): Unknown site key
Liveness Endpoint #
GET /healthz
Returns process liveness for load balancers, uptime monitors, and the Cloudron health check. The handler reads no database and runs no challenge code. HEAD requests work as well.
Response (200 OK):
ok
The content type is text/plain. The response carries Cache-Control: no-store.
Metrics Endpoint #
GET /metrics
Returns the server counters in the Prometheus text format. The endpoint is disabled while METRICS_TOKEN is not set, and a disabled endpoint returns 404 Not Found. With METRICS_TOKEN set, each request must carry the Authorization: Bearer <METRICS_TOKEN> header. A request without the token returns 401 Unauthorized. See Observability for the metric list and example queries.
Admin Login Challenge #
POST /admin/login/challenge
Generates a proof-of-work challenge for admin login rate limiting. The route takes POST only, so the fetch carries an Origin header, and the signature binds it.
Scope note: unlike the public challenge endpoint (GET /challenge), this route, together with the rest of /admin, uses per-IP rate limiting. See No User Tracking.
Rate limiting: The proof of work is a deterrent, not a true rate limit. An attacker can solve challenges offline in parallel. For this reason the server also enforces a per-IP token bucket: 10 requests per minute with a burst of 5 on the challenge endpoints, and 5 requests per minute with a burst of 2 on the login POST. A request over the budget gets 429 Too Many Requests with a Retry-After header. The limiter keys on the direct peer address. It uses the leftmost X-Forwarded-For entry only when TRUST_PROXY=1, because an untrusted client can otherwise rotate a forged header and mint a fresh budget per request.
Response (200 OK):
{
"salt": "hex_encoded_bytes",
"ts": 1234567890,
"diff": { "n": 60, "k": 4 },
"sig": "base64_signature",
"flags": 0,
"nonce_min": 2413984768,
"nonce_max": 2414984768,
"sv": 1
}
The login page solves the challenge in the same solver worker (/static/worker.js) that the demo page uses. The form field pow_data carries the proof. Next to the signed fields, the payload echoes the module handshake (hs), the worker attestation (att), and the page origin.
Admin Setup Challenge #
POST /admin/setup/challenge
Generates a proof-of-work challenge for the first-run setup page. The route stays reachable only while no admin credentials exist. The response has the same shape as the login challenge (diff is {"n": 60, "k": 4}, and flags is 0). The solver worker and the attestation payload match the login flow. The challenge rate limit from the previous section applies.
JavaScript WASM API #
The WebAssembly solver exposes three globals: solveChallenge, moduleHandshake, and the solverIdentity string. In practice, use the provided worker.js. This file wraps solveChallenge with the correct iteration logic and seals the handshake path against foreign replacements.
solveChallenge(salt, timestamp, n, k, nonceMin, nonceMax, progressCb) #
Attempts to solve an Equihash puzzle. The function iterates the nonce window of the challenge.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
salt |
string | Yes | Hex-encoded salt from the challenge |
timestamp |
number | Yes | Challenge timestamp (int64) |
n |
number | Yes | Equihash N parameter |
k |
number | Yes | Equihash K parameter |
nonceMin |
number | Yes | First nonce of the signed window (inclusive) |
nonceMax |
number | No | End of the window (exclusive). Default: nonceMin + 10000 |
progressCb |
function | No | Callback that the solver calls with progress updates |
Progress Callback:
function onProgress(iterationsCompleted) {
// Called every 1000 iterations
console.log(`Checked ${iterationsCompleted} nonces`);
}
Return Value (Success):
{
"nonce": "hex_string", // 8 lowercase hex digits
"solution": [0, 1, 2, 3, ...], // Array of uint32 indices
"sv": 1, // Solver generation of this module
"hs": "base64_digest" // Module handshake over the challenge fields
}
Return Value (No solution found):
{
"error": "no solution found in range",
"lastNonce": 2413984768, // Next nonce to try
"sv": 1,
"hs": "base64_digest"
}
Return Value (Error):
{
"error": "error description"
}
Note: The solver attempts a maximum of 10,000 nonces per call. worker.js runs one call for each SOLVE message. If the result has error: "no solution found in range", send a new SOLVE message with lastNonce as nonceMin and the same nonceMax. Repeat until the result has a nonce key.
Note: Copy the sv value of the result into the proof that you send to your backend. The value binds the solve to this build of the module, and the server rejects other generations after the proof check. The message protocol of worker.js does this for you: the SOLVE message takes nonceMin and nonceMax of the challenge, and the RESULT message carries sv inside payload. The same message carries the module handshake as the top-level hs field and the capability struct as caps. The READY status carries caps as well.
moduleHandshake(salt, timestamp, n, k, sv) #
Computes the module handshake: a BLAKE2b-256 digest, in base64, of the signed challenge fields and a constant compiled into the module. The server recomputes the same value at verification.
Parameters: The salt, the timestamp, the two difficulty parameters, and the solver generation of the challenge.
Return Value:
{ "hs": "base64_digest" } // or { "error": "missing arguments" }
The value is a pure function of the challenge fields. Call it once per challenge, before the search loop. You do not need this function when you use worker.js: the worker seals the call and attaches hs to each RESULT message.
solverIdentity #
A string that the module registers when it loads: the lowercase hex digest of the module constant. The worker reads it into the mod field of the capability struct. A generic WASM runtime or a harness without the module does not have this value. The value changes only with the module constant, so it identifies the module build, not the user.
Admin Endpoints Security #
All admin endpoints require CSRF protection. The CSRF token:
- The server sets it as a cookie named
csrf_tokenon the first request - You must submit it in one of these ways:
- As the form field
csrf_token, or - As the
X-CSRF-Tokenheader
- As the form field
- If you submit it with both methods at the same time, the server rejects it as a confused deputy attack
- It is valid for 2 hours (
MaxAge: 7200)
Go Package API #
crypto Package #
Implements the Equihash algorithm.
Types:
// Solution represents Equihash solution indices
type Solution []uint32
Functions:
Solve(n, k int, seed []byte) ([]uint32, error) #
Finds an Equihash solution with Wagner's algorithm.
Parameters:
n: Collision bit width (must be divisible by k+1)k: Number of reduction stepsseed: Seed bytes for puzzle generation
Returns:
[]uint32: Solution indices (length = 2^k)error: Parameter validation errorsnil, nil: No solution found (retry with a different nonce)
Example:
import "hecapte/internal/crypto"
seed := []byte("deterministic-seed")
solution, err := crypto.Solve(80, 4, seed)
if err != nil {
log.Fatal(err)
}
if solution == nil {
// Try different nonce
}
Verify(n, k int, seed []byte, indices []uint32) bool #
Verifies an Equihash solution for the given parameters.
Parameters:
n,k: Equihash parametersseed: Seed bytes used to generate the puzzleindices: Solution indices to verify
Returns: true if valid, false otherwise
ModuleHandshake(salt string, ts int64, n, k int, sv int) string #
Computes the module handshake: a BLAKE2b-256 digest, in base64, of the signed challenge fields and the compile-time constant HandshakeConstant. The WASM solver imports the same function, so the module and the server cannot drift. tokens.VerifyProof recomputes the value and rejects a mismatch. Use this function when an in-process flow attaches the attestation digest to a proof.
tokens Package #
Handles challenge generation and verification.
Types:
type Challenge struct {
Salt string `json:"salt"`
Timestamp int64 `json:"ts"`
Difficulty Difficulty `json:"diff"`
Signature string `json:"sig"`
Flags Flags `json:"flags"`
NonceMin uint32 `json:"nonce_min"`
NonceMax uint32 `json:"nonce_max"`
Sv int `json:"sv"`
}
type Difficulty struct {
N int `json:"n"`
K int `json:"k"`
}
type ChallengeRequest struct {
Nonce string `json:"nonce"`
Salt string `json:"salt"`
Timestamp int64 `json:"ts"`
Difficulty Difficulty `json:"diff"`
Signature string `json:"sig"`
Solution []uint32 `json:"sol"`
Flags Flags `json:"flags"`
NonceMin uint32 `json:"nonce_min"`
NonceMax uint32 `json:"nonce_max"`
Sv int `json:"sv"`
Handshake string `json:"hs"`
}
// Flags is a bitmask inside the signed challenge. FlagDeny marks a
// challenge issued to a deny-tier request (see Request Filtering).
// Verifiers ignore unknown bits, and the signature covers them all.
// SolverVersion is the solver generation this build verifies. Challenges
// carry it in the signed payload, and solutions must echo it.
const SolverVersion = 1
Functions:
GenerateChallenge(serverSecret string, n, k int, flags Flags, origin string) (*Challenge, error) #
Creates a new signed challenge.
Parameters:
serverSecret: HMAC signing keyn,k: Difficulty parametersflags: Request-classification bits to bind into the signature (tokens.FlagNonefor an ordinary challenge)origin: TheOriginheader of the issuing request, or an empty string. The value enters the signature and never appears in the response.
Returns: Signed challenge or error
VerifySolution(store ReplayStore, serverSecret string, req ChallengeRequest, origin string) error #
Verifies a complete solution including signature, timestamp, and Equihash proof. The replay store must not be nil. A store error rejects the verification (the check fails closed). The origin argument must name the value given to GenerateChallenge, because the signature covers it.
Verification Steps:
- Verifies the HMAC signature (it covers the flags, the nonce window, the solver generation, and the origin)
- Rejects a solver generation other than the one of this build
- Verifies the timestamp (a 60-second window) and rejects future timestamps
- Checks the salt against the replay store (a cheap pre-check before the hashing work)
- Verifies the hex format of the salt, the canonical nonce form, and the nonce window bounds
- Verifies the Equihash solution
- Rejects a wrong module handshake (a present
hsthat does not match; an absent one is accepted in 4.0.0) - Rejects a challenge that carries the deny flag (the verdict of the request filter, visible only after a valid proof)
- Atomically marks the salt as used in the replay store
Returns: nil on success, descriptive error on failure
VerifyProof(store ReplayStore, serverSecret string, req ChallengeRequest, origin string) (*Proof, error) #
Runs every check of VerifySolution except the salt claim. On success it returns a validated *Proof and leaves the salt unclaimed. The Proof carries the salt and the challenge expiry as informational fields. VerifySolution is VerifyProof plus ClaimProof.
ClaimProof(store ReplayStore, proof *Proof) error #
Consumes a Proof from VerifyProof. It re-checks the challenge window, then claims the salt atomically. An error means the proof was not consumed. The window re-check closes the race where a proof validated at the end of its window is claimed after expiry.
Use the two-step form only when another decision sits between the proof and the salt claim. The admin login checks the password there: a wrong password does not burn the salt. An unclaimed salt stays open to replay until a claim or the expiry, so call ClaimProof exactly once on success. The public /verify endpoint uses the one-step VerifySolution. These functions are for the server internals. Integrations solve challenges with the WASM solver and submit them with the client SDK.
Returns: nil on success, descriptive error on failure
ReplayStore Interface #
type ReplayStore interface {
IsUsed(salt string, now int64) (bool, error)
MarkUsed(salt string, expiry, now int64) (bool, error)
}
The source of truth for replay protection. *db.Store (the SQLite used_salts table) and *replay.RedisStore implement it. MarkUsed must be atomic: concurrent claims of one salt produce exactly one winner. A salt counts as used while expiry >= now.
NewMemoryStore() *MemoryStore #
Returns a process-local ReplayStore for embedded use and tests. It shares nothing and forgets everything on restart. The server does not use it.
BuildSeed(salt string, ts int64, n, k int, nonceHex string) ([]byte, error) #
Helper function to construct the seed for external verification.
db Package #
SQLite persistence layer.
Types:
type Store struct { /* ... */ }
type Site struct {
SiteKey string
SecretKey string
Name string
AllowedOrigins string
DifficultyPreset *string // nil: the site uses the global default
CreatedAt time.Time
}
type AdminAuth struct {
PasswordHash string
WebAuthnUserID []byte
CredentialID []byte
CredentialPublic []byte
CredentialCounter uint32
}
Store Methods:
| Method | Description |
|---|---|
NewStore(path string) (*Store, error) |
Initialize database connection |
AddSite(siteKey, secretKey, name, allowedOrigins string, difficultyPreset *string) error |
Register new site (nil preset: global default) |
GetSite(siteKey string) (*Site, error) |
Lookup site by key |
UpdateSite(siteKey, name, allowedOrigins string, difficultyPreset *string) error |
Modify site (nil preset: global default) |
DeleteSite(siteKey string) error |
Remove site |
ListSites() ([]Site, error) |
All sites |
HasAuth() (bool, error) |
Shows if an admin exists |
SetPasswordHash(hash string) error |
Set admin password |
GetAdminAuth() (*AdminAuth, error) |
Get admin auth |
SetWebAuthnUserID(id []byte) error |
Set WebAuthn user identifier |
UpdateWebAuthnCredential(userID, credID, publicKey []byte, signCount uint32) error |
Store/update passkey |
StoreSession(token string, expires time.Time) error |
Create session |
ValidateSession(token string) (bool, error) |
Verifies the session |
DeleteSession(token string) error |
Revoke session |
StoreWebAuthnSession(token, purpose string, data *webauthn.SessionData, expires time.Time) error |
Store WebAuthn ceremony session |
LoadWebAuthnSession(token, purpose string) (*webauthn.SessionData, error) |
Load and delete WebAuthn session |
IsUsed(salt string, now int64) (bool, error) |
Replay store: is the salt present and unexpired |
MarkUsed(salt string, expiry, now int64) (bool, error) |
Replay store: atomically claim the salt (false = replay) |
RemoveExpiredUsedSalts(now int64) (int64, error) |
Remove expired replay records (runs in the periodic sweep too) |
GetString(key string) (string, error) |
Get a configuration value |
SetString(key, value string) error |
Set a configuration value |
GetInt(key string) (int, error) |
Get an integer configuration value |
SetInt(key string, value int) error |
Set an integer configuration value |
GetBool(key string) (bool, error) |
Get a boolean configuration value |
SetBool(key string, value bool) error |
Set a boolean configuration value |
Delete(key string) error |
Deletes the configuration key |
List() (map[string]string, error) |
Lists all configuration keys and values |
Close() error |
Cleans up and closes |
Helper Functions:
| Function | Description |
|---|---|
RandomBytes(n int) ([]byte, error) |
Generate cryptographically random bytes |
hashToken(token string) string |
SHA-256 hash of a session token |
i18n Package #
Internationalization support via go-i18n.
Functions:
| Function | Description |
|---|---|
NewBundle(defaultLang language.Tag) *i18n.Bundle |
Create a bundle with TOML unmarshaler registered |
LoadFromFS(bundle *i18n.Bundle, fsys embed.FS) error |
Load active.*.toml files from embedded filesystem |
LocalizerFromRequest(bundle *i18n.Bundle, r *http.Request) *i18n.Localizer |
Create localizer from Accept-Language header and ?lang query param |
T(localizer *i18n.Localizer, messageID string, templateData ...map[string]interface{}) string |
Localize a message. Returns the messageID if the translation is not found. |
Middleware(bundle *i18n.Bundle) func(http.Handler) http.Handler |
Chi middleware that injects *i18n.Localizer into request context |
FromContext(ctx context.Context) *i18n.Localizer |
Retrieve localizer from context |
Locale files are stored in locales/ as TOML files (for example, active.en.toml) and embedded via //go:embed.
replay Package #
Shared replay-protection stores for multi-instance deployments. Implements tokens.ReplayStore.
Types:
type RedisStore struct { /* ... */ } // Redis-backed ReplayStore
Functions:
| Function | Description |
|---|---|
NewRedisStore(rawURL string) (*RedisStore, error) |
Connect to Redis. Error when the URL is bad or the startup ping fails |
Resolve(fallback tokens.ReplayStore, redisURL string) (tokens.ReplayStore, func(), error) |
Pick the store from configuration: Redis when the URL is set, otherwise the fallback. The returned closer is a no-op for the fallback |
RedisStore Methods:
| Method | Description |
|---|---|
IsUsed(salt string, now int64) (bool, error) |
Is the salt key present in Redis |
MarkUsed(salt string, expiry, now int64) (bool, error) |
Atomically claim the salt (SET ... NX EX; false = replay) |
Addr() string |
Redis address for startup logs (never contains credentials) |
Close() error |
Close the connection pool |
Every Redis operation has a 2-second timeout. The startup ping has a 5-second timeout.
admin Package #
Admin interface handlers, authentication, site management, and configuration.
Types:
type Handler struct { /* ... */ }
type CombinedStore interface {
AuthStore
SiteStore
WebAuthnStore
ConfigStore
}
type SiteView struct {
SiteKey string
SecretKey string
Name string
AllowedOrigins string
DifficultyPreset string // Empty: the site uses the global default
CreatedAt string // RFC3339 formatted
}
type DomainConfig struct {
PrimaryDomain string
AliasDomains []string
AllDomains []string
}
Security Presets:
type SecurityPreset string
const (
LowSecurityPreset SecurityPreset = "low"
RecommendedSecurityPreset SecurityPreset = "recommended"
HighSecurityPreset SecurityPreset = "high"
)
var PresetParams = map[SecurityPreset]struct{ N, K int }{
LowSecurityPreset: {N: 60, K: 3},
RecommendedSecurityPreset: {N: 80, K: 4},
HighSecurityPreset: {N: 108, K: 5},
}
Constructor:
func NewHandler(store CombinedStore, replayRecords tokens.ReplayStore, loginPowSecret string, domainConfig *DomainConfig, publicOrigin string, bundle *i18n.Bundle, instance observability.InstanceInfo) (*Handler, error)
CombinedStore is an interface that composes AuthStore, SiteStore, WebAuthnStore, and ConfigStore. replayRecords is the shared replay store for the login and setup proof-of-work salts; the same instance the /verify path uses works here. DomainConfig configures WebAuthn for multi-domain deployments (it can be nil for single-domain setups). publicOrigin is the public scheme://host of this instance (for example, https://captcha.example.com). It fixes the WebAuthn relying-party configuration and the Secure cookie flag. Pass an empty string to use per-request headers. bundle is the i18n bundle for template localization. instance carries the build identity (server version and solver.wasm digest) that the dashboard Instance card shows.
Handler Methods (HTTP):
| Method | Route | Description |
|---|---|---|
AdminPage |
GET/POST /admin |
Dashboard with site management |
LoginPage |
GET/POST /admin/login |
Password + PoW authentication |
SetupPage |
GET/POST /admin/setup |
First-time password setup |
Logout |
POST /admin/logout |
Session termination |
UpdateSite |
POST /admin/site/update |
Modify site settings |
DeleteSite |
POST /admin/site/delete |
Remove site |
LoginChallenge |
GET /admin/login/challenge |
PoW challenge |
SetupChallenge |
GET /admin/setup/challenge |
PoW challenge for first-run setup |
HandlePasswordChangeForm |
GET /admin/password |
Password change form |
HandlePasswordChange |
POST /admin/password |
Process password change (requires current password or passkey reauth) |
HandleSettingsForm |
GET /admin/settings |
Settings page (Equihash parameters, security preset, solve-time benchmark) |
HandleSettingsUpdate |
POST /admin/settings |
Update Equihash security preset |
BeginPasskeyRegistration |
POST /admin/webauthn/register/start |
WebAuthn registration |
FinishPasskeyRegistration |
POST /admin/webauthn/register/finish |
Complete registration |
BeginPasskeyLogin |
POST /admin/webauthn/login/start |
WebAuthn login |
FinishPasskeyLogin |
POST /admin/webauthn/login/finish |
Complete login |
BeginPasskeyReauth |
POST /admin/webauthn/reauth/start |
WebAuthn reauthentication (for password change) |
FinishPasskeyReauth |
POST /admin/webauthn/reauth/finish |
Completes the reauth. Returns a short-lived reauth_token |
Security Constants:
const (
minPasswordEntropyBits = 60 // Minimum password strength
loginPowN = 60 // Admin PoW difficulty (N)
loginPowK = 4 // Admin PoW difficulty (K)
loginMaxBodyBytes = 64 * 1024 // Request size limit
webAuthnSessionMinutes = 10 // WebAuthn ceremony timeout
webAuthnReauthMinutes = 2 // Passkey reauth token TTL
webAuthnPurposeReg = "registration"
webAuthnPurposeLogin = "login"
webAuthnPurposePasswordChange = "password_change"
sessionCookieName = "admin_session"
webAuthnSessionCookie = "admin_webauthn_session"
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfHeader = "X-CSRF-Token"
)
observability Package #
Metrics, the liveness handler, the structured-logging setup, and the solver-asset identity. The package generates the Prometheus text format itself, so no metrics client library is a dependency.
Types:
type Metrics struct { /* ... */ } // Counter registry and /metrics handler
type SolverWasm struct { /* ... */ } // solver.wasm snapshot: content digest, size, read status
type InstanceInfo struct { /* ... */ } // Build identity: server version and the solver snapshot
Constructor and Functions:
| Function | Description |
|---|---|
NewMetrics(version, solverWasmSHA256 string) *Metrics |
Create the registry. The values appear as labels on the hecapte_build_info gauge. |
InspectSolverWasm(path string) SolverWasm |
Hash the solver asset once at startup. A missing or unreadable file produces a struct with OK false and a machine-checkable Err (missing or unreadable). |
(SolverWasm).ShortHash() string |
The first 12 hex digits of the digest, for the dashboard and the metrics label. |
(SolverWasm).LogStartup() |
Log the full digest and size at INFO, or an ERROR when the file is missing or unreadable. |
InitLogging() |
Install the process-wide slog logger from LOG_LEVEL and LOG_FORMAT |
RequestLogger(clientIP func(*http.Request) string, skipPaths ...string) |
Chi middleware: one structured line per request, with the request ID also sent as the X-Request-Id response header |
SiteAttrs(name, siteKey string) []slog.Attr |
The standard site log fields (site_name, site_key_hash) |
SiteKeyHash(siteKey string) string |
The SHA-256 hex of a site key, its log-safe identifier |
VerifyFailureReason(err error) string |
Map a tokens.VerifySolution error to a stable reason code |
Healthz(w http.ResponseWriter, r *http.Request) |
Liveness handler: 200 OK with body ok, no I/O |
Metrics Methods:
| Method | Description |
|---|---|
IncChallenge(site, result string) |
Count one GET /challenge outcome |
IncVerify(site, result string) |
Count one POST /verify outcome |
IncVerifyFailure(site, reason string) |
Count one rejected proof by reason |
Handler(token string) http.Handler |
The /metrics handler. An empty token disables the endpoint (always 404). A set token requires Bearer authorization. |
Pass an empty site to the Inc* methods when the request matches no registered site. Label values must never carry attacker-controlled input, because each new label value creates a new counter series.
Usage #
Running Locally (Development) #
export ADMIN_LOGIN_POW_SECRET="dev-secret-key-change-me"
go run ./cmd/server --port 8080 --db hecaptetest.db
Access the demo at http://localhost:8080/. This page demonstrates the challenge and the solve loop with a Web Worker. Verification stays with your backend: the demo has no secret key, and /verify requires one.
The demo page encodes the site key with encodeURIComponent before it requests the challenge. If the challenge request fails, the status line shows the error body from the server. The body names the cause: invalid request for a shape violation.
Running in Production #
export ADMIN_LOGIN_POW_SECRET=$(openssl rand -hex 32)
go run ./cmd/server \
--host example.com \
--port 443 \
--tls-cert /path/to/cert.pem \
--tls-key /path/to/key.pem \
--db hecapte.db
Running on Cloudron #
HeCAPTe ships with built-in Cloudron support. A Cloudron-specific build tag (cloudron) removes the built-in TLS server, because the Cloudron reverse proxy handles the TLS termination.
The Cloudron build:
- Omits the
--tls-certand--tls-keyflags (the platform handles the TLS) - Reads
CLOUDRON_APP_DOMAINandCLOUDRON_ALIAS_DOMAINSenvironment variables for multi-domain WebAuthn - Uses
start.shto auto-generateADMIN_LOGIN_POW_SECRETon the first run - Serves
/healthzfor the platform health check (the manifest setshealthCheckPath) instead of the demo page
Note: HeCAPTe is not exclusively a Cloudron package. The Cloudron-related files at the repo root (
Dockerfile,CloudronManifest.json,CloudronVersions.json,start.sh,CHANGELOG,icon.png) exist only for Cloudron distribution. If you self-host HeCAPTe directly, you can ignore or remove them. Build the Go binary and run it as described in the Running in Production section above, or download a prebuilt archive from katsuricata.tngl.io/hecapte (see Option 1: Prebuilt Release).
Cloudron packaging files at the repo root:
| File | Purpose |
|---|---|
Dockerfile |
Multi-stage build (Go compile → Cloudron base image) |
start.sh |
Runtime entrypoint (generates secret, drops privileges) |
CloudronManifest.json |
App store metadata |
CloudronVersions.json |
Release tracking |
CHANGELOG |
Cloudron-specific changelog |
icon.png |
Cloudron app store icon |
Screenshots for the Cloudron app store listing are stored in deploy/cloudron/screenshots/.
Admin Dashboard #
- Navigate to
/admin(for example,http://localhost:8080/admin) - First run: The server redirects you to
/admin/setupto set the admin password - Login: Password authentication with optional Passkey (WebAuthn)
- Create Site Keys and Secret Keys:
- Site Key: Public identifier (embedded in HTML)
- Secret Key: Private key. Your backend presents it as the bearer credential on
/verify.
- Settings (
/admin/settings): Configure the global Equihash difficulty with security presets (Low / Recommended / High). The Solve-Time Benchmark on the page times every preset on the current device. Run it on every device class you expect, for example an older phone. - Per-site difficulty: Edit a site on the dashboard and select a Difficulty preset. The site then ignores the global default.
- Instance (on the dashboard): The server version and the solver.wasm digest of the running instance. See Solver Version Check.
- Password (
/admin/password): Change admin password (requires current password or passkey reauthentication)
Embedding in Your Application #
1. Include the Web Worker (Recommended):
The provided worker.js loads the WASM module and answers SOLVE messages. Each SOLVE message attempts one range of 10,000 nonces. The READY status and every RESULT message carry the capability struct of the worker in caps:
<script>
const worker = new Worker('/static/worker.js');
let solverReady = false;
let workerCaps = null;
worker.onmessage = function(e) {
const { type, payload, caps } = e.data;
if (caps) workerCaps = caps;
if (type === 'STATUS' && payload === 'READY') {
solverReady = true;
}
};
</script>
The caps object reports live facts of the form { w: 1, a: 1, mod: "hex...", ready: 1 }. w means the code ran inside a real Web Worker. a means the engine accepts WebAssembly modules. mod carries the module identity digest that the solver binary registered when it loaded. ready means the module answered a live handshake round-trip before the worker announced READY. These values are presence checks, not fingerprints: they never describe the user or the hardware.
2. Manual WASM Initialization (if you do not use worker.js):
<script src="/static/wasm_exec.js"></script>
<script>
const go = new Go();
const wasmResponse = await fetch("/static/solver.wasm");
const wasmBinary = await wasmResponse.arrayBuffer();
const result = await WebAssembly.instantiate(wasmBinary, go.importObject);
go.run(result.instance);
// solveChallenge, moduleHandshake, and solverIdentity are now available
// globally. Compute hs = moduleHandshake(salt, ts, n, k, sv).hs once per
// challenge and echo it with the proof.
</script>
3. Request and Solve Challenge:
Each SOLVE message attempts one range of 10,000 nonces, inside the window of the challenge. If the range has no solution, the result has the key error with the value no solution found in range, plus the key lastNonce. Send a new SOLVE message with lastNonce as nonceMin, and keep nonceMax. Repeat until the result has a nonce key:
// Using the provided worker.js
function postSolve(worker, challenge, nonceMin, nonceMax) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const handler = (e) => {
if (e.data.id !== id) return;
worker.removeEventListener('message', handler);
// The RESULT message carries the solve in payload, the module
// handshake in hs, and the capability struct in caps.
if (e.data.type === 'RESULT') resolve({ result: e.data.payload, hs: e.data.hs, caps: e.data.caps });
if (e.data.type === 'ERROR') reject(new Error(e.data.payload));
};
worker.addEventListener('message', handler);
worker.postMessage({
id,
type: 'SOLVE',
payload: {
salt: challenge.salt,
ts: challenge.ts,
n: challenge.diff.n,
k: challenge.diff.k,
nonceMin,
nonceMax
}
});
});
}
async function solveHeCAPTe(worker, siteKey) {
// Get the challenge. The browser sends the Origin header by itself on
// a cross-origin fetch, and the challenge binds that value.
const challenge = await fetch(`/challenge?site_key=${siteKey}`)
.then(r => r.json());
// Solve in the worker, one window slice per message. The server picked
// the window, and the solver must stay inside it.
let solution;
let hs = '';
let caps = null;
let nonceMin = challenge.nonce_min;
const nonceMax = challenge.nonce_max;
while (!solution) {
const slice = await postSolve(worker, challenge, nonceMin, nonceMax);
if (slice.hs) hs = slice.hs;
if (slice.caps) caps = slice.caps;
if (slice.result.nonce) {
solution = slice.result;
} else if (slice.result.error === 'no solution found in range' && slice.result.lastNonce < nonceMax) {
nonceMin = slice.result.lastNonce;
} else {
throw new Error(slice.result.error || 'the signed window has no solution');
}
}
// Send the proof to your own backend. The backend calls /verify with
// the secret key. The browser never holds that key.
const response = await fetch('/submit-form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_key: siteKey,
data: {
nonce: solution.nonce,
salt: challenge.salt,
ts: challenge.ts,
diff: challenge.diff,
sig: challenge.sig,
sol: solution.solution,
// Echo the signed fields: the request classification, the nonce
// window, and the solver generation of the module. Verification
// fails without them.
flags: challenge.flags,
nonce_min: challenge.nonce_min,
nonce_max: challenge.nonce_max,
sv: solution.sv,
// The module handshake. The worker computes it. A wrong value
// fails verification after the proof check.
hs: hs
}
})
});
return response.json();
}
4. Backend Verification:
The browser sends the solved proof to your backend. Only your backend calls /verify. The call carries the secret key as the bearer credential.
Browser --GET /challenge (public site_key)--> HeCAPTe
Browser --solved proof--> Your backend
Your backend --POST /verify (site_key + proof, Authorization: Bearer secret_key)--> HeCAPTe
Keep the secret key in the environment or the configuration of your backend.
Origin is part of the signature (4.0.0 footgun). The
Originheader of the browser's/challengefetch is folded into the challenge HMAC, and/verifyre-derives the signature over the origin your backend names in the optional top-leveloriginfield. If your page fetches the challenge from a browser — which always sends anOriginheader — your backend must send that same origin (scheme + host, e.g.https://app.example.com) asorigin. Omitting it re-signs over the empty string, which only matches a challenge fetched with noOriginheader (a server-to-server solver). The result isinvalid signatureon every browser-minted proof — a total verification failure that looks, from the client, like an unsolvable CAPTCHA. The value must match the page origin exactly; a challenge minted onhttps://app.example.comdoes not verify againsthttps://www.app.example.com. Lying is safe by design: the signature already binds the true origin, so a wrong echo just fails the check. See theinvalid signatureentry in Error Handling.
A call from the shell shows the full shape:
curl -X POST https://captcha.example.com/verify \
-H "Authorization: Bearer $HECAPTE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"site_key": "...", "origin": "https://app.example.com", "data": {...}}'
A Go backend can embed the verifier and skip the HTTP API. The HeCAPTe packages do the same work in-process:
import "hecapte/internal/tokens"
func handleSubmission(w http.ResponseWriter, r *http.Request) {
var req struct {
SiteKey string `json:"site_key"`
Data tokens.ChallengeRequest `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Get site's secret key from database
site, err := store.GetSite(req.SiteKey)
if err != nil {
http.Error(w, "invalid site", http.StatusForbidden)
return
}
// Verify the solution (the db store doubles as the replay store).
// The origin argument must match the Origin header of the challenge
// fetch. Pass the page origin when a browser fetched the challenge;
// pass the empty string only when your flow sends no Origin header.
if err := tokens.VerifySolution(store, site.SecretKey, req.Data, "https://app.example.com"); err != nil {
http.Error(w, "verification failed", http.StatusForbidden)
return
}
// Success - process the form submission
w.WriteHeader(http.StatusOK)
}
The embedded example passes the db store as the replay store, because *db.Store implements tokens.ReplayStore. To share replay state across hosts, use replay.NewRedisStore(redisURL) and pass its result instead.
Ports & Integrations #
HeCAPTe is designed for embedding into other projects. Below are the known ports and integrations that the community maintains.
| Project | Platform | Description |
|---|---|---|
| HeCAPTe CAPTCHA | Drupal | Submodule for the CAPTCHA module universe. Drop-in HeCAPTe integration for Drupal forms. |
Have you ported or integrated HeCAPTe into another project? Open an issue or pull request to get it listed here!
Error Handling #
Server Errors #
| Error | HTTP Status | Cause | Resolution |
|---|---|---|---|
invalid request |
403 | Unknown site_key, or origin not in the allowed list on /challenge |
Make sure that the site configuration is correct |
bad request |
400 | Malformed JSON, a shape violation, a query string on POST, a body outside POST, or conflicting transfer framing |
Make sure that the request matches the endpoint shapes |
method not allowed |
405 | The verb is outside the allowlist of GET, HEAD, POST, and OPTIONS |
Send one of the four allowed verbs |
request header fields too large |
431 | More than 64 distinct header names, or more than 32 KiB of header bytes | Send fewer and smaller headers |
unauthorized |
401 | The Authorization credential on /verify is missing, malformed, or wrong |
Send Authorization: Bearer <secret_key> from your backend |
verification failed |
403 | Invalid signature (also: the backend did not echo the challenge's origin, or echoed a different one), expired challenge, or wrong solution |
Regenerate the challenge and retry |
internal error |
500 | Database or signing failure | Read the server logs |
Challenge Verification Errors #
// From tokens.VerifySolution - returned as inline errors.New() calls
"invalid signature" // HMAC mismatch (covers flags, nonce window, solver generation, origin).
// The most common cause: the backend omitted the top-level `origin`
// field on /verify, or echoed an origin different from the one the
// browser's /challenge fetch carried. See the origin footgun in
// Embedding in Your Application.
"solver version mismatch" // The echoed solver generation is not the one of this build
"challenge expired" // >60 seconds old or future timestamp
"challenge already used" // Replay attack (salt reuse), or salt claim rejected
"missing nonce" // The solution carries no nonce
"nonce too large" // More than 8 hex characters
"nonce not canonical" // Short, odd-length, or uppercase nonce
"invalid nonce format" // The 8 characters are not valid hex
"nonce outside window" // The nonce lies outside the signed search window
"invalid salt format" // Salt is not valid hex
"invalid proof of work" // Equihash verification failed
"module handshake mismatch" // The echoed module handshake does not match the recomputed digest
"request flagged" // The challenge carries the deny flag of the request filter
"replay store not configured" // A nil ReplayStore was passed
// Wrapped errors (fmt.Errorf with %w) when the replay store cannot answer:
"replay store check failed: ..." // Store outage at the pre-check (fails closed)
"replay store mark failed: ..." // Store outage at the claim (fails closed)
Note: These error strings are not exported variables. The functions return them as inline errors.New() strings.
HeCAPTe returns a vague 403 to the client for a bad site key, for a bad origin on /challenge, and for a bad proof on /verify. A bad credential on /verify gets a 401. This vagueness is intentional and resists probing. The structured server log records the exact reason. Search for "msg":"verification failed" and read the site_name, reason, and error fields. The same data is available as the hecapte_verify_failures_total metric when METRICS_TOKEN is set. When a valid submission is rejected, read the server logs first.
Admin Authentication Errors #
The admin pages localize these messages. This table shows the English values:
| Error | Cause | Resolution |
|---|---|---|
The CSRF token is not valid. |
Missing or mismatched CSRF token | Make sure that the cookie is set and that you submit the token via the form or the X-CSRF-Token header |
The proof of work is not valid, or it expired. |
Login PoW challenge failed, or a login form was submitted twice | Complete the PoW challenge before you submit the login. Reload the page after a failed submit. |
The password does not meet the security requirements. |
Password < 60 bits entropy | Use a longer or more complex password |
WASM Solver Errors #
| Error | Cause | Resolution |
|---|---|---|
missing arguments |
Less than 5 arguments provided | Make sure that the function call has the correct arguments |
build seed: ... |
Invalid salt format, or a non-canonical nonce | Make sure that the salt is valid hex and that the nonce has 8 lowercase hex digits |
no solution found in range |
Tried 10,000 nonces without success | Continue with lastNonce as the new nonceMin, while lastNonce is less than nonceMax |
Database Errors #
// Common errors returned from db package (inline errors.New() strings)
"site not found"
"admin auth not initialized"
"session not found" // WebAuthn session lookup failure
Replay-Protection Stores #
The server picks the replay-protection store at startup. The store records each verified salt until the challenge expires (60 seconds after issue). A salt claim is one atomic operation, so concurrent replicas accept one salt exactly once.
| Store | How to select it | Shared between hosts | Survives restart |
|---|---|---|---|
SQLite used_salts table |
Default (*db.Store implements the interface) |
No. Several processes on one host share the file | Yes |
| Redis | REDIS_URL (fallback CLOUDRON_REDIS_URL) |
Yes | Follows your Redis configuration |
| Memory | tokens.NewMemoryStore() in embedded Go use |
No | No |
| Property | SQLite | Redis | Memory |
|---|---|---|---|
| Atomic claim | One-statement upsert | SET ... NX EX |
sync.Map compare-and-store |
| Expiry sweep | Background delete every 5 minutes | Redis TTL deletes the keys | Lazy, at most every 30 seconds |
| Capacity | Disk | Redis memory | 100,000 entries, then forced cleanup |
| Startup check | Database open | Startup ping; the server aborts on failure | None |
A store error rejects the verification (fail closed). See Replay Protection and High Availability for the full story.
Retry Logic Example (direct WASM API):
// Using solveChallenge directly (not via worker.js)
async function solveWithRetry(challenge, maxAttempts = 10) {
let nonceStart = 0;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = solveChallenge(
challenge.salt,
challenge.ts,
challenge.diff.n,
challenge.diff.k,
nonceStart
);
if (result.nonce) {
return result; // Success
}
if (result.error === "no solution found in range") {
nonceStart = result.lastNonce;
continue; // Try next range
}
throw new Error(result.error); // Fatal error
}
throw new Error("Max attempts exceeded");
}
Recovery Strategies #
- Challenge Expired: The client must request a new challenge
- Solution Not Found: Continue with higher nonce values
- Replay Protection: The server accepts each salt once. In the default store, the record survives a restart.
- Rate Limiting: Admin login uses two layers. The proof of work makes each attempt cost CPU time. A per-IP token bucket bounds the attempt rate, and the Argon2id gate serializes the password comparisons.
- Passkey Reauth Expired: Re-authenticate with the passkey (2-minute TTL). A reauth token does not survive a server restart.
- Configurable Difficulty: Use the admin settings page to change the global difficulty. Edit a site on the dashboard to override the difficulty for that site. The server never accepts a difficulty below the
lowpreset, whatever the configuration contains.
Project Layout #
HeCAPTe/
├── cmd/
│ ├── server/ # Main HTTP server application
│ │ ├── main.go # Server entry point, routes, challenge/verify handlers
│ │ ├── security_headers.go # nosniff, frame-ancestors, Referrer-Policy (both builds)
│ │ ├── server_tls.go # TLS server + HSTS (default build)
│ │ ├── server_cloudron.go # Plain HTTP server (cloudron build)
│ │ ├── flags_tls.go # TLS command-line flags (default build)
│ │ ├── flags_cloudron.go # Stub TLS flags (cloudron build)
│ │ ├── server_test.go # CORS and origin tests
│ │ └── router_test.go # End-to-end tests: /healthz, /metrics gating, /verify authentication, verify flow, request filter, method guard
│ └── wasm-solver/ # WebAssembly client solver
│ └── main.go # WASM entry point
├── deploy/
│ └── cloudron/
│ └── screenshots/ # Cloudron app store screenshots
├── internal/
│ ├── admin/ # Admin interface
│ │ ├── handler.go # Dashboard, login, setup, logout handlers
│ │ ├── handlers_password.go # Password change (with passkey reauth)
│ │ ├── handlers_settings.go # Equihash preset configuration
│ │ ├── handlers_webauthn.go # Passkey registration, login, reauth
│ │ ├── auth.go # Password hashing, session management
│ │ ├── webauthn.go # WebAuthn/Passkey manager, DomainConfig
│ │ ├── sites.go # Site CRUD, key generation
│ │ ├── config.go # ConfigStore interface, security presets
│ │ ├── middleware.go # CSRF, auth, setup middleware
│ │ ├── ratelimit.go # Per-IP token buckets for login/challenge endpoints
│ │ ├── secure.go # IsSecure (TLS/reverse proxy detection), forwarded-header warning
│ │ ├── const.go # Cookie name constants
│ │ ├── handler_test.go
│ │ ├── auth_test.go
│ │ ├── webauthn_test.go
│ │ ├── middleware_test.go
│ │ ├── secure_test.go
│ │ ├── sites_test.go
│ │ └── config_test.go
│ ├── crypto/ # Equihash implementation
│ │ ├── equihash.go # Solve and verify algorithms
│ │ ├── attestation.go # Module handshake (BLAKE2b digest over challenge fields + constant)
│ │ ├── attestation_test.go
│ │ └── equihash_test.go
│ ├── db/ # Database persistence layer
│ │ ├── store.go # SQLite operations, schema init, connection pragmas
│ │ ├── config.go # admin_config table, key-value store
│ │ ├── used_salts.go # Replay-protection store (SQLite)
│ │ ├── store_test.go
│ │ ├── used_salts_test.go
│ │ ├── coverage_test.go
│ │ └── config_test.go
│ ├── i18n/ # Internationalization
│ │ ├── bundle.go # i18n bundle creation, TOML loading
│ │ ├── localizer.go # T(), LocalizerFromRequest helpers
│ │ ├── middleware.go # Accept-Language middleware, FromContext
│ │ └── i18n_test.go
│ ├── replay/ # Shared replay stores for multi-instance deployments
│ │ ├── redis.go # RedisStore + Resolve (store selection)
│ │ └── redis_test.go # miniredis-backed tests
│ ├── observability/ # Metrics, liveness probe, structured logging, solver identity
│ │ ├── metrics.go # Counter registry, Prometheus text format, failure reasons
│ │ ├── logging.go # slog setup, request logger, site log labels, /healthz
│ │ ├── solver.go # solver.wasm content digest, startup log, InstanceInfo
│ │ ├── metrics_test.go
│ │ ├── solver_test.go
│ │ └── logging_test.go
│ ├── tokens/ # Challenge/verification logic
│ │ ├── tokens.go # Challenge generation/verification, signed Flags, module handshake check
│ │ ├── replay.go # ReplayStore interface
│ │ ├── cache.go # MemoryStore: process-local replay store
│ │ ├── tokens_test.go
│ │ └── cache_test.go
│ └── waf/ # Stateless request filtering: 8G port, shapes, mechanics
│ ├── waf.go # Verdicts, Evaluate, deny-tier policy
│ ├── mechanics.go # Method allowlist, framing sanity, header caps, placement rules
│ ├── shape.go # Strict request shapes of /challenge and /verify
│ ├── signatures_gen.go # Generated signature tables (do not edit)
│ ├── 8G-Firewall.txt # Vendored upstream release, generator input
│ ├── gen/ # Build-time generator (go generate ./internal/waf)
│ ├── waf_test.go # Verdict battery, false-positive battery, policy checks
│ ├── mechanics_test.go # Mechanics boundaries, verb inventory, 8G drift pin
│ └── shape_test.go # Shape allowlist boundaries
├── locales/ # Embedded translation files (via //go:embed)
│ ├── active.en.toml # English translations
│ └── embed.go # LocaleFS embedding
├── templates/ # Embedded HTML templates (via //go:embed)
│ ├── admin.html
│ ├── admin_login.html
│ ├── admin_password.html
│ ├── admin_settings.html
│ ├── admin_setup.html
│ ├── demo.html
│ ├── error.html # Error page template
│ ├── embed.go # Template embedding
│ └── embed_test.go # Template embedding tests
├── web/
│ └── static/ # Static assets (served from disk)
│ ├── admin.js # Admin interface JS
│ ├── solver.wasm # Compiled WASM solver (gitignored, build before serving)
│ ├── style.css # Stylesheets
│ ├── wasm_exec.js # Go WASM runtime
│ └── worker.js # Web Worker for solving and environment attestation
├── .dockerignore # Docker build exclusions
├── .gitignore # Git ignore rules
├── CHANGELOG # Cloudron release changelog
├── CloudronManifest.json # Cloudron app metadata
├── CloudronVersions.json # Cloudron release catalog
├── Dockerfile # Cloudron multi-stage Docker build
├── icon.png # Cloudron app store icon
├── hecaptesmall.webp # Logo that this document uses
├── start.sh # Cloudron runtime entrypoint
├── README.md # This document
├── AGENTS.md # Notes for agents and contributors
├── LICENSE.md # Mutualist License v1.2
├── go.mod # Go module definition
└── go.sum # Dependency checksums
Internationalization (i18n) #
HeCAPTe uses go-i18n for server-side internationalization and locale-aware template rendering. The translation files are embedded at build time via //go:embed. To add a new language, add a TOML file. No code changes are necessary.
How It Works #
-
Bundle initialization (
internal/i18n/bundle.go):NewBundle()creates ago-i18n.Bundlewith the TOML unmarshaler registered.LoadFromFS()loads allactive.*.tomlfiles from the embeddedlocales/directory. -
Request middleware (
internal/i18n/middleware.go): TheMiddleware()chi middleware reads theAccept-Languageheader (and the optional?langquery parameter) and injects an*i18n.Localizerinto the context of each request. -
Template rendering (
internal/admin/handler.go): Each handler callsh.loc(r)to get the localizer, then builds a*Messagesstruct (for example,DashboardMessagesandLoginMessages) by callinghecapte18n.T(loc, "message.id"). These structs are passed tohtml/template— server-rendered HTML uses the translated strings directly. -
JavaScript client-side (
web/static/admin.js): Message IDs under*.js.*in the TOML file are collected into amap[string]stringand serialized aswindow.I18n = {{.Msg.JS | json}}in each template. The client-sideI18n(key)function looks up keys in this map. If a key is not found, the function uses the key name. -
Fallback chain: If a translation is missing in the requested language, go-i18n uses English (
en). If the English translation is also missing,T()returns the message ID itself.
Translation File Format #
Translations live in locales/ as TOML files named active.<lang>.toml (for example, active.en.toml or active.fr.toml). Each file is a flat list of message entries:
[section.key_name]
other = "Translated text"
[section.templated]
other = "Hello, {{.Name}}!"
[section.js.message]
other = "Loading..."
Key conventions:
| Convention | Meaning | Example |
|---|---|---|
global.* |
Shared across all pages | global.skip_to_content |
admin.* |
Admin dashboard strings | admin.heading |
login.* |
Login page strings | login.login_btn |
setup.* |
First-time setup page | setup.save_btn |
password.* |
Password change page | password.new_password_label |
settings.* |
System settings page | settings.recommended_desc |
demo.* |
Public demo page | demo.start_btn |
webauthn.* |
WebAuthn display strings | webauthn.rp_display_name |
error.* |
Error messages | error.invalid_csrf |
*.js.* |
Strings exposed to JavaScript | admin.js.network_error |
Important rules for .js.* keys:
- JavaScript values use
{key}placeholders (for example,{nonces}and{duration}), not the Go{{.Key}}template delimiters. TheTestJSMessageIDsNoTemplateDelimiterstest fails if you use{{in a.js.*value. - Server-side (non-
.js.*) values use Go template syntax, such as{{.Counter}}and{{.Name}}.
Adding a New Language #
-
Copy the English file as a starting point:
cp locales/active.en.toml locales/active.<lang>.tomlReplace
<lang>with a BCP 47 language tag (for example,fr,de,pt-BR, orzh-Hans). -
Translate the strings in the new file. Only translate the
othervalues — do not change the TOML keys (the[section.key_name]headers). For example:# locales/active.fr.toml [admin.heading] other = "Administration HeCAPTe" [admin.logout_btn] other = "Déconnexion" -
Rebuild and test — the
//go:embed active.*.tomldirective inlocales/embed.goloads the new file automatically at build time:go build -o hecapte ./cmd/server go test ./internal/i18n/... ./internal/admin/... -
Verify the language detection. Visit any page with
?lang=<lang>(for example,http://localhost:8080/admin?lang=fr), or send the correctAccept-Languageheader.
No Go code changes are needed. The LoadFromFS() function finds all active.*.toml files automatically, and the language matcher of the middleware selects the new tag.
Contributing Translations #
New translations and improvements to existing translations are welcome.
-
Fork the repository and create a feature branch:
git checkout -b add-<lang>-translation -
Add or update the appropriate
active.<lang>.tomlfile inlocales/. -
Make sure that the file is complete — compare your file with
active.en.toml. Every key in the English file must also be in your translation. Missing keys use English at runtime, but a complete translation is better. -
Do not add new keys — if you need a message ID that does not exist yet, open an issue first. Then we can add it to
active.en.toml(the canonical source) before you translate. -
Run the tests to make sure nothing is broken:
go test ./internal/i18n/... ./internal/admin/...In particular, the
TestJSMessageIDsNoTemplateDelimiterstest validates that.js.*values do not contain Go template delimiters. -
Visually verify your translation by running the server and browsing with
?lang=<lang>:ADMIN_LOGIN_POW_SECRET=dev-secret go run ./cmd/server --port 8080 --db test.db # Open http://localhost:8080/admin?lang=<lang>Check all pages: the admin dashboard, login, setup, password change, and settings.
-
Submit a pull request with your translation file. Add a short note about the language and any locale-specific choices that you made.
Translation Guidelines #
- Let the templates escape values:
html/templateescapes every localized string. Do not put raw HTML in a translation. Never introduce raw<script>tags or unescaped user input. - Preserve template variables: Keep
{{.Key}}(server-side) and{key}(JS-side) placeholders intact and in the same logical position. - Respect context: Keys like
admin.passkey_registereduse template data ({{.Counter}}). Translate the surrounding text while keeping the placeholder. - Tone: The UI tone of HeCAPTe is professional but approachable. Avoid formal language where a natural, short phrasing works better.
- Consistency: Use the same translation for the same concept across different pages (for example,
Log out/Déconnexionmust be consistent in the admin and login contexts). - Test edge cases: Long translations can break the layout. Make sure that your strings do not overflow buttons or table cells at typical viewport widths.
Architecture Reference #
For the full i18n package API, see the i18n Package section under API Reference. Key entry points:
| Function | Purpose |
|---|---|
i18n.NewBundle(tag) |
Create a bundle with TOML support |
i18n.LoadFromFS(bundle, fsys) |
Load all active.*.toml from embedded FS |
i18n.Middleware(bundle) |
Chi middleware that injects *i18n.Localizer into the context |
i18n.FromContext(ctx) |
Retrieve localizer from request context |
i18n.T(loc, id, data) |
Translate a message (returns id if not found) |
License #
This project is licensed under the Mutualist License v1.2.
See the LICENSE.md file for the full text.
Mutualist License Summary #
Permissions
- ✅ Commercial use
- ✅ Private / internal use
- ✅ Modification
- ✅ Distribution (source and binaries)
- ✅ Network / SaaS use
- ✅ Patent use (from contributors, as described in the license)
Conditions
- ❗ Keep copyright and license notices
- ❗ Give appropriate credit (see "Credit" in the license)
- ❗ Share source for modified versions you distribute
- ❗ Share source for modified versions you let others use over a network
- ❗ License your changes under the Mutualist License too (same license)
- ❗ Do not add technical measures (such as DRM) that limit the rights of the users
- ❗ Patent peace: you lose patent rights under this license if you start a patent attack over this software.
Limitations
- ❌ No liability
- ❌ No warranty
- ❌ No trademark rights
- ❌ No implied endorsement