Self-hosted, open-source captcha service — no third-party vendor required.
Live demo → ajaykumarvarma.github.io/EasyCaptcha
Try both variants live (CanvasCaptcha works instantly; ServerCaptcha connects to your own instance).
Two ready-to-use variants:
| Variant | How it works | Best for |
|---|---|---|
| ServerCaptcha | Backend generates a distorted PNG image; answer verified server-side in MongoDB | Sign up, sign in, password reset — any high-risk form |
| CanvasCaptcha | Canvas drawn entirely in the browser; no backend needed | Contact forms, newsletter subscribe, low-risk forms |
GET /stats/detailed— every verify call now logs a lightweight{ts, outcome}event to acaptcha_eventscollection (auto-purged afterSTATS_RETENTION_DAYS, default 7 days). The new endpoint returns solve rate, per-rejection-type counts, and the dominant rejection reason over any window from 1–168 hours. RequiresX-API-Key.
- HMAC-SHA256 answer hashing — CAPTCHA answers are stored as HMAC-SHA256 digests (keyed with
API_SECRET_KEY) instead of plaintext. Verification compares hashes. A database dump without the application secret does not expose answers. - Constant-time answer comparison —
hmac.compare_digest()replaces==for comparing hashes, eliminating timing-oracle side channels. - Multi-font random selection — up to 6 distinct bold typefaces (sans-serif, serif, monospace) are selected randomly per character per image. Breaks single-font OCR pattern training.
- Expanded random color palette — 14-color palette (was 6 fixed). Each character picks a color randomly instead of cycling by index. Increases per-image visual entropy.
- Paste blocking —
onPastehandlers in both React components and the demo page rejectCtrl+V/ right-click paste. Bots paste; real humans type.
- Minimum solve time — answers arriving in under
CAPTCHA_MIN_SOLVE_MSms (default 1500) are automatically rejected. Automated solvers answer in < 50 ms; humans take ≥ 2 s. Zero false positives for real users. - Honeypot hidden field —
ServerCaptchaincludes a CSS-hiddenname="website"input. Bots fill it; humans never see it. Any non-empty value triggers instant rejection before any DB lookup. - Enhanced image distortion — 180 background dots (was 130), arc noise arcs over characters, variable character spacing, and 8 foreground interference lines (was 5). Significantly harder for batch OCR.
- Strict case-sensitive comparison — both React components and the demo page now compare answers exactly as displayed (mixed upper/lower/digit).
- Character progress dots — filling dots below the input show how many characters have been typed (●●●○○ style). Available in all three frontends.
- Auto-validate — canvas variant auto-submits 700 ms after the last character is typed.
- New error code —
too_fastinVerifyResponse.error_codewhen timing check fails. - Per-request length randomisation —
CAPTCHA_LENGTH_MIN=4/CAPTCHA_LENGTH_MAX=6vary the challenge length on every request. Makes length-based pattern attacks impossible.CanvasCaptchaacceptsminLength/maxLengthprops; the demo page usesCANVAS_MIN_LEN/CANVAS_MAX_LENJS constants. - HTTPS/TLS production guide — new README section with Caddy (automatic TLS), nginx + Certbot, Docker Compose nginx, and forwarded-IP/IP-binding notes.
- MongoDB authentication with dedicated least-privilege
captcha_svcuser. - IP binding:
ENFORCE_IP_BINDING=trueties each token to the requesting IP. - Audio CAPTCHA:
GET /captcha/audio/{token_id}viaespeak-ng(WCAG 2.1 SC 1.1.1).
- Wave distortion + foreground noise for OCR resistance.
- Single-use tokens (consumed on any attempt, not just correct ones).
- Mixed-case character pool (upper + lower + digits, no ambiguous glyphs).
- Per-IP sliding-window rate limiter on all three endpoints.
- How it works
- Repository layout
- Live demo page
- Quick start — Docker
- Manual setup
- Configuration reference
- Step-by-step integration guide
- Backend verification examples
- API reference
- Running the tests
- Security notes
- Troubleshooting
- FAQ
- Contributing
- License
Browser Your App Backend EasyCaptcha Service
| | |
|--- GET /captcha -------------------------------------> |
|<-- { token_id, image_b64, |
| captcha_length } ------------------------------ |
| | |
| (user sees image, types) | |
| | |
|--- POST /your-signup ------> | |
| { email, password, | |
| captcha_token_id, | |
| captcha_answer } | |
| |--- POST /captcha/verify ->|
| | { token_id, answer } |
| |<-- { valid: true } ------- |
| | |
| | (create account) |
|<-- { success } ------------- | |
Key points:
- The browser never sends the answer directly to EasyCaptcha — your backend does.
- Each token is single-use and expires after 5 minutes (configurable).
- The correct code is never sent to the browser — only the image.
- The API response includes
captcha_lengthso the React components automatically adapt when you changeCAPTCHA_LENGTHin the backend config.
Browser
|
| (page loads — canvas draws random characters)
| (user reads and types the code)
| (JS validates locally before submit)
|
|--- POST /contact ---------> Your App Backend
{ name, email, message }
No EasyCaptcha service needed. The check happens entirely in the browser.
easycaptcha/
├── backend/
│ ├── captcha_service.py — Complete FastAPI service (single file, runs standalone)
│ ├── requirements.txt — Python dependencies
│ ├── requirements-dev.txt — Test dependencies
│ ├── .env.example — Copy to .env and fill in values
│ ├── Dockerfile — Python + espeak-ng environment
│ ├── pytest.ini — pytest-asyncio configuration
│ ├── test_captcha.py — Automated tests (79 unit tests, 9 integration/audio skipped)
│ └── conftest.py — pytest configuration
├── frontend/
│ ├── ServerCaptcha.jsx — React component (server-side variant, v1.3.0)
│ └── CanvasCaptcha.jsx — React component (canvas / client-side variant, v1.3.0)
├── docker/
│ ├── docker-compose.yml — One-command local setup (service + MongoDB with auth)
│ ├── mongo-init.js — Creates dedicated captcha_svc MongoDB user (least privilege)
│ └── .env.example — Docker secrets template
├── docs/
│ └── index.html — GitHub Pages live demo (both variants, v1.3.0)
├── README.md
└── LICENSE — MIT
The docs/ folder contains a fully self-contained demo page you can host for free on GitHub Pages.
- Push this repository to GitHub (repo name:
easycaptcha). - Go to Settings → Pages.
- Under "Build and deployment", set Source → Deploy from a branch.
- Set Branch →
mainand Folder →/docs. - Click Save — the demo is live at:
https://ajaykumarvarma.github.io/EasyCaptcha
Replace your-username with ajaykumarvarma (already configured).
| Section | Works without a backend? |
|---|---|
| CanvasCaptcha tab | Yes — loads instantly, validates in-browser |
| ServerCaptcha tab | No — enter your backend URL (e.g. http://localhost:8080) and click Load |
The ServerCaptcha tab is intentionally designed for developers to point at their own running instance, so the backend verification flow can be verified end-to-end.
- Docker Desktop (includes Docker Compose)
# 1. Clone / download this repository
git clone https://github.com/ajaykumarvarma/EasyCaptcha.git
cd easycaptcha
# 2. Set a real API secret before starting
# Open docker/docker-compose.yml and replace the placeholder value for API_SECRET_KEY.
# Generate a strong key with:
python -c "import secrets; print(secrets.token_hex(32))"
# 3. Start the service — MongoDB is included, nothing else to install
docker compose -f docker/docker-compose.yml up --buildThe service is now running at http://localhost:8080.
Open http://localhost:8080/docs in your browser to explore the interactive API.
Test it immediately with curl:
# Generate a captcha — returns token_id, base64 image, and captcha_length
curl http://localhost:8080/captcha- Python 3.9 or newer
- MongoDB 6 or newer — local download or MongoDB Atlas (free tier)
cd backend
# Copy and fill in the env file
cp .env.example .env
# Edit .env — set MONGODB_URL and API_SECRET_KEY at minimum
# Install Python dependencies
pip install -r requirements.txt
# Start the service
uvicorn captcha_service:app --host 0.0.0.0 --port 8080 --reloadService is live at http://localhost:8080
Interactive API docs: http://localhost:8080/docs
Alternative docs (ReDoc): http://localhost:8080/redoc
cp frontend/ServerCaptcha.jsx your-project/src/components/
cp frontend/CanvasCaptcha.jsx your-project/src/components/No npm install needed — the components have zero external dependencies.
All settings live in backend/.env (or as Docker environment variables).
| Variable | Required | Default | Description |
|---|---|---|---|
MONGODB_URL |
Yes | — | MongoDB connection string |
API_SECRET_KEY |
Yes | — | Secret key your backend uses to call /captcha/verify and /stats |
DB_NAME |
No | easycaptcha |
MongoDB database name |
ALLOWED_ORIGINS |
No | * |
Comma-separated CORS-allowed origins |
TOKEN_TTL_MINUTES |
No | 5 |
Minutes before an unused token auto-expires |
RATE_LIMIT_PER_MIN |
No | 15 |
Max GET /captcha calls per IP per minute |
VERIFY_LIMIT_PER_MIN |
No | 60 |
Max POST /captcha/verify calls per IP per minute |
AUDIO_LIMIT_PER_MIN |
No | 20 |
Max GET /captcha/audio/{id} calls per IP per minute |
CAPTCHA_LENGTH |
No | 5 |
Fixed length when min/max not set (kept for backward compat) |
CAPTCHA_LENGTH_MIN |
No | CAPTCHA_LENGTH |
Minimum challenge length for per-request randomisation |
CAPTCHA_LENGTH_MAX |
No | CAPTCHA_LENGTH |
Maximum challenge length for per-request randomisation |
CAPTCHA_MIN_SOLVE_MS |
No | 1500 |
Minimum ms between token creation and verify (0 = disabled) |
ENFORCE_IP_BINDING |
No | false |
Reject verify if client_ip doesn't match token origin IP |
LOG_LEVEL |
No | INFO |
DEBUG / INFO / WARNING / ERROR |
Example .env for production:
MONGODB_URL=mongodb+srv://user:pass@cluster.mongodb.net
API_SECRET_KEY=a4f8c2e1d9b3a7f0c5e2d8b4a1f7c3e6d9b2a8f5c1e4d7b0a3f6c9e2d5b8a1
DB_NAME=easycaptcha
ALLOWED_ORIGINS=https://yourdomain.com
TOKEN_TTL_MINUTES=5
RATE_LIMIT_PER_MIN=15
CAPTCHA_LENGTH=5
CAPTCHA_LENGTH_MIN=4
CAPTCHA_LENGTH_MAX=6
CAPTCHA_MIN_SOLVE_MS=1500
ENFORCE_IP_BINDING=falseTip — changing
CAPTCHA_LENGTH: The API response always includescaptcha_lengthas a field, so theServerCaptchaReact component adapts automatically without any prop changes. ForCanvasCaptchapass thelengthprop (e.g.<CanvasCaptcha length={6} />).
lengthprop (e.g.<CanvasCaptcha length={6} />).
Running EasyCaptcha without TLS exposes the X-API-Key header and active token IDs in plain text — treat HTTPS as mandatory, not optional.
Choose the proxy that fits your stack:
Caddy obtains and renews Let's Encrypt certificates with zero configuration.
Install (Debian/Ubuntu):
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy/etc/caddy/Caddyfile:
captcha.yourdomain.com {
# Automatic TLS via Let's Encrypt — no config needed
reverse_proxy localhost:8080 {
header_up X-Real-IP {remote_host}
}
# HSTS — force browsers to use HTTPS for 1 year
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
sudo systemctl enable --now caddy
sudo systemctl reload caddyDone. Caddy handles certificate issuance and auto-renewal.
Install:
sudo apt install -y nginx certbot python3-certbot-nginxObtain a certificate:
sudo certbot --nginx -d captcha.yourdomain.com/etc/nginx/sites-available/easycaptcha:
# Redirect HTTP → HTTPS
server {
listen 80;
server_name captcha.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name captcha.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/captcha.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/captcha.yourdomain.com/privkey.pem;
# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# HSTS — tell browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Pass real client IP to EasyCaptcha (required for IP binding and rate limiting)
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
# Tighten upload body size (captcha images are small)
client_max_body_size 64k;
location / {
proxy_pass http://localhost:8080;
proxy_read_timeout 30s;
}
}sudo ln -s /etc/nginx/sites-available/easycaptcha /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxAuto-renewal is enabled by certbot install — verify with:
sudo certbot renew --dry-runAdd a nginx service to your docker-compose.yml that handles TLS and proxies to the captcha service:
services:
captcha:
build: ../backend
env_file: .env
expose:
- "8080" # internal only — not published to the host
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- captchanginx.conf (same content as Option B, but proxy_pass http://captcha:8080).
When EasyCaptcha is behind a proxy, the real client IP arrives in X-Forwarded-For or X-Real-IP. The service reads the first untrusted IP from X-Forwarded-For automatically.
If you enable
ENFORCE_IP_BINDING=true, make sure your proxy always setsX-Real-IP $remote_addr(nginx) orheader_up X-Real-IP {remote_host}(Caddy) so the token IP matches the verify IP.
# Lock CORS to your exact frontend origin — never use * in production
ALLOWED_ORIGINS=https://yourdomain.com
# Use HTTPS in your service URL when calling from the frontend
# (nothing to set here — just make sure your API_BASE in the frontend points to https://)This section walks through adding EasyCaptcha to every common form on your website.
1a. Copy the components (already done above)
1b. Store the EasyCaptcha URL in your frontend environment
React (.env in your React project root):
REACT_APP_CAPTCHA_URL=http://localhost:8080Next.js (.env.local):
NEXT_PUBLIC_CAPTCHA_URL=http://localhost:80801c. Store the API secret key in your backend environment
# .env in YOUR app backend
CAPTCHA_API_SECRET=same-value-as-API_SECRET_KEY-in-EasyCaptcha
CAPTCHA_SERVICE_URL=http://localhost:8080Never put the API secret in your frontend code or any
.envfile that gets bundled with the browser. It belongs only in your backend.
// src/pages/SignupPage.jsx
import { useState } from 'react';
import ServerCaptcha from '../components/ServerCaptcha';
export default function SignupPage() {
const [form, setForm] = useState({ firstName: '', email: '', password: '' });
const [captcha, setCaptcha] = useState(null); // { tokenId, answer }
const [resetTrigger, setResetTrigger] = useState(0);
const [captchaError, setCaptchaError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState('');
const handleChange = (e) =>
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
// 1. Ensure the user completed the captcha
if (!captcha) {
setCaptchaError('Please complete the security check before continuing.');
return;
}
setSubmitting(true);
setCaptchaError('');
try {
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: form.firstName,
email: form.email,
password: form.password,
// 2. Include the captcha data
captcha_token_id: captcha.tokenId,
captcha_answer: captcha.answer,
}),
});
const data = await res.json();
if (!res.ok) {
setMessage(data.message || 'Signup failed. Please try again.');
// 3. Always refresh the captcha on failure — tokens are single-use
setResetTrigger((t) => t + 1);
} else {
setMessage('Account created! Please check your email to verify.');
}
} catch {
setMessage('Network error — please try again.');
setResetTrigger((t) => t + 1);
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 420, margin: '40px auto' }}>
<h2>Create account</h2>
<label>First name
<input name="firstName" value={form.firstName}
onChange={handleChange} required />
</label>
<label>Email
<input name="email" type="email" value={form.email}
onChange={handleChange} required />
</label>
<label>Password
<input name="password" type="password" value={form.password}
onChange={handleChange} minLength={8} required />
</label>
{/* captcha_length is read from the API automatically — no extra props needed */}
<ServerCaptcha
apiUrl={process.env.REACT_APP_CAPTCHA_URL}
onReady={setCaptcha}
resetTrigger={resetTrigger}
externalError={captchaError}
/>
{message && <p>{message}</p>}
<button type="submit" disabled={submitting}>
{submitting ? 'Creating account…' : 'Sign up'}
</button>
</form>
);
}1. Receive { first_name, email, password, captcha_token_id, captcha_answer }
2. Call EasyCaptcha POST /captcha/verify → { valid: true/false }
3. If valid == false → return 400 "Invalid security code"
4. Proceed with normal signup logic
See Backend verification examples for complete code.
// src/pages/LoginPage.jsx
import { useState } from 'react';
import ServerCaptcha from '../components/ServerCaptcha';
export default function LoginPage() {
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [captcha, setCaptcha] = useState(null);
const [resetTrigger, setResetTrigger] = useState(0);
const [captchaError, setCaptchaError] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!captcha) {
setCaptchaError('Please complete the security check.');
return;
}
setSubmitting(true);
setError('');
setCaptchaError('');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier,
password,
captcha_token_id: captcha.tokenId,
captcha_answer: captcha.answer,
}),
});
if (res.ok) {
const { token } = await res.json();
localStorage.setItem('token', token);
window.location.href = '/dashboard';
} else {
const data = await res.json();
setError(data.message || 'Incorrect credentials.');
// Refresh captcha — tokens are single-use even on wrong answers
setResetTrigger((t) => t + 1);
}
setSubmitting(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '40px auto' }}>
<h2>Sign in</h2>
<label>Email or username
<input value={identifier} onChange={(e) => setIdentifier(e.target.value)}
required autoComplete="username" />
</label>
<label>Password
<input type="password" value={password}
onChange={(e) => setPassword(e.target.value)} required />
</label>
<ServerCaptcha
apiUrl={process.env.REACT_APP_CAPTCHA_URL}
onReady={setCaptcha}
resetTrigger={resetTrigger}
externalError={captchaError}
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}1. Receive { identifier, password, captcha_token_id, captcha_answer }
2. Call EasyCaptcha POST /captcha/verify → { valid: true/false }
3. If valid == false → return 400 "Invalid security code"
4. Look up user, verify password
5. If credentials wrong → return 401 "Incorrect credentials"
6. Return JWT / session cookie
// src/pages/ForgotPasswordPage.jsx
import { useState } from 'react';
import ServerCaptcha from '../components/ServerCaptcha';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [captcha, setCaptcha] = useState(null);
const [resetTrigger, setResetTrigger] = useState(0);
const [captchaError, setCaptchaError] = useState('');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!captcha) {
setCaptchaError('Please complete the security check.');
return;
}
setSubmitting(true);
setCaptchaError('');
await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
captcha_token_id: captcha.tokenId,
captcha_answer: captcha.answer,
}),
});
// Always show the same message — prevents email enumeration
setMessage('If that email is registered, a reset link has been sent.');
setResetTrigger((t) => t + 1);
setSubmitting(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '40px auto' }}>
<h2>Reset your password</h2>
<p>Enter your email and we will send you a reset link.</p>
<label>Email address
<input type="email" value={email}
onChange={(e) => setEmail(e.target.value)} required />
</label>
<ServerCaptcha
apiUrl={process.env.REACT_APP_CAPTCHA_URL}
onReady={setCaptcha}
resetTrigger={resetTrigger}
externalError={captchaError}
/>
{message && <p style={{ color: 'green' }}>{message}</p>}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send reset link'}
</button>
</form>
);
}1. Receive { email, captcha_token_id, captcha_answer }
2. Call EasyCaptcha POST /captcha/verify → { valid: true/false }
3. If valid == false → return 400 "Invalid security code"
4. Look up user by email silently (do NOT reveal if email exists — prevents enumeration)
5. If found → generate reset token, send email link
6. Always return the same success message regardless of whether email exists
Use CanvasCaptcha when you cannot run a backend service.
Validation happens entirely in the browser — no API calls needed.
// src/pages/ContactPage.jsx
import { useRef, useState } from 'react';
import CanvasCaptcha from '../components/CanvasCaptcha';
export default function ContactPage() {
const captchaRef = useRef(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
// validate() returns true/false, auto-shows error + redraws canvas on failure
if (!captchaRef.current.validate()) return;
setSubmitting(true);
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
});
if (res.ok) {
setStatus('Message sent!');
setName(''); setEmail(''); setMessage('');
captchaRef.current.refresh();
} else {
setStatus('Something went wrong. Please try again.');
}
setSubmitting(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 480, margin: '40px auto' }}>
<h2>Get in touch</h2>
<label>Name
<input value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>Email
<input type="email" value={email}
onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>Message
<textarea value={message} onChange={(e) => setMessage(e.target.value)}
rows={5} required />
</label>
{/* No apiUrl prop — fully client-side */}
{/* Use length={6} if you want 6 characters instead of the default 5 */}
<CanvasCaptcha ref={captchaRef} />
{status && <p>{status}</p>}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send message'}
</button>
</form>
);
}The /api/contact backend route needs no captcha code — the check already happened in the browser.
<!DOCTYPE html>
<html>
<body>
<form id="loginForm">
<h2>Sign In</h2>
<input type="email" id="email" placeholder="Email" required />
<input type="password" id="password" placeholder="Password" required />
<div id="captchaImageWrap"
style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;min-height:62px;
display:flex;align-items:center;justify-content:center;">
<span id="captchaLoading" style="font-size:12px;color:#64748b;">Loading…</span>
<img id="captchaImg" style="display:none;width:100%;height:62px;object-fit:fill;"
alt="Security code" />
</div>
<button type="button" id="captchaRefresh">↺ New image</button>
<input type="text" id="captchaInput" placeholder="Type the characters above"
autocomplete="off" style="letter-spacing:6px;font-size:18px;font-family:monospace;width:100%;" />
<p id="captchaError" style="color:red;font-size:12px;display:none;"></p>
<button type="submit">Sign in</button>
<p id="formMessage"></p>
</form>
<script>
const CAPTCHA_URL = 'http://localhost:8080'; // ← your EasyCaptcha URL
let currentTokenId = null;
let captchaLength = 5;
async function loadCaptcha() {
document.getElementById('captchaImg').style.display = 'none';
document.getElementById('captchaLoading').style.display = 'block';
document.getElementById('captchaInput').value = '';
document.getElementById('captchaError').style.display = 'none';
currentTokenId = null;
try {
const res = await fetch(`${CAPTCHA_URL}/captcha`);
const data = await res.json();
currentTokenId = data.token_id;
captchaLength = data.captcha_length ?? 5;
document.getElementById('captchaInput').maxLength = captchaLength;
document.getElementById('captchaInput').placeholder = `Type the ${captchaLength} characters above`;
const img = document.getElementById('captchaImg');
img.src = `data:image/png;base64,${data.image_b64}`;
img.style.display = 'block';
document.getElementById('captchaLoading').style.display = 'none';
} catch {
document.getElementById('captchaLoading').textContent = 'Failed — click ↺';
}
}
document.getElementById('captchaRefresh').addEventListener('click', loadCaptcha);
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const answer = document.getElementById('captchaInput').value.trim();
if (!answer || answer.length < captchaLength) {
document.getElementById('captchaError').textContent = 'Please complete the security check.';
document.getElementById('captchaError').style.display = 'block';
return;
}
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: document.getElementById('email').value,
password: document.getElementById('password').value,
captcha_token_id: currentTokenId,
captcha_answer: answer,
}),
});
const data = await res.json();
document.getElementById('formMessage').textContent =
res.ok ? 'Signed in!' : (data.message || 'Login failed.');
if (!res.ok) loadCaptcha();
});
loadCaptcha();
</script>
</body>
</html><!DOCTYPE html>
<html>
<body>
<form id="contactForm">
<h2>Contact us</h2>
<input type="text" id="name" placeholder="Name" required />
<input type="email" id="email" placeholder="Email" required />
<textarea id="message" placeholder="Message" required></textarea>
<canvas id="captchaCanvas" width="260" height="62"
style="border:1px solid #e2e8f0;border-radius:8px;display:block;"></canvas>
<button type="button" onclick="refreshCanvas()">↺ New code</button>
<input type="text" id="captchaAnswer" placeholder="Type the 5 characters above"
maxlength="5" autocomplete="off"
style="letter-spacing:6px;font-size:18px;font-family:monospace;width:100%;" />
<p id="captchaErr" style="color:red;font-size:12px;"></p>
<button type="submit">Send</button>
</form>
<script>
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const COLORS = ['#1e3a8a','#6b21a8','#9d174d','#0c4a6e','#14532d','#92400e'];
let currentCode = '';
function refreshCanvas() {
const canvas = document.getElementById('captchaCanvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
currentCode = '';
for (let i = 0; i < 5; i++)
currentCode += CHARS[Math.floor(Math.random() * CHARS.length)];
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, '#eef4fd'); g.addColorStop(1, '#f1f5f9');
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 6; i++) {
ctx.beginPath();
ctx.strokeStyle = `rgba(${Math.random()*120|0},${Math.random()*120|0},${Math.random()*180|0},0.25)`;
ctx.moveTo(Math.random()*W, Math.random()*H);
ctx.bezierCurveTo(Math.random()*W,Math.random()*H,Math.random()*W,Math.random()*H,Math.random()*W,Math.random()*H);
ctx.stroke();
}
const sw = (W - 24) / currentCode.length;
currentCode.split('').forEach((ch, i) => {
ctx.save();
ctx.translate(14 + i * sw + sw / 2, H / 2 + 9);
ctx.rotate((Math.random() - 0.5) * 0.55);
ctx.font = `bold ${22 + Math.random()*7|0}px Arial,sans-serif`;
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.textAlign = 'center';
ctx.fillText(ch, 0, 0);
ctx.restore();
});
document.getElementById('captchaAnswer').value = '';
document.getElementById('captchaErr').textContent = '';
}
document.getElementById('contactForm').addEventListener('submit', async (e) => {
e.preventDefault();
const answer = document.getElementById('captchaAnswer').value.trim().toUpperCase();
if (answer !== currentCode) {
document.getElementById('captchaErr').textContent = 'Incorrect — a new image has loaded.';
refreshCanvas();
return;
}
await fetch('/api/contact', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('name').value,
email: document.getElementById('email').value,
message: document.getElementById('message').value,
}),
});
alert('Message sent!');
});
refreshCanvas();
</script>
</body>
</html>These go inside your own application's backend — not in EasyCaptcha itself. Replace the placeholder values:
http://localhost:8080→ your EasyCaptcha service URLyour-api-secret→ value ofAPI_SECRET_KEYin EasyCaptcha
# routers/auth.py in your app
import os, httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
CAPTCHA_URL = os.environ["CAPTCHA_SERVICE_URL"] # http://localhost:8080
CAPTCHA_SECRET = os.environ["CAPTCHA_API_SECRET"]
async def verify_captcha(token_id: str, answer: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
res = await client.post(
f"{CAPTCHA_URL}/captcha/verify",
json={"token_id": token_id, "answer": answer},
headers={"X-API-Key": CAPTCHA_SECRET},
)
return res.status_code == 200 and res.json().get("valid", False)
except Exception:
return False # fail closed on service errors
class SignupRequest(BaseModel):
first_name: str
email: str
password: str
captcha_token_id: str
captcha_answer: str
@router.post("/auth/signup")
async def signup(payload: SignupRequest):
if not await verify_captcha(payload.captcha_token_id, payload.captcha_answer):
raise HTTPException(400, "Invalid security code. Please try again.")
# ... your normal signup logic
return {"message": "Account created."}
@router.post("/auth/login")
async def login(payload: dict):
if not await verify_captcha(
payload.get("captcha_token_id", ""),
payload.get("captcha_answer", ""),
):
raise HTTPException(400, "Invalid security code. Please try again.")
# ... verify credentials, return token// routes/auth.js in your app
const express = require('express');
const router = express.Router();
const CAPTCHA_URL = process.env.CAPTCHA_SERVICE_URL;
const CAPTCHA_SECRET = process.env.CAPTCHA_API_SECRET;
async function verifyCaptcha(tokenId, answer) {
try {
const res = await fetch(`${CAPTCHA_URL}/captcha/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': CAPTCHA_SECRET },
body: JSON.stringify({ token_id: tokenId, answer }),
});
return (await res.json()).valid === true;
} catch { return false; }
}
router.post('/signup', async (req, res) => {
const { firstName, email, password, captcha_token_id, captcha_answer } = req.body;
if (!await verifyCaptcha(captcha_token_id, captcha_answer))
return res.status(400).json({ message: 'Invalid security code.' });
// ... create user
res.status(201).json({ message: 'Account created.' });
});
router.post('/login', async (req, res) => {
const { identifier, password, captcha_token_id, captcha_answer } = req.body;
if (!await verifyCaptcha(captcha_token_id, captcha_answer))
return res.status(400).json({ message: 'Invalid security code.' });
// ... verify credentials
});
module.exports = router;# views/auth.py in your app
import os, json, requests
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
CAPTCHA_URL = os.getenv("CAPTCHA_SERVICE_URL", "http://localhost:8080")
CAPTCHA_SECRET = os.environ["CAPTCHA_API_SECRET"]
def verify_captcha(token_id: str, answer: str) -> bool:
try:
r = requests.post(
f"{CAPTCHA_URL}/captcha/verify",
json={"token_id": token_id, "answer": answer},
headers={"X-API-Key": CAPTCHA_SECRET},
timeout=10,
)
return r.status_code == 200 and r.json().get("valid", False)
except Exception:
return False
@csrf_exempt
def signup(request):
if request.method != "POST":
return JsonResponse({"error": "Method not allowed"}, status=405)
data = json.loads(request.body)
token_id = data.get("captcha_token_id", "")
answer = data.get("captcha_answer", "")
if not verify_captcha(token_id, answer):
return JsonResponse({"message": "Invalid security code."}, status=400)
# ... create user
return JsonResponse({"message": "Account created."}, status=201)<?php
define('CAPTCHA_URL', getenv('CAPTCHA_SERVICE_URL') ?: 'http://localhost:8080');
define('CAPTCHA_SECRET', getenv('CAPTCHA_API_SECRET'));
function verifyCaptcha(string $tokenId, string $answer): bool {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-API-Key: " . CAPTCHA_SECRET,
'content' => json_encode(['token_id' => $tokenId, 'answer' => $answer]),
'timeout' => 10,
]]);
$result = @file_get_contents(CAPTCHA_URL . '/captcha/verify', false, $ctx);
return $result && (json_decode($result, true)['valid'] ?? false) === true;
}
// POST /api/signup
$body = json_decode(file_get_contents('php://input'), true);
$tokenId = $body['captcha_token_id'] ?? '';
$answer = $body['captcha_answer'] ?? '';
if (!verifyCaptcha($tokenId, $answer)) {
http_response_code(400);
echo json_encode(['message' => 'Invalid security code.']);
exit;
}
// ... create user
http_response_code(201);
echo json_encode(['message' => 'Account created.']);Response — 200
{
"token_id": "550e8400-e29b-41d4-a716-446655440000",
"image_b64": "<base64-encoded PNG>",
"captcha_length": 5,
"audio_available": true
}audio_available is true when espeak-ng is installed on the server — show the audio button only when this is true.
The captcha_length field tells the frontend exactly how many characters to expect. The ServerCaptcha React component reads this automatically; in vanilla JS, set input.maxLength = data.captcha_length.
Error — 429 Rate limit exceeded.
Returns a WAV file that spells out each character with a clear pause between them. Designed for visually impaired users.
- Does not consume the token — user still types the answer.
- Requires
espeak-ngon the server (included in the Docker image). - Returns
503ifespeak-ngis not installed. - Returns
404if the token doesn't exist or is already used. - Rate limited to
AUDIO_LIMIT_PER_MIN(default 20) per IP/min.
Headers
X-API-Key: <your API_SECRET_KEY>
Content-Type: application/json
Body
{ "token_id": "...", "answer": "Ab3Kz", "client_ip": "1.2.3.4", "honeypot": "" }client_ip is optional — only required when ENFORCE_IP_BINDING=true. Pass the end-user's IP as seen by your backend.
honeypot is always "" for real users. Pass the value from onReady payload directly: { token_id: data.tokenId, answer: data.answer, honeypot: data.honeypot }. The backend rejects any non-empty value with error_code: "bot_suspected" before touching the DB.
Response — 200
{ "valid": true }{ "valid": false, "error_code": "wrong_answer" }Possible error_code values (for your backend logs only — never surface to users):
| Code | Meaning |
|---|---|
not_found |
Token ID doesn't exist or was already used |
expired |
Token TTL has elapsed |
wrong_answer |
Case-sensitive answer mismatch (hash comparison) |
too_fast |
Answer arrived faster than CAPTCHA_MIN_SOLVE_MS ms |
bot_suspected |
Honeypot field was non-empty |
ip_missing |
ENFORCE_IP_BINDING=true but client_ip not provided |
ip_mismatch |
ENFORCE_IP_BINDING=true and IPs don't match |
Error — 401 Missing or wrong X-API-Key.
Error — 429 Rate limit exceeded.
Requires the same X-API-Key header.
Response — 200
{
"tokens_in_db": 42,
"active_unused": 8,
"verified": 34,
"service_version": "1.5.0"
}Use this endpoint for monitoring dashboards or uptime checks.
Note: tokens_in_db reflects documents currently in MongoDB — the TTL index
automatically removes expired tokens approximately every 60 seconds, so this
number stays low in production.
Error — 401 Missing or wrong X-API-Key.
Returns per-rejection-type counts and solve rate for the last N hours.
Query parameters
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
hours |
integer | 24 |
1–168 | Rolling window size in hours |
Requires X-API-Key header.
Response — 200
{
"window_hours": 24,
"total_attempts": 312,
"solved": 218,
"solve_rate": 0.6987,
"rejections": {
"wrong_answer": 56,
"too_fast": 22,
"bot_suspected": 9,
"not_found": 7
},
"top_rejection": "wrong_answer",
"retention_days": 7,
"service_version": "1.5.0"
}Outcome types logged
| outcome | Meaning |
|---|---|
ok |
Answer accepted |
wrong_answer |
HMAC hash comparison failed |
too_fast |
Solved faster than CAPTCHA_MIN_SOLVE_MS |
bot_suspected |
Honeypot field was non-empty |
ip_missing |
ENFORCE_IP_BINDING=true but client_ip not provided |
ip_mismatch |
ENFORCE_IP_BINDING=true and IPs don't match |
expired |
Token TTL elapsed at verify time |
not_found |
Token ID unknown or already consumed |
Events are stored in a captcha_events collection with a TTL index. The retention
window defaults to 7 days; override with STATS_RETENTION_DAYS=N.
Error — 422 hours is outside the 1–168 range.
Error — 401 Missing or wrong X-API-Key.
{ "status": "ok", "version": "1.5.0", "service": "EasyCaptcha", "audio_available": true }No authentication needed. Use for load balancer health probes and uptime monitoring.
The test suite covers image generation, character set correctness, rate limiting logic, minimum solve time configuration, and audio availability — all without needing a running server or MongoDB.
cd backend
# Install Python dependencies (if not already done)
pip install -r requirements.txt
# Install test-only extras (not in requirements.txt)
pip install -r requirements-dev.txt
# Run all unit tests (79 tests, ~1.0 s)
MONGODB_URL=mongodb://localhost:27017 API_SECRET_KEY=test \
pytest test_captcha.py -vExpected output:
...
79 passed, 9 skipped
Skipped tests require either:
- A live running service (integration tests) — add
--integrationflag espeak-nginstalled (audio WAV generation tests)
| Protection | Detail |
|---|---|
| Code never sent to browser | Only the PNG image is returned. The answer is stored in MongoDB only. |
| HMAC-SHA256 answer hashing | Answers stored as keyed HMAC-SHA256 digests. A DB dump without the API secret cannot reveal answers. |
| Single-use tokens | Marked used immediately after the first verify call — replay is impossible. |
| TTL expiry | MongoDB TTL index auto-deletes tokens after TOKEN_TTL_MINUTES (default 5). |
| Per-IP rate limiting | Sliding 60-second window on all three endpoints. Configurable. |
| Minimum solve time | Answers arriving faster than CAPTCHA_MIN_SOLVE_MS (default 1500 ms) are rejected. Automated solvers typically answer in < 50 ms; real humans take ≥ 2 s. |
| Honeypot hidden field | ServerCaptcha renders a CSS-hidden name="website" input (not type="hidden", which bots skip). Bots fill every visible input field, humans don't. Non-empty value = immediate rejection, no DB hit. |
| Paste blocking | onPaste handlers in both React components and the demo page block Ctrl+V / clipboard paste. Bots paste; real users type. |
| Strict case-sensitivity | Answer must match the displayed characters exactly (upper/lower/digit). |
| Enhanced image distortion | Wave distortion, variable rotation (±33°), arc noise, variable character spacing, foreground lines, and 180 background dots. Hard to batch-OCR. |
| Multi-font rendering | Up to 6 bold typefaces (sans-serif, serif, monospace) selected randomly per character. Breaks single-font OCR pattern training. |
| Random color palette | 14 colors, randomly assigned per character. Increases per-image entropy vs. fixed color cycling. |
| Constant-time comparison | hmac.compare_digest() for both the API key check and the answer hash comparison — prevents timing-oracle attacks. |
| Security headers | X-Content-Type-Options, X-Frame-Options, Cache-Control: no-store on every response. |
| API key guard | /captcha/verify and /stats require X-API-Key — only your backend can call them. |
| IP binding (optional) | When ENFORCE_IP_BINDING=true, the verify IP must match the generate IP. Prevents token-theft attacks. |
| MongoDB auth (optional) | docker-compose.yml supports dedicated captcha_svc user with least-privilege readWrite on the easycaptcha DB only. |
| Audio CAPTCHA (WCAG 2.1) | GET /captcha/audio/{token_id} reads characters aloud via espeak-ng. Screen-reader accessible. Does not consume the token. |
| Concern | Recommendation |
|---|---|
| Advanced OCR/ML | Combine with login attempt rate limiting (lock after N failures per IP/account). |
| CanvasCaptcha | Client-side only — do not use for high-value flows like login or payment. |
| In-memory rate limiter | Resets on restart; not shared across multiple instances. Swap to Redis for multi-instance deployments. |
| HTTPS | Always serve EasyCaptcha over HTTPS in production. |
| Audio CAPTCHA | Inherently less OCR-resistant than images. Use as accessibility supplement, not the primary channel. |
-
API_SECRET_KEYis a strong random value (32+ hex chars) -
ALLOWED_ORIGINSis set to your exact frontend domain (not*) - Service is behind a reverse proxy (nginx / Caddy) with TLS
-
TOKEN_TTL_MINUTESis 5 or lower -
CAPTCHA_MIN_SOLVE_MSis at least 1500 (the default) -
/healthis monitored by your uptime tool - Login/signup endpoints also have their own rate limiting
-
API_SECRET_KEYis rotated periodically (HMAC keying means old hashes are automatically invalidated on rotation — all tokens expire in 5 min anyway)
- Is EasyCaptcha running? Check:
curl http://localhost:8080/health - Is the
apiUrlprop /CAPTCHA_URLvariable correct? - Does the browser console show a CORS error?
- Fix: set
ALLOWED_ORIGINS=http://localhost:3000(your frontend origin) in.env, restart.
- Fix: set
- Does
API_SECRET_KEYin EasyCaptcha exactly matchCAPTCHA_API_SECRETin your app? - Are you sending
token_id(snake_case) in the JSON body? - Has the token expired? Increase
TOKEN_TTL_MINUTESif your forms take a long time. - Was the token already used? Each token is single-use. Refresh the captcha after each attempt.
Increase RATE_LIMIT_PER_MIN in .env (default 15/min). For development use 100.
The Dockerfile installs fonts-dejavu-core which provides a clear bold font.
Rebuild: docker compose -f docker/docker-compose.yml up --build
Manual: sudo apt-get install fonts-dejavu-core (Ubuntu/Debian)
The component reads captcha_length from the API response automatically.
If you changed CAPTCHA_LENGTH in .env, restart the service so the new value
is returned in the API response. No frontend code changes needed.
Pass the length prop to match your preference:
<CanvasCaptcha ref={captchaRef} length={6} />Can I use this without MongoDB?
Use CanvasCaptcha — fully browser-side, no database needed.
Does this work with Next.js?
Yes. Use NEXT_PUBLIC_CAPTCHA_URL as the apiUrl prop. Call /captcha/verify from a Next.js API Route.
Does this work with Vue or Angular?
The backend API is plain HTTP JSON. Copy the fetch calls from ServerCaptcha.jsx and adapt them to your framework.
How do I monitor how many captchas are being solved?
Use GET /stats (requires X-API-Key). Returns tokens_in_db, active_unused, verified, and service_version.
How do I change the number of characters?
Set CAPTCHA_LENGTH in .env and restart. ServerCaptcha adapts via captcha_length in the API response. For CanvasCaptcha pass length={N}.
Can I run multiple EasyCaptcha instances?
Yes — all share the same MongoDB. For cross-instance rate limiting, replace _check_rate_limit with a Redis-backed implementation.
How do I run the tests?
See Running the tests above.
Pull requests are welcome.
Please open an issue first for large changes.
MIT — see LICENSE.