Express.js applications often need CAPTCHA handling in two directions: verifying CAPTCHAs submitted by users (Turnstile/reCAPTCHA) and solving CAPTCHAs when making outbound requests (scraping, API aggregation). This guide covers both.
Prerequisites
npm install express
Pattern 1: Verify Turnstile tokens from your forms
Protect your Express forms with Cloudflare Turnstile and verify tokens server-side:
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET;
async function verifyTurnstile(token, remoteIp) {
const resp = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: TURNSTILE_SECRET,
response: token,
remoteip: remoteIp,
}),
}
);
const data = await resp.json();
return data.success;
}
// Login form page
app.get("/login", (req, res) => {
res.send(`
<form action="/login" method="POST">
<input name="email" type="email" required>
<input name="password" type="password" required>
<div class="cf-turnstile" data-sitekey="${process.env.TURNSTILE_SITEKEY}"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<button type="submit">Login</button>
</form>
`);
});
// Login handler with Turnstile verification
app.post("/login", async (req, res) => {
const token = req.body["cf-turnstile-response"];
if (!token) {
return res.status(400).json({ error: "CAPTCHA response missing" });
}
const valid = await verifyTurnstile(token, req.ip);
if (!valid) {
return res.status(403).json({ error: "CAPTCHA verification failed" });
}
// Proceed with login logic
const { email, password } = req.body;
// ... authenticate user
res.json({ success: true });
});
Pattern 2: CAPTCHA verification middleware
function requireCaptcha(type = "turnstile") {
return async (req, res, next) => {
let token;
if (type === "turnstile") {
token = req.body["cf-turnstile-response"];
} else if (type === "recaptcha") {
token = req.body["g-recaptcha-response"];
}
if (!token) {
return res.status(400).json({ error: "CAPTCHA token required" });
}
try {
let valid = false;
if (type === "turnstile") {
valid = await verifyTurnstile(token, req.ip);
} else if (type === "recaptcha") {
valid = await verifyRecaptcha(token, req.ip);
}
if (!valid) {
return res.status(403).json({ error: "CAPTCHA verification failed" });
}
next();
} catch (error) {
console.error("CAPTCHA verification error:", error);
return res.status(500).json({ error: "CAPTCHA verification error" });
}
};
}
async function verifyRecaptcha(token, remoteIp) {
const resp = await fetch(
"https://www.google.com/recaptcha/api/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: process.env.RECAPTCHA_SECRET,
response: token,
remoteip: remoteIp,
}),
}
);
const data = await resp.json();
return data.success;
}
// Apply to routes
app.post("/register", requireCaptcha("turnstile"), (req, res) => {
// CAPTCHA already verified
res.json({ success: true });
});
app.post("/contact", requireCaptcha("recaptcha"), (req, res) => {
// Process contact form
res.json({ success: true });
});
Pattern 3: CaptchaAI solving service
For outbound requests where your Express app needs to solve CAPTCHAs on external sites:
const API_KEY = process.env.CAPTCHAAI_KEY;
class CaptchaSolverService {
#apiKey;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async solve(method, params) {
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({
key: this.#apiKey,
method,
json: "1",
...params,
}),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) throw new Error(submitData.request);
const taskId = submitData.request;
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
})}`
);
const data = await pollResp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
}
throw new Error("Timed out");
}
async getBalance() {
const resp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: this.#apiKey,
action: "getbalance",
json: "1",
})}`
);
const data = await resp.json();
return parseFloat(data.request);
}
}
const captchaSolver = new CaptchaSolverService(API_KEY);
Pattern 4: REST API for CAPTCHA solving
Expose CaptchaAI as a microservice:
// POST /api/solve
app.post("/api/solve", async (req, res) => {
const { method, sitekey, pageurl, ...extra } = req.body;
if (!method || !sitekey || !pageurl) {
return res.status(400).json({
error: "Required: method, sitekey, pageurl",
});
}
try {
const params = { pageurl };
if (method === "userrecaptcha") {
params.googlekey = sitekey;
} else if (method === "turnstile") {
params.sitekey = sitekey;
}
Object.assign(params, extra);
const token = await captchaSolver.solve(method, params);
res.json({
status: "solved",
token,
method,
});
} catch (error) {
res.status(500).json({
status: "error",
error: error.message,
});
}
});
// GET /api/balance
app.get("/api/balance", async (req, res) => {
try {
const balance = await captchaSolver.getBalance();
res.json({ balance });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Pattern 5: Background solving with callbacks
For async workflows where solving takes time:
const activeTasks = new Map();
// Submit a solve task
app.post("/api/solve/async", async (req, res) => {
const { method, sitekey, pageurl } = req.body;
const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2)}`;
activeTasks.set(taskId, { status: "solving", createdAt: Date.now() });
// Solve in background
captchaSolver
.solve(method, {
[method === "userrecaptcha" ? "googlekey" : "sitekey"]: sitekey,
pageurl,
})
.then((token) => {
activeTasks.set(taskId, { status: "solved", token, solvedAt: Date.now() });
})
.catch((error) => {
activeTasks.set(taskId, { status: "error", error: error.message });
});
res.json({ taskId, status: "solving" });
});
// Check task status
app.get("/api/solve/status/:taskId", (req, res) => {
const task = activeTasks.get(req.params.taskId);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json({ taskId: req.params.taskId, ...task });
});
Pattern 6: Rate limiting with CAPTCHA
const rateLimit = new Map();
function rateLimitMiddleware(maxRequests = 5, windowMs = 60000) {
return (req, res, next) => {
const ip = req.ip;
const now = Date.now();
const record = rateLimit.get(ip) || { count: 0, resetAt: now + windowMs };
if (now > record.resetAt) {
record.count = 0;
record.resetAt = now + windowMs;
}
record.count++;
rateLimit.set(ip, record);
if (record.count > maxRequests) {
// Require CAPTCHA when rate limit hit
return res.status(429).json({
error: "Rate limit exceeded",
requireCaptcha: true,
captchaType: "turnstile",
sitekey: process.env.TURNSTILE_SITEKEY,
});
}
next();
};
}
app.get("/api/data", rateLimitMiddleware(10, 60000), (req, res) => {
res.json({ data: "..." });
});
// Allow rate-limited users to bypass with CAPTCHA
app.post("/api/data", requireCaptcha("turnstile"), (req, res) => {
// Reset their rate limit after CAPTCHA solve
rateLimit.delete(req.ip);
res.json({ data: "..." });
});
Error handling middleware
class CaptchaVerificationError extends Error {
constructor(message, code) {
super(message);
this.name = "CaptchaVerificationError";
this.statusCode = code || 403;
}
}
// Global error handler
app.use((err, req, res, next) => {
if (err instanceof CaptchaVerificationError) {
return res.status(err.statusCode).json({
error: err.message,
code: "CAPTCHA_FAILED",
});
}
next(err);
});
Complete server
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const captchaSolver = new CaptchaSolverService(process.env.CAPTCHAAI_KEY);
// Health check
app.get("/health", async (req, res) => {
try {
const balance = await captchaSolver.getBalance();
res.json({ status: "ok", balance });
} catch {
res.status(503).json({ status: "error" });
}
});
// Protected form with Turnstile
app.post("/submit", requireCaptcha("turnstile"), (req, res) => {
res.json({ success: true, data: req.body });
});
// Solve CAPTCHAs on demand
app.post("/solve", async (req, res) => {
try {
const token = await captchaSolver.solve(req.body.method, req.body.params);
res.json({ token });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(3000, () => console.log("Running on :3000"));
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Turnstile token always invalid | Wrong secret key | Use the secret key, not the site key |
| Request body is empty | Missing body parser | Add express.urlencoded() middleware |
| Solve endpoint times out | Express default timeout | Set timeout: req.setTimeout(180000) |
| CORS errors on API calls | Missing CORS headers | Add cors middleware |
| Memory leak with activeTasks | Tasks never cleaned up | Add TTL cleanup with setInterval |
Frequently asked questions
Should I verify CAPTCHAs server-side or client-side?
Always server-side. Client-side verification can be bypassed. The server should validate the token with the CAPTCHA provider's API.
How do I handle the time it takes to solve a CAPTCHA?
Use the async pattern (Pattern 5) — return a task ID immediately and let the client poll for results.
Can I use Express with CaptchaAI to protect my own API?
Yes. Use Turnstile or reCAPTCHA on your frontend and verify tokens server-side with the middleware pattern.
Summary
Express.js + CaptchaAI supports both inbound verification (protecting your forms with Turnstile/reCAPTCHA middleware) and outbound solving (solving CAPTCHAs on external sites). Use the middleware pattern for form protection, the service class for outbound solving.
Discussions (0)
Join the conversation
Sign in to share your opinion.
Sign InNo comments yet.