Introduction
Captchas.ir is a self-hosted CAPTCHA control plane One Go binary serves public and admin APIs, widgets and the Vue panel The public website is distinct from /panel/
Your backend creates an intent, the browser solves the selected engine challenge, and the backend consumes the receipt through /v1/siteverify These widget examples use Cap; other engines have their own renderer and contract
Web assets are self-hosted Engine traffic depends on that engine configuration PostgreSQL 18 and SQLite have local test evidence; a fresh PostgreSQL 16 and full deployment matrix remains incomplete
Status and scope
| Area | Status | Claim boundary |
|---|---|---|
| Intent to receipt consumption | Implemented | Backend credentials, request binding and atomic consumption |
| Engines and routing | Environment dependent | Implementation, configuration, health and real acceptance are distinct |
| Management and stats | Implemented | Admin session, TOTP and CSRF controls; email needs SMTP |
| Commerce and plans | Incomplete | Payments, quotas, invoicing and contractual SLA are not a complete product |
| Add-ons and SDKs | Soon | Integration examples are not released official SDKs or plugins |
The current data schema is 11 Inspect this environment through the panel and /v1/capabilities; implementation or successful health alone does not prove a real solve
Quick start
Building needs Go as pinned in go.mod. The panel is prebuilt and embedded in the binary
# 1. build
go build -o digicaptcha ./cmd/digicaptcha
# 2. data key: exactly 32 bytes, standard base64; create once and keep it
install -d -m 700 /etc/digicaptcha /var/lib/digicaptcha
umask 077
if [ ! -f /etc/digicaptcha/data.key ]; then
openssl rand -base64 32 > /etc/digicaptcha/data.key
fi
# 3. configure
export DIGICAPTCHA_DB=/var/lib/digicaptcha/digicaptcha.db
export DIGICAPTCHA_DATA_KEY="$(cat /etc/digicaptcha/data.key)"
export DIGICAPTCHA_ADMIN_USER=owner
read -r -s -p "Admin password: " DIGICAPTCHA_ADMIN_PASSWORD
export DIGICAPTCHA_ADMIN_PASSWORD
# 4. run (default 127.0.0.1:8088)
./digicaptcha -listen 127.0.0.1:8088
# 5. check
curl -s 127.0.0.1:8088/livez # {"stage":"m1","status":"live"}
curl -s 127.0.0.1:8088/readyz # {"stage":"m1-sqlite","status":"ready"}
Then open 127.0.0.1:8088/panel/ and sign in with that username and password /panel and /dashboard redirect to the panel too
Do not lose the data key Stored secrets and idempotency responses are encrypted with AES-GCM under this key. A new key means existing data cannot be read
Without DIGICAPTCHA_DB the process starts in foundation mode: /readyz and every public POST answer 503 with ADAPTERS_NOT_IMPLEMENTED, and no verification is ever produced
Environment variables
All settings come from the environment. The only command-line flag is -listen, default 127.0.0.1:8088
| Variable | Purpose |
|---|---|
DIGICAPTCHA_DB | A value starting with postgres:// or postgresql:// selects PostgreSQL; anything else is a SQLite file path. Empty means foundation mode |
DIGICAPTCHA_DATA_KEY | Required with a database. Standard base64 of exactly 32 bytes, otherwise the process refuses to start |
DIGICAPTCHA_ADMIN_USERDIGICAPTCHA_ADMIN_PASSWORD | Creates the owner login at startup, or updates its password (argon2id). Once the password is changed in the panel, the panel password is kept on later starts |
DIGICAPTCHA_PASSWORD_RESET | 1 in /etc/digicaptcha/env applies DIGICAPTCHA_ADMIN_PASSWORD again even after a panel change, clears the sign-in lock, and closes sessions. A shell export is not seen. Remove it after recovery. This flag does not clear a second factor |
DIGICAPTCHA_BOOTSTRAP_TOKEN | Optional one-time token for POST /admin/v1/session/bootstrap when you have no password login yet |
DIGICAPTCHA_COOKIE_SECURE | true sets the Secure flag on the session and CSRF cookies. Required behind HTTPS |
DIGICAPTCHA_MFA_RESET | 1 together with DIGICAPTCHA_ADMIN_USER clears the owner's second factor at startup. Remove it after recovery |
DIGICAPTCHA_CAP_ENDPOINTDIGICAPTCHA_CAP_ADMIN_KEY | URL of a local Cap Standalone (http or https, no path) and its admin key (at least 12 characters). The panel probes Cap's health and sites can connect without re-typing the key |
DIGICAPTCHA_SMTP_HOSTDIGICAPTCHA_SMTP_PORTDIGICAPTCHA_SMTP_USERDIGICAPTCHA_SMTP_PASSWORDDIGICAPTCHA_SMTP_FROM | Sends second-factor email codes. Enabled only when both HOST and FROM are set; port defaults to 587 |
DIGICAPTCHA_MCP_LISTEN | Optional IP:port whose IP must be loopback, for example 127.0.0.1:8090. Names such as localhost are refused. Empty means off |
DIGICAPTCHA_MCP_TOKEN | Optional when a database is set, since tokens can be created in the panel; required without one. 16 to 128 characters, no whitespace. Clients send it as Authorization: Bearer |
Behind nginx
Keep Captchas.ir on loopback and expose only nginx. Captchas.ir trusts X-Forwarded-For only when the request comes from loopback, so nginx must overwrite it with $remote_addr for the real visitor address to reach Cap
server {
listen 443 ssl;
http2 on;
server_name captcha.example.com;
ssl_certificate /etc/ssl/captcha.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/captcha.example.com/privkey.pem;
# Captchas.ir bodies are capped at 64 KiB anyway
client_max_body_size 64k;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_read_timeout 20s;
}
}
server {
listen 80;
server_name captcha.example.com;
return 301 https://captcha.example.com$request_uri;
}
- Set
DIGICAPTCHA_COOKIE_SECURE=true - The browser's
Originheader must reach Captchas.ir unchanged; nginx does this by default - Do not publish Cap Standalone. Only Captchas.ir should be able to reach it
Sites and keys in the panel
- Sign in and add a second factor Sign in at
/panel/and enable a TOTP app, or email once SMTP is configured, on the Security page - New site Enter a name, an environment such as
production, and the allowed origins. Each origin is complete, for examplehttps://shop.example.com - Site key Create a public site key on the site page. It is used in the browser as
X-DigiCaptcha-Site-Keyand is not a secret - Backend credential Create a credential with the
intents:createandreceipts:verifyscopes. The full value, shapeddck_<prefix>_<secret>, is shown exactly once; afterwards only the eight-character prefix is visible - Connect Cap Attach the site to Cap. With
DIGICAPTCHA_CAP_ENDPOINTset you need not enter a URL or key. Captchas.ir creates a Cap site key with instrumentation on, default difficulty1000000and obfuscation level 3 - Try it From the site page, open
/playground/to run the whole flow in your browser and see a real siteverify
Both backend credentials and the Cap instance secret can be rotated. A rotated credential keeps its scopes, and the new value is again shown only once
One site per domain
Create a separate site for every domain that needs a captcha, for example a Pulse site with origin https://pulse.digitalvps.ir and a BGP Map site with origin https://bgpmap.digitalvps.ir. Each site has its own public key, server key and Cap instance, and a key of one site does not work on the origin of another
- At
/panel/sites/newenter the name, environment and the domain origin, with no path and no trailing slash - On the site page the “Set up this site” box shows four steps: origin, public key, server key and Cap instance
- Put the public key in the page HTML and the server key only in that domain's backend configuration
- The “Browser test” button runs from the panel itself, so the panel address never needs to be added to a site's origins
Managing keys and sites
- Every public key and server key can carry a label, such as “signup form”
- Each key can be revoked on its own, without issuing a replacement
- Server key scopes are editable and a narrowed scope applies from the next request, the secret itself changes only by rotation
- A new secret is shown masked, the eye button reveals it and its box closes after two minutes
- Deleting a site requires typing its exact name, every key and instance on it stops at once and the audit history stays
Verification flow
Backend Browser Captchas.ir Cap (loopback)
| | | |
|-- POST /v1/intents (Bearer) ----------------->| |
|<-------------------------------- intent_id ---| |
|-- intent_id ---->| | |
| |-- POST /v1/sessions ------>| (X-DigiCaptcha-Site-Key) |
| |<- session_id, completion --| |
| |-- .../cap/challenge ------>|-- challenge --------------->|
| |<---------------------------|<---------------------------|
| | (solve proof-of-work) | |
| |-- .../cap/redeem --------->|-- redeem ------------------>|
| |-- .../complete ----------->|-- siteverify (secret) ----->|
| |<---------------- receipt --| |
|<-- receipt ------| | |
|-- POST /v1/siteverify (Bearer) -------------->| consume once, atomically |
|<------------------------------ success:true --| |
- An intent lives 30 to 600 seconds (default 300)
- A session lives at most 5 minutes and never longer than the intent's remaining time
- A receipt is valid for 2 minutes and can be consumed once
- Challenge and redeem responses are not successes. Only complete issues a receipt, and only siteverify confirms it
Browser widget
Captchas.ir serves two browser files: /widget/cap.js, which drives the flow, and the official Cap widget at /widget/vendor/cap.min.js with its WASM files. Order matters: cap.js must load first so Cap fetches its WASM from this origin, not from a CDN
<form id="signup" method="post" action="/signup">
<input name="email" type="email" required>
<div id="captcha"></div>
<input type="hidden" name="dc_intent">
<input type="hidden" name="dc_receipt">
<button>Sign up</button>
</form>
<script src="https://captcha.example.com/widget/cap.js" nonce="RANDOM"></script>
<script src="https://captcha.example.com/widget/vendor/cap.min.js" nonce="RANDOM"></script>
<script src="/js/signup.js" nonce="RANDOM"></script>
const API = "https://captcha.example.com";
const SITE_KEY = "PUBLIC_SITE_KEY_FROM_PANEL";
const form = document.getElementById("signup");
async function startCaptcha() {
// 1. Your backend creates the intent; it holds the credential.
const intent = await fetch("/captcha/intent", { method: "POST" }).then((r) => r.json());
// 2. Open a Captchas.ir session with the public site key.
const res = await fetch(API + "/v1/sessions", {
method: "POST",
credentials: "omit",
headers: {
"content-type": "application/json",
"x-digicaptcha-site-key": SITE_KEY,
"idempotency-key": "sess-" + crypto.randomUUID(),
},
body: JSON.stringify({ intent_id: intent.intent_id, locale: "en" }),
});
const session = await res.json();
if (!res.ok) throw new Error(session.error.code);
// 3. Render Cap; resolves with the Captchas.ir receipt.
const done = await DigiCaptcha.mountCap({
apiBase: API,
siteKey: SITE_KEY,
sessionId: session.session_id,
completionToken: session.completion_token,
container: document.getElementById("captcha"),
});
form.elements.dc_intent.value = done.intent_id;
form.elements.dc_receipt.value = done.receipt;
}
startCaptcha().catch((err) => console.error("captcha", err));
DigiCaptcha.mountCap(options)
Renders the official Cap widget inside container, routes its challenge and redeem calls through the Captchas.ir session, and calls /complete once solved. The promise resolves with the complete response, {receipt, receipt_expires_at, intent_id, session_id}
siteKey- Public site key. Required
sessionIdsession_idfromPOST /v1/sessions. RequiredcompletionTokencompletion_tokenfrom the same response. Requiredcontainer- DOM element that receives the widget. Required
apiBase- Captchas.ir origin. Defaults to the origin
cap.jswas loaded from lang"fa"switches the widget labels to Persianlabels- Override labels using Cap's keys, such as
initial-state autoSolve- Ignored. Solving starts only after the person clicks the widget
DigiCaptcha.runCap(options)
For a custom solver or tests, without the official widget. Options: apiBase, siteKey, sessionId, completionToken, idempotency: {challenge, redeem, complete} and solve(challenge), which must return {token, solutions}. It resolves with the complete response. Define globalThis.DigiCaptchaTransport to replace fetch
Host page CSP
The Cap widget uses WebAssembly and a Web Worker for proof-of-work and injects its own styles with a nonce cap.js hands its own script tag's nonce to Cap. A policy like this one, taken from Captchas.ir's own playground page, is enough:
script-src 'self' https://captcha.example.com 'nonce-RANDOM' 'wasm-unsafe-eval' 'unsafe-eval';
style-src 'self' 'nonce-RANDOM';
worker-src 'self' blob:;
connect-src 'self' https://captcha.example.com;
frame-src 'self'
If Captchas.ir is served on the site's own origin (for example via an nginx location), the captcha.example.com entries are unnecessary. Browsers without DecompressionStream cannot unpack Cap's instrumentation, so the challenge fails on purpose; no library is fetched from a CDN
Prosopo OSS
Create a Prosopo site in the panel, select pow, puzzle, iconOrder or image and register the customer site’s exact origin
Your backend creates the intent and the browser uses the normal session response below Keep backend credentials and native registration secrets on the server
<script src="https://captchas.ir/widget/engines.js"></script>
const controller = new AbortController()
const result = await DigiCaptcha.mountProsopo({
apiBase: 'https://captchas.ir',
siteKey: YOUR_PUBLIC_SITE_KEY,
prosopoSiteKey: session.challenge.public_config.prosopo_site_key,
sessionId: session.session_id,
completionToken: session.completion_token,
container: document.getElementById('captcha'),
lang: 'en',
signal: controller.signal
})
// Send result.receipt to your backend for /v1/siteverify
// controller.abort() cancels this widget onlyPerform the protected action only after backend verification and one-use receipt consumption A network error is not success and an issued session must not switch engines
The three visual modes offer an English spoken-digit audio alternative Persian audio and Prosopo’s managed behavioral detector are not included Custom image class labels may use the English fallback
Browser modules and WASM load from this service The style adapter uses the trusted page script nonce For local audio restrict media-src to self and data
Backend verification
Your backend makes two calls, both with the backend credential and an Idempotency-Key. The credential must never reach a browser
1. Create an intent
curl -sS https://captcha.example.com/v1/intents \
-H "Authorization: Bearer $DIGICAPTCHA_CREDENTIAL" \
-H "Idempotency-Key: intent-$(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"site_id": "SITE_ID",
"action": "signup",
"request_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"binding_version": "jcs-v1",
"expires_in_seconds": 300
}'
# 201 Created
{"intent_id":"4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e","action":"signup","expires_at":"2026-10-25T08:05:00Z"}
Your backend computes request_hash; Captchas.ir only compares it. The format is sha256: followed by 64 lowercase hex characters binding_version is currently only jcs-v1, which refers to the RFC 8785 canonical form; what matters is that you hash exactly the same bytes when creating the intent and when calling siteverify
2. Consume the receipt
curl -sS https://captcha.example.com/v1/siteverify \
-H "Authorization: Bearer $DIGICAPTCHA_CREDENTIAL" \
-H "Idempotency-Key: verify-4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e" \
-H "Content-Type: application/json" \
-d '{
"receipt": "RECEIPT_FROM_FORM",
"intent_id": "4b7c0d1e2f3a4b5c6d7e8f9a0b1c2d3e",
"action": "signup",
"request_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}'
# 200 OK
{"success":true,"verification_id":"…","intent_id":"4b7c…","site_id":"…","action":"signup",
"request_hash":"sha256:9f86…","verified_at":"2026-10-25T08:01:12Z"}
Golden rule Proceed only on HTTP 200 with success equal to true. Anything else, including timeouts and network errors, is a rejection
Snippets for PHP, Python, Node and Go are on the integrations page
HTTP API reference
The dedicated reference covers request creation, sessions and receipt consumption
Errors
Every error has the same shape:
{
"error": {
"code": "ALREADY_CONSUMED",
"message": "This receipt was already consumed.",
"request_id": "5f0c2a9e8b7d4c3e1a2b3c4d5e6f7a8b",
"retryable": false
}
}
| HTTP | code | Meaning |
|---|---|---|
| 400 | INVALID_BODY · INVALID_REQUEST | Bad JSON, unknown field or failed validation, including a missing Idempotency-Key |
| 401 | UNAUTHENTICATED | Credential, key or session missing or invalid |
| 403 | FORBIDDEN | Missing scope, disallowed origin or wrong CSRF token |
| 403 | BINDING_MISMATCH | Receipt or intent belongs to another site, action or installation |
| 404 | NOT_FOUND | Not found in this tenant |
| 409 | ALREADY_CONSUMED | The receipt was already consumed |
| 409 | IDEMPOTENCY_CONFLICT | Same key with a different body |
| 409 | OPERATION_IN_PROGRESS | The same operation is still running; retryable: true |
| 409 | CONFLICT | The resource is not in a state that accepts this operation |
| 410 | SESSION_EXPIRED | Intent, session or receipt has expired |
| 413 | BODY_TOO_LARGE | Body over 64 KiB |
| 400 | CAPTCHA_REQUIRED | The panel sign-in captcha is on and its receipt is missing or was not accepted |
| 422 | INVALID_PROOF | Cap rejected the token, or it belongs to another Cap site |
| 502 | UPSTREAM_REJECTED | Cap rejected an admin request |
| 503 | UPSTREAM_UNAVAILABLE | Cap unreachable for challenge or redeem; retryable: true |
| 503 | UPSTREAM_RESULT_UNKNOWN | Verification outcome unknown; no receipt issued, do not retry with the same proof |
| 503 | NO_ELIGIBLE_PROVIDER | No eligible active engine is available for the site |
| 503 | STORAGE_UNAVAILABLE | Database unavailable; retryable: true |
| 429 | RATE_LIMITED | Too many sign-in or browser session calls from one address, or the site's per-IP session cap is full; wait for Retry-After seconds |
| 503 | MAIL_NOT_CONFIGURED | SMTP is not configured |
| 503 | ADAPTERS_NOT_IMPLEMENTED | Foundation mode, no database |
| 500 | INTERNAL_ERROR | Internal error; the operation failed closed |
Idempotency
- Generate one random key per logical operation and send the same key when retrying that operation
- The same key with the same body returns the stored response. That is not a second consumption and creates no new success
- The same key with a different body gets
409 IDEMPOTENCY_CONFLICT - If the earlier operation is still running you get
409 OPERATION_IN_PROGRESS - After
UPSTREAM_RESULT_UNKNOWNdo not retry blindly. Start a new intent and session
Monitor and MCP
The panel's monitor page is fed by GET /admin/v1/monitor:
| Field | Meaning |
|---|---|
engine · schema · ping_ms | Database engine, schema version and ping latency |
pool | open, in_use and idle connections |
counts | Number of sites, users and receipts |
mail_configured | Whether SMTP is configured |
cap | state is running, unreachable or contract_tested_not_live_cap, plus latency_ms, version and commit |
local | Database host, captcha_upstreams: 0, cap_loopback, panel_assets: "origin" and the mcp state |
The MCP listener is optional and opens, separate from the main port, on the loopback address you give in DIGICAPTCHA_MCP_LISTEN. It exposes two read-only tools over Streamable HTTP, digicaptcha_status and digicaptcha_traffic (today per site, countries over seven days and the last 24 hours). Every request needs Authorization: Bearer with a token created in the panel under Settings, or DIGICAPTCHA_MCP_TOKEN from the service settings. It runs no SQL, no shell and no outbound request
Security notes
- Keep backend credentials (
dck_…) on the server only. If one leaks, rotate it from the panel - Give each site only its exact origins. Browser calls without an Origin, or with another one, are refused
- Keep Cap Standalone and the MCP port on loopback
- Behind TLS, do not forget
DIGICAPTCHA_COOKIE_SECURE=true - Enable a second factor for the owner, and remove
DIGICAPTCHA_MFA_RESETafter use - Store the data key apart from database backups
- In your backend, never treat a timeout or network error from siteverify as a pass
- Receipts are stored only as hashes, and the widget sends requests with
credentials: "omit"
Troubleshooting
| Symptom | Likely cause |
|---|---|
The process exits with DIGICAPTCHA_DATA_KEY must be standard base64… | The key is not 32 bytes or not standard base64. Use openssl rand -base64 32 |
/readyz returns 503 | Foundation mode without DIGICAPTCHA_DB, or the database is unreachable |
| After signing in you land on the login page again | DIGICAPTCHA_COOKIE_SECURE=true on plain HTTP; the browser drops Secure cookies. Turn it on only behind HTTPS |
Every admin API change gets 403 FORBIDDEN | No X-CSRF-Token header matching this session's digicaptcha_csrf cookie |
POST /v1/sessions returns 403 | The page's Origin is not in the site's allowed origins, or the site key belongs to another site |
NO_ELIGIBLE_PROVIDER | The site is not connected to a Cap instance yet |
| Monitor shows Cap as unreachable | Cap does not answer its health check at DIGICAPTCHA_CAP_ENDPOINT |
| Widget does not render or WASM fails | Script order or the page's CSP cap.js must come before cap.min.js |
siteverify returns BINDING_MISMATCH | action, intent or request_hash differ from intent creation, or the credential belongs to another site |
| Email codes never arrive | DIGICAPTCHA_SMTP_HOST or DIGICAPTCHA_SMTP_FROM is not set |
Panel password forgotten and DIGICAPTCHA_PASSWORD_RESET=1 has no effect | Put the line in /etc/digicaptcha/env and run systemctl restart digicaptcha. The journal must say admin password reset from environment. The password is DIGICAPTCHA_ADMIN_PASSWORD |
| Second factor lost | Also put DIGICAPTCHA_MFA_RESET=1 in /etc/digicaptcha/env for one restart, then remove it |
Licenses
- The panel UI is derived from GateCHA (MIT)
- The Cap widget and WASM files are unmodified copies from Cap Standalone (Apache-2.0)
- The Vazirmatn font is under the SIL Open Font License 1.1
- Flags are from flag-icons 7.6.0 under the MIT License
- The world map is generated from world-atlas 2.0.2 (Natural Earth 1:110m, public domain; package under the ISC License)
- Country lookup uses IP2Location LITE data compiled into the binary, so no request leaves the host. This site or product includes IP2Location LITE data available from https://lite.ip2location.com