Skip to content

If you've added CAPTCHA to an Express app before, you've probably written the same fifteen lines twice — pluck the token off the body, fetch the verify endpoint, check the response, branch on validity. Do it three times across signup, login, and password reset and you've copy-pasted yourself into a maintenance problem. This post wraps the whole thing into a small, typed Express middleware that talks to CaptchaLa and drops cleanly into any route.

Works in Express, Fastify (with a tiny adapter), Hono, Koa — anywhere a (req, res, next) middleware chain exists.

Quick start (2 minutes)

You don't need an npm package on the server. CaptchaLa's verify endpoint is a single HTTP call, and Node 18+ ships fetch. So:

bash
npm init -y
npm install express dotenv

Then the middleware below. That's the entire dependency story.

The middleware

ts
// captcha-verify.ts
import type { Request, Response, NextFunction } from 'express';

interface VerifyResponse {
  code: number;
  data: {
    valid: boolean;
    action: string;
    risk_score: number;
    offline: boolean;
  };
}

export function verifyCaptcha(action: string, opts: { maxRisk?: number } = {}) {
  const maxRisk = opts.maxRisk ?? 90;

  return async function (req: Request, res: Response, next: NextFunction) {
    const token = req.body?.captchaToken ?? req.body?.captcha_token;
    if (!token) {
      return res.status(400).json({ ok: false, error: 'captcha_token missing' });
    }

    const fwd = req.headers['x-forwarded-for'];
    const ip = (Array.isArray(fwd) ? fwd[0] : fwd?.split(',')[0]?.trim()) ?? req.ip ?? '';

    let upstream: Response;
    try {
      upstream = await fetch('https://apiv1.captcha.la/v1/challenge/verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-App-Key': process.env.CAPTCHALA_APP_KEY!,
          'X-App-Secret': process.env.CAPTCHALA_APP_SECRET!,
        },
        body: JSON.stringify({ token, action, client_ip: ip }),
        signal: AbortSignal.timeout(5000),
      });
    } catch (e) {
      // network error — fail closed for security-critical actions
      return res.status(503).json({ ok: false, error: 'verify upstream unreachable' });
    }

    if (!upstream.ok) {
      return res.status(502).json({ ok: false, error: 'verify upstream returned ' + upstream.status });
    }

    const json = (await upstream.json()) as VerifyResponse;

    if (json.code !== 0 || !json.data.valid || json.data.action !== action) {
      return res.status(403).json({ ok: false, error: 'captcha rejected' });
    }

    if (json.data.risk_score > maxRisk) {
      return res.status(403).json({ ok: false, error: 'risk threshold exceeded' });
    }

    // attach for downstream handlers that want to inspect the score
    (req as any).captcha = {
      riskScore: json.data.risk_score,
      offline: json.data.offline,
    };

    next();
  };
}

A few decisions worth flagging. We pass action as a closure argument so each route declares which action it expects — that's how you bind the token to the right operation. We use AbortSignal.timeout(5000) so a slow upstream doesn't hang the whole request. And we attach req.captcha for the route handler to inspect, in case it wants to apply extra friction at a lower threshold than the middleware's hard reject.

Wiring it into routes

ts
// app.ts
import 'dotenv/config';
import express from 'express';
import { verifyCaptcha } from './captcha-verify';

const app = express();
app.set('trust proxy', true); // honor X-Forwarded-For from the load balancer
app.use(express.json());

app.post('/api/signup', verifyCaptcha('signup'), (req, res) => {
  // captcha already verified — req.captcha.riskScore available
  res.json({ ok: true });
});

app.post('/api/login', verifyCaptcha('login', { maxRisk: 60 }), (req, res) => {
  res.json({ ok: true });
});

app.listen(3000);

The action string binds the verification to the operation — a token issued for signup will fail at verifyCaptcha('login'), which is exactly what you want.

Pairing with rate limiting

CAPTCHA and rate limiting aren't substitutes; they're complements. Put the rate limiter outside the captcha check so abusive IPs can't burn through your CaptchaLa quota:

ts
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true });

app.post('/api/login', loginLimiter, verifyCaptcha('login'), loginHandler);

Order matters: rate limit first (cheap, in-process), captcha verify second (network call), business logic third.

Common pitfalls

  1. process.env not loaded. import 'dotenv/config' must run before anything reads process.env.CAPTCHALA_APP_KEY. Put it at the top of your entrypoint, not buried inside a module.
  2. Not setting trust proxy. Behind Nginx, ALB, or Cloudflare, req.ip is the proxy IP unless you call app.set('trust proxy', true). Without it, every captcha request looks like it's from the same IP and your risk model degrades.
  3. Failing open on upstream errors. The middleware above fails closed (503) when it can't reach captcha.la. For non-critical endpoints (newsletter signup) you might prefer to fail open. Make that an explicit decision, not an accident.
  4. Forgetting body parsing. express.json() must run before the captcha middleware, otherwise req.body is undefined and you get a confusing "captcha_token missing" error on every request.
  5. No timeout on fetch. Without AbortSignal.timeout, a slow upstream wedges the request for as long as the kernel keepalive lasts. Five seconds is generous for a healthy verify endpoint.

Where to go next

  • Server-side reference and the optional Node SDK on captcha.la/docs — covers the server-token issuance endpoint for high-value flows
  • Plan limits and free tier: captcha.la/pricing
  • If you want to issue server-bound tokens (TTL + IP binding) before the user even starts the challenge, CaptchaLa supports that via a separate endpoint — useful for protecting payment flows where replay risk is the main concern

The Node integration is honestly small — one middleware, one fetch call, one response shape to handle. The hard part is remembering to pass the action string consistently and to forward the real client IP. Get those right and you have a captcha layer that scales to every protected endpoint without touching it again.

Articles are CC BY 4.0 — feel free to quote with attribution