Production-ready Caddy HTTP middleware for ALTCHA captcha verification. Protects web applications from automated abuse using cryptographic proof-of-work challenges.
ALTCHA is entirely self-contained within your Caddy server. No separate ALTCHA service or external API calls are required. The module:
- Generates cryptographic challenges via
/api/altcha/challengeendpoint - Verifies solutions submitted by clients
- Manages sessions using Memory, Redis, or File storage backends
The only external dependency is the ALTCHA JavaScript widget (can be self-hosted). All cryptographic operations happen within your Caddy module.
Core Functionality:
- Cryptographic protection using HMAC-signed proof-of-work challenges
- Sub-10ms verification latency
- Multiple storage backends (Redis, Memory, File)
- POST data preservation across verification redirects
- Automatic return URI preservation (users return to originally requested page)
Security & Protection:
- Rate limiting per IP (DoS protection)
- CORS origin validation (resource protection)
- Constant-time HMAC comparison (timing attack prevention)
- Secure session management with 256-bit entropy
- Safe cookie defaults (Secure, HttpOnly, SameSite)
- Input validation and payload size limits
Integration & Deployment:
- Coraza WAF integration support
- Production-ready with comprehensive error handling
- Distributed deployments via Redis backend
- Environment variable configuration
- JSON and Caddyfile configuration support
- Docker-ready with examples
Build Caddy with the ALTCHA module using a specific Caddy version (recommended for stability):
xcaddy build v2.8.4 --with github.com/stardothosting/caddy-altchaOr build with the latest Caddy:
xcaddy build --with github.com/stardothosting/caddy-altchaOr using Docker:
cd examples
docker-compose upNote: Using a specific Caddy version (e.g., v2.8.4) prevents compatibility issues with newer Go versions and ensures build reproducibility.
Create a Caddyfile:
{
order altcha_verify before reverse_proxy
}
example.com {
# Challenge generation endpoint
route /api/altcha/challenge {
altcha_challenge {
hmac_key {env.ALTCHA_HMAC_KEY}
algorithm SHA-256
max_number 100000
expires 5m
}
}
# Challenge UI page
route /captcha {
root * /var/www/altcha
file_server
}
# Protect specific routes
@protected {
path /login /register /api/*
}
# IMPORTANT: defining a matcher alone does nothing.
# You must attach it to a handler (altcha_verify or handle block),
# otherwise requests will fall through to reverse_proxy.
altcha_verify @protected {
hmac_key {env.ALTCHA_HMAC_KEY}
session_backend memory://
challenge_redirect /captcha
preserve_post_data true
}
reverse_proxy localhost:8080
}Set your HMAC key (minimum 32 characters):
export ALTCHA_HMAC_KEY="your-secret-key-min-32-chars-long"Copy the example HTML to your web root:
cp examples/www/index.html /var/www/altcha/index.htmlCreate an HTML page at /var/www/altcha/index.html with the ALTCHA widget. This page will be shown to users when they need to complete a challenge.
<!DOCTYPE html>
<html>
<head>
<title>Verification Required</title>
<script type="module" src="https://cdn.jsdelivr.net/npm/altcha@1.0.5/dist/altcha.min.js"></script>
</head>
<body>
<h1>Verification Required</h1>
<p>Please complete the challenge below.</p>
<altcha-widget
name="altcha"
challengeurl="/api/altcha/challenge"
hidefooter="false">
</altcha-widget>
<script>
const widget = document.querySelector('altcha-widget');
widget.addEventListener('statechange', (ev) => {
if (ev.detail.state === 'verified') {
const payload = ev.detail.payload;
const urlParams = new URLSearchParams(window.location.search);
const session = urlParams.get('session');
const returnTo = urlParams.get('return') || '/';
// Build redirect URL with solution
let redirectURL = `${returnTo}?altcha=${encodeURIComponent(payload)}`;
if (session) {
redirectURL += `&session=${encodeURIComponent(session)}`;
}
// Redirect back to original protected page
// The same altcha_verify handler will now verify the solution
window.location.href = redirectURL;
}
});
</script>
</body>
</html>A complete, styled example is available in examples/www/index.html. Key features:
- Modern responsive design with Poppins font
- Cache control headers to prevent stale page loads
- Status messages for user feedback (solving, verified, error)
- Logo support with embedded SVG
- Mobile-friendly layout
Copy the example to your web root:
cp examples/www/index.html /var/www/altcha/index.htmlOr view the full source: examples/www/index.html
You MUST include this redirect logic in your challenge page to properly handle URLs with existing query parameters (WordPress login, Drupal, Magento, Laravel, etc.):
/**
* Build a safe redirect URL that properly handles existing query parameters.
* Works with any CMS: WordPress, Drupal, Magento, Laravel, etc.
*/
function buildRedirectURL(returnTo, payload, session) {
try {
// Parse the return URL using the URL API
// This handles all edge cases: existing query params, fragments, encoding
const url = new URL(returnTo, window.location.origin);
// Security: Only allow same-origin redirects to prevent open redirect attacks
if (url.origin !== window.location.origin) {
console.error('Security: Cross-origin redirect blocked:', returnTo);
return '/?altcha=' + encodeURIComponent(payload);
}
// Use searchParams.set() to properly append/merge query parameters
// This automatically uses & if params exist, or ? if not
url.searchParams.set('altcha', payload);
if (session) {
url.searchParams.set('session', session);
}
// Return relative URL (pathname + search + hash) for same-origin redirect
return url.pathname + url.search + url.hash;
} catch (e) {
// Fallback for malformed URLs - redirect to root with payload
console.error('Invalid return URL, using fallback:', returnTo, e);
return '/?altcha=' + encodeURIComponent(payload);
}
}
// Usage in your statechange handler:
const urlParams = new URLSearchParams(window.location.search);
const returnTo = urlParams.get('return') || '/';
const session = urlParams.get('session');
// After widget verification succeeds
const redirectURL = buildRedirectURL(returnTo, payload, session);
window.location.href = redirectURL;Why the URL API is required:
When protecting pages with existing query parameters (like WordPress login):
- Original URL:
/wp-login.php?redirect_to=...&reauth=1 - Bad (string concat):
/wp-login.php?redirect_to=...&reauth=1?altcha=...(broken - double?) - Good (URL API):
/wp-login.php?redirect_to=...&reauth=1&altcha=...(correct)
The URL API automatically uses & when query params exist, or ? when they don't.
Flow explanation:
- User requests
/admin/settings(protected byaltcha_verify) - Module redirects to
/captcha?session=xyz&return=/admin/settings - User solves challenge
- JavaScript reads
returnparameter and redirects to/admin/settings?altcha=payload - Same handler on
/admin/settingsverifies solution and lets user through
Without proper URL handling, users will experience redirect loops when the protected page has query parameters.
Add visual code input on top of proof-of-work:
<script type="module" src="https://cdn.jsdelivr.net/npm/altcha@1.0.5/dist/altcha.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/altcha@1.0.5/obfuscation"></script>
<altcha-widget
name="altcha"
challengeurl="/api/altcha/challenge"
plugins="obfuscation"
hidefooter="false">
</altcha-widget>Then enable in your handler config: code_challenge: true
- Pin the widget version (e.g.,
@1.0.5) to prevent breaking changes from automatic updates - Do NOT add
verifyurlattribute - that's for ALTCHA Sentinel cloud service, not self-hosted - The widget solves challenges client-side - it generates a proof-of-work solution in the browser
- Cache control headers are recommended to prevent users from seeing stale challenge pages
Generates cryptographic challenges for clients to solve.
altcha_challenge {
hmac_key <string> # Required: HMAC secret key (min 32 chars recommended)
algorithm <string> # SHA-256, SHA-384, or SHA-512 (default: SHA-256)
max_number <int> # Maximum random number (default: 100000)
# Recommended: 1000000 (~200ms solve time)
expires <duration> # Challenge validity (default: 5m)
salt_length <int> # Salt length in bytes (default: 12)
code_challenge <bool> # Enable visual code challenges (default: false)
code_length <int> # Visual code length (default: 6, requires code_challenge: true)
# Security (Optional)
rate_limit_requests <int> # Max requests per window (0 = disabled, default: 0)
rate_limit_window <duration># Rate limit window (default: 1m)
}Difficulty Tuning (max_number):
100000- Very fast (~20ms solve time), light protection1000000- Recommended (~200ms solve time), good balance10000000- High security (~2s solve time), may frustrate users
Code Challenges (Optional):
- Set
code_challenge: trueto add visual code input on top of proof-of-work - Similar to traditional CAPTCHAs but with computational challenge first
- Requires ALTCHA obfuscation plugin in your HTML (see Widget Configuration)
Rate Limiting (Optional):
rate_limit_requests: 10- Max 10 challenges per IP per windowrate_limit_window: 1m- Reset window duration- Prevents DoS attacks via excessive challenge generation
- Returns HTTP 429 (Too Many Requests) when limit exceeded
Verifies ALTCHA solutions and manages verification state.
altcha_verify [<matcher>] {
hmac_key <string> # Required: Same key as altcha_challenge
session_backend <uri> # Session storage (default: memory://)
session_ttl <duration> # Session validity (default: 5m)
# Verification cookie configuration
verified_cookie_name <string> # Cookie name (default: altcha_verified)
verified_cookie_ttl <int> # Cookie TTL in seconds (default: 3600)
verified_cookie_secure <bool> # Secure flag (default: true)
verified_cookie_http_only <bool> # HttpOnly flag (default: true)
verified_cookie_same_site <string> # SameSite: Strict, Lax, None (default: Strict)
verified_cookie_path <string> # Cookie path (default: /)
verified_cookie_domain <string> # Cookie domain (optional)
# Behavior
challenge_redirect <path> # Challenge page path (default: /captcha)
preserve_post_data <bool> # Save POST data across redirects (default: false)
verify_field_name <string> # Form field with solution (default: altcha)
# Integrations
coraza_env_var <string> # Environment variable set by Coraza
}Provides a dedicated endpoint for widget-based server-side verification. This is optional and only needed if you're using the ALTCHA widget with verifyurl attribute (typically for ALTCHA Sentinel cloud service).
altcha_verify_solution {
hmac_key <string> # Required: Same key as altcha_challenge
allowed_origins <string...> # Allowed CORS origins (optional, restricts widget usage)
}CORS Security:
- By default (no
allowed_origins), allows requests from any origin (backward compatible) - Configure
allowed_originsto restrict which domains can use your challenges - Example:
allowed_origins https://yourdomain.com https://test.yourdomain.com - Prevents unauthorized sites from consuming your server resources
{
"handler": "altcha_challenge",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"algorithm": "SHA-256",
"max_number": 1000000,
"expires": "5m",
"rate_limit_requests": 10,
"rate_limit_window": "1m"
}{
"handler": "altcha_verify",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"session_backend": "redis://localhost:6379",
"session_ttl": "5m",
"verified_cookie_secure": true,
"verified_cookie_http_only": true,
"verified_cookie_same_site": "Strict"
}# Generate cryptographically secure 256-bit key
openssl rand -base64 32
# Store in environment variable
export ALTCHA_HMAC_KEY="your-generated-key-here"Key Requirements:
- Minimum 32 bytes (256 bits) for strong security
- Use
crypto/randor similar CSPRNG for generation - Never hardcode keys in configuration files
- Rotate keys periodically (implement key rotation strategy)
- Store securely (environment variables, secrets manager, or vault)
Protect against resource exhaustion attacks:
altcha_challenge {
hmac_key {env.ALTCHA_HMAC_KEY}
max_number 1000000
rate_limit_requests 10 # Max 10 challenges per IP
rate_limit_window 1m # Per 60-second window
}Recommendations:
- Development: Disable rate limiting (
rate_limit_requests: 0) - Production: Start with
10-20 requests/minute - High-traffic: Increase to
50-100 requests/minuteif needed - Monitor
429 Too Many Requestsresponses to tune appropriately
Prevent unauthorized widget usage:
{
"handler": "altcha_verify_solution",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"allowed_origins": [
"https://yourdomain.com",
"https://www.yourdomain.com"
]
}Warning: Without allowed_origins, any website can embed your challenge widget and consume your server resources.
altcha_verify {
session_backend redis://localhost:6379
session_ttl 5m # Short TTL reduces exposure window
verified_cookie_secure true # HTTPS only
verified_cookie_http_only true # Prevent XSS access
verified_cookie_same_site Strict # Prevent CSRF
}Production Backend Recommendations:
- Memory: Development only (sessions lost on restart)
- File: Simple deployments, single server
- Redis: Production, distributed deployments, high availability
In-memory storage with LRU eviction:
session_backend memory://Distributed storage with connection pooling:
session_backend redis://localhost:6379/0
session_backend redis://:password@localhost:6379/0File-based storage:
session_backend file:///var/lib/caddy/altcha@auth {
path /login /register /reset-password
}
altcha_verify @auth {
hmac_key {env.ALTCHA_HMAC_KEY}
session_backend redis://localhost:6379/0
challenge_redirect /captcha
}@api_post {
path /api/*
method POST
}
altcha_verify @api_post {
hmac_key {env.ALTCHA_HMAC_KEY}
preserve_post_data true
challenge_redirect /captcha
}@external {
not remote_ip 10.0.0.0/8 192.168.0.0/16
}
altcha_verify @external {
hmac_key {env.ALTCHA_HMAC_KEY}
challenge_redirect /captcha
}For production deployments using JSON configuration (common in WAF-as-a-service setups):
{
"apps": {
"http": {
"servers": {
"main": {
"listen": [":443"],
"routes": [
{
"match": [{"host": ["test.example.com"]}],
"handle": [{
"handler": "subroute",
"routes": [
{
"match": [{
"path_regexp": {
"pattern": "^/api/altcha/challenge/?$"
}
}],
"handle": [{
"handler": "altcha_challenge",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"algorithm": "SHA-256",
"max_number": 1000000,
"expires": "5m"
}],
"terminal": true
},
{
"match": [{
"path_regexp": {
"pattern": "^/captcha/?$"
}
}],
"handle": [
{
"handler": "rewrite",
"uri": "/index.html"
},
{
"handler": "file_server",
"root": "/var/www/altcha"
}
],
"terminal": true
},
{
"match": [{
"path_regexp": {
"pattern": "^/protected/?$"
}
}],
"handle": [
{
"handler": "altcha_verify",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"session_backend": "memory://",
"challenge_redirect": "/captcha",
"preserve_post_data": true
},
{
"handler": "static_response",
"body": "{\"success\": true, \"message\": \"Verification passed\"}"
}
]
}
]
}]
}
]
}
}
}
}
}Important JSON Configuration Notes:
- Use
path_regexpwith pattern^/path/?$to handle both/pathand/path/(trailing slash) - Set
terminal: truefor routes that should not fall through - Use
rewritebeforefile_serverto serve index.html at /captcha - Recommended
max_number: 1000000for ~200ms solve time
For sites behind Coraza WAF, add ALTCHA routes to existing configurations:
{
"@id": "example.com",
"match": [{"host": ["example.com"]}],
"handle": [{
"handler": "subroute",
"routes": [
{
"@comment": "Coraza WAF - runs first",
"handle": [{
"handler": "waf",
"directives": "SecComponentSignature \"WAF | example.com\"\nSecRuleEngine On\n..."
}]
},
{
"@comment": "ALTCHA Challenge Endpoint",
"match": [{"path": ["/api/altcha/challenge"]}],
"handle": [{
"handler": "altcha_challenge",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"algorithm": "SHA-256",
"max_number": 100000,
"expires": "5m"
}]
},
{
"@comment": "ALTCHA Challenge UI",
"match": [{"path": ["/captcha"]}],
"handle": [{
"handler": "file_server",
"root": "/var/www/altcha"
}]
},
{
"@comment": "Protected routes requiring ALTCHA",
"match": [{"path": ["/login", "/register", "/api/submit"]}],
"handle": [
{
"handler": "altcha_verify",
"hmac_key": "{env.ALTCHA_HMAC_KEY}",
"session_backend": "redis://localhost:6379/1",
"session_ttl": "5m",
"verified_cookie_name": "altcha_verified",
"verified_cookie_ttl": 3600,
"challenge_redirect": "/captcha",
"preserve_post_data": true,
"coraza_env_var": "altcha_required"
},
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "backend:443"}]
}
]
},
{
"@comment": "Unprotected routes bypass ALTCHA",
"handle": [{
"handler": "reverse_proxy",
"upstreams": [{"dial": "backend:443"}]
}]
}
]
}],
"terminal": true
}- Generate HMAC key:
openssl rand -base64 32- Create challenge UI directory:
mkdir -p /var/www/altcha- Create
/var/www/altcha/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verification Required</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 500px;
text-align: center;
}
h1 { margin-top: 0; color: #333; }
p { color: #666; line-height: 1.6; }
#altcha-widget { margin: 2rem 0; }
.error { color: #d32f2f; margin-top: 1rem; }
.loading { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Verification Required</h1>
<p>Please complete the challenge below to continue.</p>
<form id="altcha-form" method="POST">
<div id="altcha-widget"></div>
<div id="status" class="loading">Loading challenge...</div>
</form>
</div>
<script type="module">
import 'altcha' from 'https://cdn.jsdelivr.net/npm/altcha/dist/altcha.min.js';
const widget = document.createElement('altcha-widget');
widget.setAttribute('challengeurl', '/api/altcha/challenge');
widget.setAttribute('auto', 'onload');
document.getElementById('altcha-widget').appendChild(widget);
document.getElementById('status').textContent = 'Solving challenge...';
widget.addEventListener('statechange', (ev) => {
const state = ev.detail.state;
const status = document.getElementById('status');
if (state === 'verified') {
status.textContent = 'Verified! Redirecting...';
status.style.color = '#2e7d32';
const payload = ev.detail.payload;
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session');
const returnTo = urlParams.get('return') || '/'; // Where to redirect after verification
// Build redirect URL back to original protected page
let redirectUrl = `${returnTo}?altcha=${encodeURIComponent(payload)}`;
if (sessionId) {
redirectUrl += `&session=${encodeURIComponent(sessionId)}`;
}
setTimeout(() => window.location.href = redirectUrl, 500);
} else if (state === 'error') {
status.textContent = 'Verification failed. Please refresh and try again.';
status.className = 'error';
}
});
</script>
</body>
</html>- Set environment variable:
export ALTCHA_HMAC_KEY="your-generated-key-here"- Build Caddy with ALTCHA module:
xcaddy build --with github.com/stardothosting/caddy-altcha \
--with github.com/corazawaf/coraza-caddy/v2- Load configuration:
caddy reload --config /path/to/config.json- Test endpoints:
# Health check
curl https://test.example.com/health
# Challenge generation
curl https://test.example.com/api/altcha/challenge
# Protected route (should redirect)
curl -I https://test.example.com/protectedThe file_server handler automatically serves index.html when a directory is requested. When configured with:
{
"handler": "file_server",
"root": "/var/www/altcha"
}Requesting /captcha will serve /var/www/altcha/index.html.
The HMAC key must be identical in both altcha_challenge and altcha_verify handlers. Options:
- Environment variable (recommended):
"hmac_key": "{env.ALTCHA_HMAC_KEY}"- Hardcoded (less secure, avoid in production):
"hmac_key": "your-actual-key-here"- Per-site keys (for multi-tenant setups):
"hmac_key": "{env.ALTCHA_HMAC_KEY_SITE1}"Use Coraza to analyze requests and only challenge suspicious ones:
{
order coraza_waf before altcha_verify
order altcha_verify before reverse_proxy
}
example.com {
# Coraza analyzes requests
coraza_waf {
directives `
Include /etc/coraza/crs-setup.conf
Include /etc/coraza/rules/*.conf
# Flag suspicious requests
SecRule TX:ANOMALY_SCORE "@ge 5" \
"id:1001,phase:2,pass,setenv:altcha_required=1"
`
}
route /api/altcha/challenge {
altcha_challenge {
hmac_key {env.ALTCHA_HMAC_KEY}
}
}
route /captcha {
root * /var/www/altcha
file_server
}
# Only challenge flagged requests
altcha_verify {
hmac_key {env.ALTCHA_HMAC_KEY}
coraza_env_var altcha_required
session_backend redis://localhost:6379/0
challenge_redirect /captcha
}
reverse_proxy backend:8080
}- Verification latency: Sub-10ms per request
- Session capacity: 10,000+ concurrent sessions (memory backend)
- Redis pooling: MaxIdle 10, MaxActive 100
- Zero blocking operations in hot path
This module has undergone comprehensive security auditing and implements industry best practices for cryptographic operations, session management, and DoS protection.
Cryptographic Security:
- HMAC-SHA256/384/512 signatures for challenge integrity
- Constant-time HMAC comparison to prevent timing attacks
- Cryptographically random session ID generation (256-bit entropy)
- Hex-encoded session IDs (64 characters) for consistent length
Request Protection:
- Rate limiting per IP address (configurable sliding window)
- Payload length validation (4KB max, early rejection)
- Input sanitization for all user-provided data
- Safe redirect validation to prevent open redirect attacks
Session Security:
- Atomic write-rename pattern for file backend (prevents race conditions)
- Server-side session storage (no sensitive data in URLs)
- Configurable session TTL (default 5 minutes)
- One-time session usage for return URI restoration
Cookie Security:
- Secure flag enforced (HTTPS only)
- HttpOnly flag (prevents XSS access)
- SameSite: Strict (prevents CSRF)
- Configurable domain and path restrictions
CORS Protection:
- Configurable origin whitelist for challenge endpoint
- Prevents unauthorized widget embedding
- Protects against resource consumption attacks
Error Handling:
- Generic error messages to clients (no information disclosure)
- Detailed logging internally for debugging
- No payload or sensitive data exposure in responses
- HMAC Keys: Use cryptographically random keys (minimum 32 bytes)
- Rate Limiting: Enable in production (
rate_limit_requests: 10) - CORS Origins: Restrict to your domains (
allowed_origins: ["https://yourdomain.com"]) - Session Backend: Use Redis in production for distributed security
- Cookie Flags: Keep secure defaults enabled
- HTTPS Only: Always use TLS in production
- Key Rotation: Implement periodic HMAC key rotation
- Monitoring: Track 429 (rate limit) responses to tune limits
# Generate a cryptographically secure 256-bit HMAC key
openssl rand -base64 32export ALTCHA_HMAC_KEY="$(openssl rand -base64 32)"All HIGH and MEDIUM severity vulnerabilities identified in November 2025 security audit have been addressed:
- ✅ Session ID predictability eliminated
- ✅ CORS wildcard replaced with origin validation
- ✅ File backend race conditions fixed
- ✅ Error message information disclosure prevented
- ✅ Rate limiting implemented
- ✅ Timing attack vectors mitigated
- ✅ Payload bomb attacks prevented
Check that the challenge endpoint is accessible:
curl https://example.com/api/altcha/challengeCause: The matcher was defined but never attached to altcha_verify, or the handler runs after reverse_proxy.
Fix: Attach the matcher and ensure it runs before reverse_proxy:
# ❌ Wrong: matcher defined but never used
@protected path /login /register /api/*
reverse_proxy localhost:8080
# ✅ Correct: matcher attached to altcha_verify
@protected path /login /register /api/*
altcha_verify @protected {
hmac_key {env.ALTCHA_HMAC_KEY}
session_backend memory://
challenge_redirect /captcha
}
reverse_proxy localhost:8080Ensure HMAC keys match between challenge and verify handlers:
# Both must use the same key
altcha_challenge {
hmac_key {env.ALTCHA_HMAC_KEY}
}
altcha_verify {
hmac_key {env.ALTCHA_HMAC_KEY}
}Enable POST data preservation:
altcha_verify {
preserve_post_data true
}Symptom: After solving the challenge, user lands on root page (/) or previous page instead of intended destination.
Cause: Challenge page JavaScript not reading the return parameter.
Fix: Update your challenge page JavaScript to read the return parameter:
const urlParams = new URLSearchParams(window.location.search);
const returnTo = urlParams.get('return') || '/'; // Read destination
const payload = ev.detail.payload;
const session = urlParams.get('session');
// Redirect to original protected page
let redirectURL = `${returnTo}?altcha=${encodeURIComponent(payload)}`;
if (session) {
redirectURL += `&session=${encodeURIComponent(session)}`;
}
window.location.href = redirectURL;How it works:
- User clicks
/admin/settings(protected) - Handler redirects to
/captcha?session=xyz&return=/admin/settings - User solves challenge
- JavaScript reads
returnparam and redirects to/admin/settings?altcha=payload - Same handler on
/admin/settingsverifies solution and lets user through
Key point: The altcha_verify handler on the protected page handles both the initial redirect AND the verification on return.
The module automatically preserves the original request URI when redirecting users to the challenge page.
How it works:
- User requests
/wp-login.phpwithout verification - Module stores
/wp-login.phpin session backend (secure, server-side) - Module redirects to
/captcha?session=<session-id>&return=/wp-login.php - User solves challenge
- Widget reads
returnparam and redirects to/wp-login.php?altcha=<payload>&session=<session-id> - Module retrieves return URI from session, verifies solution
- Module continues request or redirects to clean URL
Security benefits:
- Return URI stored server-side in session backend (not in URL)
- No URL parameter tampering possible
- Prevents open redirect attacks
- Session is one-time use (deleted after retrieval)
No configuration needed - this behavior is automatic. Sessions are required for this feature (use memory://, file://, or redis:// backend).
Check Redis is running and URI is correct:
redis-cli -u redis://localhost:6379/0 pingIf /captcha works but /captcha/ shows a blank page, your route isn't handling trailing slashes.
Fix: Use path_regexp in JSON config:
{
"match": [{
"path_regexp": {
"pattern": "^/captcha/?$"
}
}]
}Or in Caddyfile, use wildcards:
route /captcha* {
rewrite * /index.html
root * /var/www/altcha
file_server
}This usually indicates a protocol mismatch. Check:
# 1. Verify challenge endpoint returns correct JSON
curl https://example.com/api/altcha/challenge | jq
# Expected fields:
# - algorithm (string)
# - challenge (string - hex hash)
# - maxNumber (int - MUST be camelCase, not maxnumber!)
# - salt (string - hex)
# - signature (string - HMAC of challenge only)
# 2. Check browser console for errors
# 3. Verify widget is NOT using verifyurl attribute (self-hosted mode)
# 4. Hard refresh browser: Ctrl+Shift+Rgit clone https://github.com/stardothosting/caddy-altcha.git
cd caddy-altcha
go mod download
xcaddy build v2.8.4 --with github.com/stardothosting/caddy-altcha=.# Run all tests
make test
# Or run individually:
go test ./...
go test -race ./...
go test -bench=. -benchmemcd examples
export ALTCHA_HMAC_KEY="test-key-min-32-characters-long"
docker-compose upVisit http://localhost/captcha to test the challenge UI.
MIT License - see LICENSE file for details
- ALTCHA - Proof-of-work captcha library
- Caddy - Web server and reverse proxy
- caddy-defender - Module architecture inspiration