The open-source human verification service for nginx making bots cry.
Note
cryKepper is currently in early development.
Anything can change, and there are no guarantees about stability, security, or data integrity. Use with caution and always test thoroughly before deploying in production.
cryKeeper is a lightweight, Python-powered security container designed to protect your web applications from automated bots, scrapers, and credential stuffing. Utilizing nginx's native auth_request module, it intercepts malicious traffic before it ever touches your backend.
Why cryKeeper?
- Open Source: Fully transparent, with no hidden dependencies.
- Zero Backend Overhead: Bots are rejected directly at the nginx level.
- Docker-Ready: Deploy in seconds via
docker-compose. - Language Agnostic: Works flawlessly whether your app is as static website or built in Node.js, PHP, Go, Python or any other language.
By default, cryKeeper focuses strictly on verifying human behavior. This means that good bots (like Googlebot, Bingbot, or uptime monitors) will also be blocked or challenged because they cannot pass human verification.
- If your site relies on SEO (Google Indexing): You can allow known search engines directly in cryKeeper, or bypass selected clients with explicit IP or User-Agent allowlists. Prefer IP-based allowlists when trust boundaries matter, because User-Agent matching is easy to spoof.
- If your site is a private app (Nextcloud, Bitwarden, internal tools): This is actually a feature! It keeps your private instances completely hidden from any search engine or automated scanner.
- Protect selected areas of a website behind nginx with a human verification step.
- Reuse signed stateless verification cookies so visitors do not need to solve a challenge on every request.
- Choose between Cap, ALTCHA, hCaptcha, or Dummy mode depending on your deployment and testing needs.
- Configure shared defaults and per-host website overrides with different domains, prefixes, and challenge settings.
- Exclude selected routes from checks with skip rules when parts of the site should stay reachable without verification.
- Bypass the human check for selected client IPs, User-Agent regexes, or a built-in set of common search-engine crawlers.
- Apply challenge and verify rate limits, with optional shared state through Valkey for multi-worker or multi-instance deployments.
- Localize the challenge page and add deployment-specific footer content.
- Internal Prometheus metrics and a dashboard for monitoring verify success rates, dominant failure reasons, provider latency, and rate-limit hits.
- nginx sends an internal auth_request subrequest to cryKeeper before the original request reaches the protected upstream.
- cryKeeper first evaluates configured auth bypasses for routes, client IPs, User-Agents, and optional known search-engine crawlers. If one matches, cryKeeper returns 204 immediately.
- Otherwise cryKeeper checks the signed verification cookie. If it is valid, cryKeeper returns 204 and nginx forwards the original request to the website.
- If the cookie is missing or invalid, cryKeeper returns 401 together with an X-Auth-Redirect header so nginx can redirect to, or internally proxy, the challenge page.
- After a successful challenge, cryKeeper sets a new signed verification cookie and redirects the browser back to the validated original target.
cryKeeper supports four verification modes. They all use the same stateless cookie flow, but differ in external dependencies, operations, and user experience.
Tip
Cap is the recommended mode for most production deployments and is the most tested option in this project.
Because Cap does significantly more than a simple CAPTCHA, it provides a better user experience and stronger bot protection than the other modes. It is also self-hosted, so you can run it in the same private network without mandatory third-party dependencies.
| Mode | External dependency | Typical use | Notes |
|---|---|---|---|
| Cap | Self-hosted Cap service | Production / privacy-focused setups | Best fit when you want strong protection without relying on third-party CAPTCHA providers. |
| ALTCHA | None required (can run fully local) | Production / minimal dependencies | Proof-of-work challenge with server-side cryptographic verification. |
| hCaptcha | hCaptcha SaaS API | Production with managed provider | Requires site/secret keys and outbound internet access from cryKeeper to hCaptcha endpoints. |
| Dummy | None | Local development and wiring tests | No real bot protection. Never use in production. |
Detailed differences:
- Cap mode performs real verification against a configured Cap instance.
- Cap is a privacy-focused self-hosted CAPTCHA service for the modern web.
- Requires running and operating Cap, but avoids mandatory third-party dependencies in production.
- ALTCHA mode serves the ALTCHA widget and verifies its signed proof-of-work payload server-side.
- Can be fully self-contained and stateless, especially with the bundled local ALTCHA script.
- hCaptcha mode uses hCaptcha's browser widget plus server-side validation against hCaptcha's siteverify API.
- Best when you prefer a managed provider over operating your own CAPTCHA backend.
- Dummy mode simulates verification without a real provider.
- Useful for local integration tests, demos, and CI wiring only.
The recommended installation path is Docker Compose with the published GHCR image ghcr.io/crymg/crykeeper:latest.
Alternatively you may use a version tag such as :v1.2.3 or :v1.2 or :v1, or the nightly tag for the latest build from the default branch.
Requirements:
- Docker
- Docker Compose
- nginx or another reverse proxy that can call cryKeeper's check endpoint
Quick start with the latest published image:
- Create a working directory and place your cryKeeper configuration there as
config.toml. You can start from config.example.toml. - Create a
docker-compose.ymllike this:
services:
crykeeper:
image: ghcr.io/crymg/crykeeper:latest
ports:
- "127.0.0.1:5000:5000"
volumes:
- ./config.toml:/app/config.toml:ro
restart: unless-stopped- Pull and start the service:
docker compose pull
docker compose up -dThis starts a minimal production-like cryKeeper service from the published image and binds it only on 127.0.0.1:5000 so a local nginx or another trusted reverse proxy can reach it without exposing cryKeeper itself publicly. The container reads /app/config.toml by default.
The Docker image runs the Gunicorn process as a dedicated unprivileged crykeeper user, so mounted config files should stay readable inside the container.
If your nginx runs in the same Docker network, prefer an internal container-to-container connection and replace the localhost port binding with expose: or an equivalent private network setup.
If you want to build cryKeeper from the local source tree instead, use the checked-in docker-compose.yml. The source-based local demo flow is documented below in Local Demo Stack.
The Docker image starts Gunicorn with 2 workers and 4 threads by default. You can override that with CRYKEEPER_GUNICORN_WORKERS and CRYKEEPER_GUNICORN_THREADS.
For the internal Prometheus endpoint and dashboard, the Docker image also enables Prometheus multiprocess mode by default through CRYKEEPER_PROMETHEUS_MULTIPROC_DIR=/tmp/crykeeper-prometheus. If you override that path, keep it writable for the container user and let startup clear it before Gunicorn forks workers.
These runtime variables affect only the container startup and internal Prometheus worker aggregation. They are separate from the cryKeeper application settings and are not part of the TOML and CRYKEEPER_* config precedence described below.
Docker images are published to ghcr.io/crymg/crykeeper.
Published tags are multi-architecture manifests for linux/amd64 and linux/arm64.
The latest tag always points to the most recent stable release, which is a Git tag in the form vX.Y.Z without any pre-release suffix. The nightly tag points to the latest build from the default branch that does not carry a version tag.
You may also pull specific version tags such as v1.2.3, v1.2, or v1 to get a specific release or the latest patch release in a minor or major series.
cryKeeper is intended to be called by nginx via auth_request. A minimal setup looks like this:
upstream crykeeper_app {
server crykeeper:5000;
}
upstream protected_app {
server app:8080;
}
location = /_crykeeper_check {
internal;
proxy_pass http://crykeeper_app/crykeeper/check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header Host $http_host;
proxy_set_header User-Agent $http_user_agent;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
}
location @crykeeper_challenge {
proxy_pass http://crykeeper_app$auth_redirect;
proxy_set_header Cookie $http_cookie;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
}
location /protected/ {
auth_request /_crykeeper_check;
auth_request_set $auth_redirect $upstream_http_x_auth_redirect;
error_page 401 =403 @crykeeper_challenge;
proxy_pass http://protected_app;
}
location ^~ /crykeeper/ {
proxy_pass http://crykeeper_app;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
}When nginx terminates HTTPS before forwarding to cryKeeper over HTTP, set trusted_proxy_hops to the number of trusted proxy hops and set trusted_proxy_cidrs to the nginx network ranges. cryKeeper aborts startup when proxy hops are enabled without trusted proxy CIDRs, because that would trust forwarded headers from any direct peer.
Keep the public prefix in nginx aligned with your configured path_prefix. If you use per-host [[website]] overrides, each host must forward to the matching cryKeeper prefix.
The example above internally proxies the challenge page so the browser keeps the original protected URL visible while still receiving 403 Forbidden. If you prefer a visible jump to the cryKeeper path instead, you can replace the named location body with return 302 $auth_redirect; and change the error_page line back to error_page 401 = @crykeeper_challenge;.
Every configured path_prefix exposes the same set of cryKeeper endpoints. With the default configuration, the paths are available below /crykeeper.
GET <path_prefix>/check: internal auth endpoint for nginxauth_request. Returns204 No Contentwhen a configured bypass matches or when the signed verification cookie is valid, or401 Unauthorizedplus theX-Auth-Redirectheader when nginx should hand the browser over to the challenge flow, for example by internally proxying the challenge page with a403 Forbiddenresponse.GET <path_prefix>/challenge: browser-facing challenge page. Renders the configured verification flow, respects the safe localreturnquery parameter, enforces the secure-transport rules, and applies the challenge rate limit.POST <path_prefix>/verify: completes the active provider verification, sets the signed verification cookie, and redirects the browser to the validated localreturnpath. This endpoint is protected by the verify rate limit.GET <path_prefix>/altcha/challenge: provider-specific ALTCHA challenge endpoint. Returns a fresh signed ALTCHA challenge as JSON and applies the same secure-transport and challenge rate-limit checks as the HTML challenge page.GET <path_prefix>/clear: removes the verification cookie and redirects to the validated localreturnpath, falling back to/if the parameter is missing or invalid.GET <path_prefix>/healthz: minimal liveness endpoint for container and reverse-proxy health checks. Returns200 OKwith the bodyok.GET <path_prefix>/static/*: static assets for the challenge page and verification flows, including the bundled vendor files.
In deployments with [[website]] overrides, the same endpoint set is also exposed below each additional configured path_prefix.
cryKeeper also exposes two fixed internal observability endpoints outside the public path_prefix namespace:
GET /_crykeeper/metrics: Prometheus exposition endpoint with counters and histograms for auth checks, challenge renders, explicit unsolved challenge attempts, verify outcomes, rate-limit hits, provider latency, and rate-limit backend fallbacks.GET /_crykeeper/dashboard: small server-rendered dashboard built from the same live Prometheus metrics. It shows verify success rates, explicit unsolved challenge attempts, dominant failure reasons, provider latency, skip-route bypass counts, rate-limit hits, and backend fallback counts.
These endpoints are meant for private reverse-proxy exposure only, for example on a dedicated internal hostname or an allowlisted admin vhost. They are intentionally not registered below path_prefix, so the normal public challenge routes do not expose them automatically.
If you run multiple Gunicorn workers, keep Prometheus multiprocess mode enabled so the metrics endpoint aggregates all workers correctly. The bundled Docker image handles this automatically with CRYKEEPER_PROMETHEUS_MULTIPROC_DIR.
Preferred configuration is TOML. Environment variables are supported as an alternative.
Configuration precedence:
- Built-in defaults
- Shared
[crykeeper]values from the TOML file - Non-empty
CRYKEEPER_*environment variables - A matching
[[website]]TOML block for the current host
Use TOML for the main configuration:
- Shared defaults go into
[crykeeper] - Optional per-host overrides go into
[[website]] - The default config path inside the container is
/app/config.toml path_prefixmust not equal/_crykeeper, because that fixed prefix is reserved for the internal observability endpoints
Minimal example:
[crykeeper]
secret_key = "change-me-in-production"
verification_mode = "dummy"
path_prefix = "/crykeeper"
human_cookie_secure = true
trusted_proxy_hops = 1
trusted_proxy_cidrs = ["172.16.0.0/12"]
cap_public_base_url = "https://cap.example.com"
cap_site_key = "your-cap-site-key"
cap_secret_key = "your-cap-secret-key"
[[website]]
domains = ["one.example.com"]
path_prefix = "/one-check"cryKeeper refuses to start while secret_key still uses the published placeholder value.
The example above assumes one trusted nginx hop in a Docker-style private network. If your reverse proxy uses a different source range or multiple hops, adjust trusted_proxy_hops and trusted_proxy_cidrs accordingly.
Start from config.example.toml for the full TOML structure.
If you prefer environment variables, use the names documented in .env.example. Example:
export CRYKEEPER_SECRET_KEY=change-me-in-production
export CRYKEEPER_VERIFICATION_MODE=dummy
export CRYKEEPER_PATH_PREFIX=/crykeeper
export CRYKEEPER_TRUSTED_PROXY_HOPS=1
export CRYKEEPER_TRUSTED_PROXY_CIDRS=172.16.0.0/12Non-empty environment variables override only the shared defaults. They do not create or override individual [[website]] entries.
footer_html is optional. If you leave it unset, the challenge page shows the built-in cryKeeper footer by default. Set it to a custom trusted HTML string or a locale-keyed table to override that default per host. Set it to - to hide the challenge footer entirely. The internal dashboard always shows the built-in default footer.
Requests that match any of the following settings are allowed through GET <path_prefix>/check immediately and do not need a valid verification cookie:
skip_routes: bypasses based on the original request path and optional HTTP method.bypass_headers: exact header/value pairs matched against the current request headers. This is suitable for automation clients that can send a dedicated token header themselves. Token values must be at least 32 characters long.bypass_user_agents: Python regexes matched against the currentUser-Agentheader.bypass_ips: client IPs or CIDR ranges matched against the sanitized client address after trusted proxy handling.allow_known_search_engines: enables a built-inUser-Agentmatcher for common search crawlers such as Googlebot, Bingbot, DuckDuckBot, Yahoo Slurp, YandexBot, Baiduspider, Applebot, PetalBot, and SeznamBot.
TOML example:
[crykeeper]
bypass_headers = [
"X-CryKeeper-Token=0123456789abcdef0123456789abcdef", "X-CryKeeper-Token=fedcba9876543210fedcba9876543210",
]
bypass_user_agents = ["^MyMonitoringBot/", "(?i)uptimerobot"]
bypass_ips = ["203.0.113.10", "2001:db8::/32"]
allow_known_search_engines = trueEnvironment variable example:
export CRYKEEPER_BYPASS_HEADERS='X-CryKeeper-Token=0123456789abcdef0123456789abcdef,X-CryKeeper-Token=fedcba9876543210fedcba9876543210'
export CRYKEEPER_BYPASS_USER_AGENTS='^MyMonitoringBot/,(?i)uptimerobot'
export CRYKEEPER_BYPASS_IPS='203.0.113.10,2001:db8::/32'
export CRYKEEPER_ALLOW_KNOWN_SEARCH_ENGINES=trueUse bypass_ips when you want the strongest built-in trust signal. bypass_headers works well for scripts, CI jobs, uptime checks, or other automation clients that can attach a long random token such as X-CryKeeper-Token: ... themselves. Tokens must be at least 32 characters long. Treat them as bearer secrets, not as trustworthy identity claims: anyone who knows a token can bypass the check, so keep tokens random, rotate them when needed, and use them only over HTTPS. If your public edge injects, strips, or rewrites that header, keep that behavior deliberate and consistent. bypass_user_agents and allow_known_search_engines are convenient, but both ultimately rely on a client-controlled header.
cryKeeper stays stateless even when you enable Valkey. The only thing stored in Valkey is rate-limit state; the human-verification cookie remains signed and client-side.
Use the default in-memory rate limiter when you run a single cryKeeper process or a small deployment where per-process limits are acceptable.
Use Valkey when you need shared and consistent rate limits across multiple cryKeeper workers, containers, or hosts. It is especially useful when:
- traffic is distributed across multiple cryKeeper replicas behind a load balancer
- you run multiple worker processes and want one common challenge or verify budget instead of separate budgets per worker
- rate limits should remain effective across process restarts instead of resetting with in-memory state
This also applies to the bundled Docker image: it starts Gunicorn with 2 workers by default unless you override CRYKEEPER_GUNICORN_WORKERS, so Valkey should be configured when you deploy the image with multiple workers and want consistent effective rate limits.
In practice, rate_limit_backend = "auto" plus a configured rate_limit_valkey_url is the simplest production setup when you need distributed rate limiting.
- Set a long random value for
secret_keyorCRYKEEPER_SECRET_KEY; cryKeeper refuses to start with the published placeholder default - Serve cryKeeper behind HTTPS and set
human_cookie_secure = truein production - Keep the reverse proxy prefix aligned with
path_prefix - Set
trusted_proxy_hopsandtrusted_proxy_cidrsto match your real proxy chain whenever a reverse proxy supplies forwarded headers - Decide explicitly whether trusted crawlers, monitoring systems, or upstreams should bypass the human check via
bypass_ips,bypass_headers,bypass_user_agents, orallow_known_search_engines - If you enable
bypass_headers, use long random tokens with at least 32 characters over HTTPS and keep any proxy-side stripping, forwarding, or injection deliberate and consistent - If you run multiple cryKeeper workers or replicas, configure Valkey for shared rate limiting via
rate_limit_backendandrate_limit_valkey_url; this includes the default Docker image, which starts Gunicorn with 2 workers - Expose
/_crykeeper/metricsand/_crykeeper/dashboardonly through a dedicated protected host, VPN, or other internal-only reverse-proxy path - In Cap mode, set
cap_public_base_url,cap_site_key, andcap_secret_key, pluscap_internal_base_urlif server-side verification should use a different route - In hCaptcha mode, set
hcaptcha_site_keyandhcaptcha_secret_key;hcaptcha_script_urlandhcaptcha_verify_urldefault to the official endpoints - In ALTCHA mode, set at least
altcha_hmac_secret;altcha_hmac_key_secretis optional andaltcha_script_urldefaults to the cryKeeper-hosted bundled ALTCHA v3 widget withPBKDF2/SHA-256as the default challenge algorithm - Mount your TOML configuration read-only in containers, or manage env-vars explicitly
cryKeeper exposes a health endpoint at <path_prefix>/healthz.
Examples:
- Default path prefix:
/crykeeper/healthz - Minimal cryKeeper-only example from the installation snippet:
http://127.0.0.1:5000/crykeeper/healthz - Checked-in local example stack:
https://localhost:8443/crykeeper/healthz
If you override path_prefix, the healthcheck path changes with it.
cryKeeper selects the UI language from the browser's Accept-Language header.
To adjust existing languages:
- Edit the JSON files in app/i18n
- Keep app/i18n/en.json complete, because English is the required fallback catalog
- Additional language files may be partial; missing keys fall back to English
To add a new language, add a new JSON file such as fr.json with translated keys.
If you run cryKeeper in Docker, you can also mount custom translation files into /app/app/i18n/, for example:
services:
crykeeper:
volumes:
- ./translations/fr.json:/app/app/i18n/fr.json:roTranslation catalogs are discovered at startup, so restart the container after adding or changing language files.
For local end-to-end testing, use the checked-in docker-compose.yml. It builds cryKeeper from the current source tree and starts nginx, the demo backend, a local CAP container, and Valkey.
cp config.example.toml config.toml
# optional: cp .env.example .env
mkdir -p nginx/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout nginx/certs/demo.key \
-out nginx/certs/demo.crt \
-config nginx/demo-cert.cnf
docker compose up --buildThen open:
https://localhost:8443/cap/
The first browser visit will show a certificate warning because the demo uses a self-signed certificate. Accept it once for local testing.
Use the CRYKEEPER_CAP_ADMIN_KEY provided in your .env file if you want a custom local Cap admin key; otherwise the example stack uses the documented demo placeholder. Then log into the Cap admin interface and create a site. Enter the site key as cap_site_key and the secret key as cap_secret_key to your config.toml.
Then restart the cryKeeper container (or the whole stack) so it picks up the new Cap configuration:
docker compose restart crykeeperThe checked-in demo config keeps the Cap demos on localhost and cap.localhost, adds a fully protected Dummy host, keeps the dedicated provider-specific hosts for Dummy, ALTCHA, and hCaptcha, and exposes the internal dashboard on a separate demo hostname:
https://localhost:8443/protected/uses Cap through the local/capservicehttps://cap.localhost:8443/protected/uses Cap through/cap-checkhttps://full.localhost:8443/uses Dummy mode through/full-checkand protects every backend pathhttps://dummy.localhost:8443/protected/uses Dummy mode through/dummy-checkhttps://altcha.localhost:8443/protected/uses ALTCHA with challenges generated by the cryKeeper itselfhttps://hcaptcha.localhost:8443/protected/uses hCaptcha with the public test keys and therefore requires internet accesshttps://dashboard.localhost:8443/serves the internal observability dashboard andhttps://dashboard.localhost:8443/metricsexposes the aggregated Prometheus metrics
Then open:
https://localhost:8443/https://full.localhost:8443/https://cap.localhost:8443/protected/https://dummy.localhost:8443/protected/https://altcha.localhost:8443/protected/https://hcaptcha.localhost:8443/protected/https://dashboard.localhost:8443/https://localhost:8443/protected/skip-route/
- Challenge redirects usually fail when nginx and cryKeeper do not use the same
path_prefix - Repeated challenges after a successful solve usually point to cookie, HTTPS, host, or proxy-header mismatches
- If
ip-user-agentbinding is unstable, verifytrusted_proxy_hops, optionaltrusted_proxy_cidrs, and your forwarded-header setup - If a custom translation does not appear, check the JSON filename, keep English complete, and restart the container after adding or changing files
- If
skip_routesdoes not bypass the challenge, verify the regex against the original request path and make sure nginx forwardsX-Original-Method - If
bypass_headersdoes not match as expected, verify the exact header name and value plus whether your reverse proxy strips, forwards, or injects that header as intended - If
bypass_ipsdoes not match as expected, verifytrusted_proxy_hops,trusted_proxy_cidrs, and which client IP cryKeeper actually sees after proxy sanitization
Copyright (c) 2026 cryeffect Media Group https://crymg.de, Peter Müller peter@crycode.de
See LICENSE for the full license text.
AI was used as an assistive tool for parts of the code, tests and documentation. The project idea, architecture, implementation decisions, testing, review and release responsibility were carried out by the maintainers before anything was committed.
cryKeeper vendors the ALTCHA browser bundle at app/static/vendor/altcha.min.js so ALTCHA mode works without a mandatory external CDN dependency.
- Upstream project: ALTCHA
- Bundled artifact: file
dist/main/altcha.min.js - Upstream license: MIT
When updating the bundled file, replace it from a reviewed ALTCHA release, prefer a pinned source URL such as https://cdn.jsdelivr.net/npm/altcha@<version>/dist/main/altcha.min.js, and rerun the focused ALTCHA tests afterwards.

