Documentation · itzsa

Math & Slider Captcha — @itzsa/captcha

Company-standard React captcha with two trust models: client-side generate + verify (UX friction), and server-side challenge + verify (trusted source of truth). One package — Text, Math (BODMAS), Slider — shared ref / onVerified API.

@itzsa/captchaClient generate + verifyServer challenge + verify

Live playground#

Pick trust model (Client / Server), then type. Registry-driven — scalable as you add modes.

Trust model
Type
Captcha

Generate + verify in the browser. Fast UX friction — not a security boundary alone. · Low-risk forms, newsletter, comments — UX friction only.

Client modecanvas text is generated and matched in the browser (generateCaptcha / verifyCaptcha). Optional verify() can still hit your API after a local match.

Letters & numbers — match case exactly as shown

0/6 · 5 left

onVerifiedfalse · validate()false

4 registered examples · trust= client · active: text

Trust models#

Both are first-class. Choose by risk — not by preference for shiny UI.

Client

Browser runs generateMathChallenge / generateCaptcha and verifies locally. Optional verify() after a local match. Fast UX friction — not a security boundary alone.

Server (company standard)

API issues prompt + token; answer stays in Redis/memory. UI uses serverChallenge. Required for login / checkout / signup.

Installation#

npm package or copy-paste from the itzsa registry.

pnpm add @itzsa/captcha
$ pnpm dlx shadcn@latest add https://itzsa.acharya-suman.com.np/r/captcha.json

Getting started#

Same MathCaptcha component — flip trust model with or without serverChallenge.

Client — generate + verify in the browser#

No serverChallenge. Local match is enough to enable submit. Soft gate only.

import { useRef, useState } from "react";
import { MathCaptcha, type MathCaptchaHandle } from "@itzsa/captcha";

/** Client: generate + verify in the browser */
export function ClientMathGate() {
  const ref = useRef<MathCaptchaHandle>(null);
  const [ok, setOk] = useState(false);

  return (
    <>
      <MathCaptcha
        ref={ref}
        difficulty="medium"
        layout="inline"
        // no serverChallenge → local generateMathChallenge + verifyMathAnswer
        onVerified={setOk}
      />
      <button type="button" disabled={!ok} onClick={() => {
        if (!ref.current?.validate()) return;
        // soft gate only
      }}>
        Continue
      </button>
    </>
  );
}

When to use

Newsletter, contact, comments, low-risk forms. Pair with rate limits on the form endpoint if abuse appears.

Server — trusted challenge + verify#

serverChallenge + verify() → your /api/captcha/*. Answer never reaches the client.

import { useCallback, useEffect, useRef, useState } from "react";
import {
  MathCaptcha,
  type MathCaptchaHandle,
  type MathCaptchaServerChallenge,
} from "@itzsa/captcha";

/** Server: trusted source of truth — answer never sent to the client */
export function ServerMathGate() {
  const ref = useRef<MathCaptchaHandle>(null);
  const meta = useRef<{ renderStamp: string; honeypotField: string } | null>(null);
  const [challenge, setChallenge] = useState<MathCaptchaServerChallenge | null>(null);
  const [ok, setOk] = useState(false);

  const load = useCallback(async () => {
    const res = await fetch("/api/captcha/challenge", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ difficulty: "bodmas", action: "login" }),
    });
    const data = await res.json();
    meta.current = { renderStamp: data.renderStamp, honeypotField: data.honeypotField };
    setChallenge({ prompt: data.prompt, token: data.token });
    setOk(false);
  }, []);

  useEffect(() => { void load(); }, [load]);

  return (
    <MathCaptcha
      ref={ref}
      layout="inline"
      serverChallenge={challenge}
      onRequestChallenge={load}
      verify={async ({ value, challengeId }) => {
        const m = meta.current;
        if (!m) return false;
        const res = await fetch("/api/captcha/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            token: challengeId,
            answer: value,
            renderStamp: m.renderStamp,
            honeypotField: m.honeypotField,
            honeypotValue: "",
            action: "login",
          }),
        });
        return res.ok && (await res.json()).ok === true;
      }}
      onVerified={setOk}
    />
  );
}

When to use

Login, checkout, signup, password reset. Gate the action with the issued humanPass cookie / JWT.

Client text (canvas) — ref + onVerified#

Classic canvas captcha. Local generate + local verify; optional verify() callback.

import { useRef, useState } from "react";
import { Captcha, type CaptchaHandle } from "@itzsa/captcha";

export function LoginGate() {
  const captchaRef = useRef<CaptchaHandle>(null);
  const [verified, setVerified] = useState(false);

  return (
    <>
      <Captcha
        ref={captchaRef}
        length={6}
        charsetMode="both"
        onVerified={setVerified}
      />
      <button
        type="button"
        disabled={!verified}
        onClick={() => {
          if (!captchaRef.current?.validate()) return;
          // submit…
        }}
      >
        Continue
      </button>
    </>
  );
}

Examples#

Each mode is its own codebase under examples/. Preview + Code tabs. Tagged by trust model.

Client · Text (canvas)#

Browser generates the canvas string and can verify locally. Optional verify() callback for an extra host check. · Low-risk forms, newsletter, comments — UX friction only.

Client modecanvas text is generated and matched in the browser (generateCaptcha / verifyCaptcha). Optional verify() can still hit your API after a local match.

Letters & numbers — match case exactly as shown

0/6 · 5 left

onVerifiedfalse · validate()false

Client · Math (BODMAS)#

Browser generates the expression and verifies the answer locally (BODMAS). Wrong answers auto-load the next problem. · Contact forms, soft gates — still not a hard anti-bot wall.

Client modechallenge is generated with generateMathChallenge in the browser; answer is checked locally with verifyMathAnswer. Not a hard security boundary.

4 + 5 = ?

Solve the expression, then verify.

5 left

onVerifiedfalse · validate()false

Client · Slider puzzle#

Browser-side drag-to-confirm. Release inside targetMin–targetMax to pass. · Lightweight friction when math/text is too noisy.

Client modesuccess zone is evaluated in the browser. Use server math for login/checkout.

→ drag the piece

Release in the 90–100% zone to pass.

Drag the piece to the end, then release.

5 left

onVerifiedfalse · validate()false

Server · Math (trusted)#

POST /api/captcha/challenge issues the prompt; answer stays on the server. MathCaptcha serverChallenge + /api/captcha/verify. · Login, checkout, signup, password reset — company standard.

data-mode=server

Server modeprompt from POST /api/captcha/challenge. Answer never leaves the server. Checked with verifyMathAnswer in POST /api/captcha/verify. Company standard for login / checkout.

8 + 4 = ?

Verifying…

5 left

onVerifiedfalse · validate()false

Challenge from POST /api/captcha/challenge · verify via POST /api/captcha/verify · answer never sent to the browser.

Math engine (headless)#

Same helpers power client UI and the server challenge API.

import {
  generateMathChallenge,
  verifyMathAnswer,
  evaluateExpression,
} from "@itzsa/captcha";

// Same helpers power BOTH client UI and your server challenge API
const challenge = generateMathChallenge({ difficulty: "bodmas" });
// challenge.prompt, challenge.expression, challenge.answer, challenge.requiresBodmas

const ok = verifyMathAnswer({
  value: userInput,
  answer: challenge.answer,
});

evaluateExpression("(2+3)*4"); // → 20  (BODMAS)
evaluateExpression("2+3*4");   // → 14

Production security#

This docs app is Next.js (not Express). @itzsa/captcha is not a security boundary alone — use server-issued challenges, single-use tokens, rate limits, Turnstile, honeypot + timing, and gate sensitive routes.

What the package actually provides

  • Headless: generateMathChallenge, verifyMathAnswer, generateCaptcha, verifyCaptcha
  • React UI: Captcha, MathCaptcha, SliderCaptcha (local generate by default)
  • Secure UI mode: MathCaptcha serverChallenge + your verify() → API
  • No built-in Redis store, JWT, or Turnstile — those live in src/lib/captcha-security

Challenge + verify API (this app)#

Implemented under src/app/api/captcha and src/lib/captcha-security.

  • POST /api/captcha/challenge — stores answer server-side (TTL 5m); returns token, prompt, renderStamp, honeypotField
  • POST /api/captcha/verify — honeypot + timing + optional Turnstile + verifyMathAnswer. Single-use delete. Issues humanPass cookie
  • Gated demos: POST /api/login, POST /api/checkout (velocity + idempotency)
  • Env: CAPTCHA_HMAC_SECRET, TURNSTILE_SECRET_KEY, optional REDIS_URL

Express sample#

For Express apps — same package helpers, express-rate-limit + Redis. Copy-adapt; this monorepo serves Next.js routes.

/**
 * Example Express wiring for @itzsa/captcha server-side challenges.
 * Uses the same headless helpers: generateMathChallenge + verifyMathAnswer.
 *
 * npm i express express-rate-limit rate-limit-redis redis ioredis cookie-parser
 * # optional: hcaptcha (or use Turnstile fetch below — no SDK)
 */
import express from "express";
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
import {
  generateMathChallenge,
  verifyMathAnswer,
  createChallengeId,
} from "@itzsa/captcha";

const app = express();
app.use(express.json());
app.use(cookieParser());

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const challenges = new Map(); // replace with redis.set(token, JSON, { EX: 300 })

const verifyLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 30,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redis.sendCommand(args),
  }),
});

app.post("/api/captcha/challenge", async (req, res) => {
  const token = createChallengeId();
  const c = generateMathChallenge({ difficulty: req.body?.difficulty ?? "medium" });
  // STORE answer server-side only — never send c.answer
  await redis.set(
    `captcha:chal:${token}`,
    JSON.stringify({ answer: String(c.answer), kind: "math" }),
    { EX: 300 },
  );
  res.json({
    token,
    prompt: c.prompt,
    honeypotField: `hp_${token.slice(0, 8)}`,
    renderStamp: Date.now(), // prefer HMAC-signed stamp in production
  });
});

app.post("/api/captcha/verify", verifyLimiter, async (req, res) => {
  const { token, answer, honeypotField, honeypotValue } = req.body ?? {};
  if (honeypotValue) return res.status(400).json({ ok: false });

  const raw = await redis.get(`captcha:chal:${token}`);
  await redis.del(`captcha:chal:${token}`); // single-use
  if (!raw) return res.status(400).json({ ok: false, error: "expired" });

  const { answer: expected } = JSON.parse(raw);
  const ok = verifyMathAnswer({ value: answer, answer: Number(expected) });
  if (!ok) return res.status(400).json({ ok: false, error: "incorrect" });

  // Issue short-lived human cookie / JWT here (HMAC or jose)
  res.cookie("itzsa_human", "…signed…", { httpOnly: true, sameSite: "lax" });
  res.json({ ok: true });
});

/** Sensitive action — require human cookie + optional Turnstile */
app.post("/api/checkout", async (req, res) => {
  // verifyTurnstile(req.body.turnstileToken)
  // verifyHumanPass(req.cookies.itzsa_human, "checkout")
  // idempotency key + velocity checks
  res.json({ ok: true });
});

app.listen(3001);

Props#

Public surface of Captcha, MathCaptcha, and SliderCaptcha.

Captcha (text)#

Core

PropTypeDefaultDescription
length / charsnumber6Number of characters in the challenge (3–16).
charsetMode"both" | "letters" | "numbers""both"Letters + digits, letters only, or digits only.
excludeAmbiguousbooleantrueDrop look-alikes (0 / O / 1 / l / I).
caseSensitivebooleantrueExact case match (ignored when charsetMode is numbers).
theme"light" | "dark" | "system""system"Canvas color scheme.
noisenumber0.55Interference intensity from 0 to 1.
maxAttemptsnumber5Failures before status becomes locked.
onVerified(valid: boolean) => void-Called with true when the answer is accepted, false when cleared / wrong / refreshed. Works with only ref + onVerified.

MathCaptcha#

MathCaptcha

PropTypeDefaultDescription
difficulty"easy" | "medium" | "hard" | "bodmas""easy"Preset operators / range / term count. Answers always use BODMAS evaluation.
layout"stack" | "inline""stack"stack = prompt then input; inline = prompt + refresh + input + verify on one row. Override further with rowClassName / *ClassName.
autoRefreshOnInvalidbooleantrueLoad a new problem after a wrong answer (keeps attempt count until maxAttempts).
operatorsArray<"+" | "-" | "*" | "/">-Override the operator pool from the difficulty preset.
operandRange{ min: number; max: number }-Inclusive min/max for leaf operands.
termCount2 | 3 | 4-Number of operands (3+ may parenthesize).
showBodmasCaution / bodmasCautionboolean / stringfalse / built-inOpt-in BODMAS / PEMDAS note under the prompt.
serverChallenge / onRequestChallenge{ prompt, token } / () => void-Secure mode: display a server-issued prompt; skip local answer checks; verify() must call your API. Refresh asks the host for a new challenge.
showVerifyButtonbooleantrueShow the Verify control (Enter also submits).
className / rowClassName / promptClassName / inputClassName / …string-CSS hooks: root, row, prompt, input, refresh, verify, status, counter, error, caution. Also data-itzsa-math-* attributes.

SliderCaptcha#

SliderCaptcha

PropTypeDefaultDescription
targetMin / targetMaxnumber90 / 100Inclusive success zone (%). Release inside the zone to pass.
maxAttemptsnumber5Failed releases before status becomes locked.
verify(payload) => boolean | Promise<boolean>-Optional server check after a successful slide. Return false or throw on failure.

Verify & errors#

Verify & errors

PropTypeDefaultDescription
verify(payload) => boolean | Promise<boolean>-Optional server check. Return false or throw on a bad API call.
verifyTimeoutMsnumber15000Abort verify() after this many milliseconds.
errorstring | null-Controlled host/API error (e.g. login 429). Shown under the field.
loadingboolean-Controlled loading while a host API is in flight.
onError(error: CaptchaError) => void-Structured failures: invalid, verify_failed, network, timeout, max_attempts, …
onLock(error: CaptchaError) => void-Fired when maxAttempts is reached.
autoRefreshOnInvalidbooleanfalseIssue a new challenge after a wrong answer.
autoRefreshOnErrorbooleanfalseIssue a new challenge after a verify/API failure.

Chrome & styling#

Chrome

PropTypeDefaultDescription
label / showLabel / requiredstring / boolean / boolean"Security check" / true / falseAccessible field label and required marker.
messagesCaptchaMessages-Override placeholders and status copy.
showRefresh / showCounter / showStatusbooleantrueToggle chrome pieces.
className / canvasClassName / inputClassName / …string-Styling hooks for root, canvas, input, refresh, label, error.
value / defaultValue / disabled / id / name-Form control helpers.

Imperative API#

Shared handle shape via ref — refresh, reset, validate, unlock. MathCaptcha also exposes getChallenge().

Handle

PropTypeDefaultDescription
refresh()() => void-New challenge, clear input, reset attempts.
reset()() => void-Clear input without regenerating the challenge.
validate()() => boolean-True if currently valid (or local match).
getValue()() => string-Current user input.
getChallengeId()() => string-Opaque id for this challenge (server correlation).
getStatus() / getAttempts()() => CaptchaStatus / number-Latest status and failure count.
unlock()(opts?) => void-Clear lock; refreshes by default.

Registry#

Installs under components/itzsa/captcha (nested components/ui).

captcha.json

https://itzsa.acharya-suman.com.np/r/captcha.json