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.
Live playground#
Pick trust model (Client / Server), then type. Registry-driven — scalable as you add modes.
Generate + verify in the browser. Fast UX friction — not a security boundary alone. · Low-risk forms, newsletter, comments — UX friction only.
Client mode — canvas 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
onVerified → false · 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.
Architecture flowchart
Client friction vs server-trusted challenge
Shared package
@itzsa/captcha — headless engine
generateMathChallengeverifyMathAnswergenerateCaptchaverifyCaptchaChoose trust model
Step 1
Render UI in the browser
Captcha · MathCaptcha · SliderCaptchaStep 2
Generate + verify locally
Step 3 · optional
Host verify() callback
Outcome
UX friction only
Step 1
POST /api/captcha/challenge
generateMathChallenge, stores answer in Redis / memory (TTL ~5m), returns { token, prompt } only.Step 2
MathCaptcha serverChallenge
onRequestChallenge.Step 3
POST /api/captcha/verify
verifyMathAnswer. Token is single-use (deleted).Step 4
Issue humanPass
/api/login and /api/checkout.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.jsonGetting 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 mode — canvas 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
onVerified → false · 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 mode — challenge is generated with generateMathChallenge in the browser; answer is checked locally with verifyMathAnswer. Not a hard security boundary.
Solve the expression, then verify.
5 left
onVerified → false · 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 mode — success zone is evaluated in the browser. Use server math for login/checkout.
Release in the 90–100% zone to pass.
Drag the piece to the end, then release.
5 left
onVerified → false · 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.
Server mode — prompt from POST /api/captcha/challenge. Answer never leaves the server. Checked with verifyMathAnswer in POST /api/captcha/verify. Company standard for login / checkout.
Verifying…
5 left
onVerified → false · 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"); // → 14Production 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+ yourverify()→ 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); returnstoken,prompt,renderStamp,honeypotFieldPOST /api/captcha/verify— honeypot + timing + optional Turnstile +verifyMathAnswer. Single-use delete. IssueshumanPasscookie- Gated demos:
POST /api/login,POST /api/checkout(velocity + idempotency) - Env:
CAPTCHA_HMAC_SECRET,TURNSTILE_SECRET_KEY, optionalREDIS_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
| Prop | Type | Default | Description |
|---|---|---|---|
| length / chars | number | 6 | Number of characters in the challenge (3–16). |
| charsetMode | "both" | "letters" | "numbers" | "both" | Letters + digits, letters only, or digits only. |
| excludeAmbiguous | boolean | true | Drop look-alikes (0 / O / 1 / l / I). |
| caseSensitive | boolean | true | Exact case match (ignored when charsetMode is numbers). |
| theme | "light" | "dark" | "system" | "system" | Canvas color scheme. |
| noise | number | 0.55 | Interference intensity from 0 to 1. |
| maxAttempts | number | 5 | Failures 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| autoRefreshOnInvalid | boolean | true | Load a new problem after a wrong answer (keeps attempt count until maxAttempts). |
| operators | Array<"+" | "-" | "*" | "/"> | - | Override the operator pool from the difficulty preset. |
| operandRange | { min: number; max: number } | - | Inclusive min/max for leaf operands. |
| termCount | 2 | 3 | 4 | - | Number of operands (3+ may parenthesize). |
| showBodmasCaution / bodmasCaution | boolean / string | false / built-in | Opt-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. |
| showVerifyButton | boolean | true | Show 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
| Prop | Type | Default | Description |
|---|---|---|---|
| targetMin / targetMax | number | 90 / 100 | Inclusive success zone (%). Release inside the zone to pass. |
| maxAttempts | number | 5 | Failed 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
| Prop | Type | Default | Description |
|---|---|---|---|
| verify | (payload) => boolean | Promise<boolean> | - | Optional server check. Return false or throw on a bad API call. |
| verifyTimeoutMs | number | 15000 | Abort verify() after this many milliseconds. |
| error | string | null | - | Controlled host/API error (e.g. login 429). Shown under the field. |
| loading | boolean | - | 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. |
| autoRefreshOnInvalid | boolean | false | Issue a new challenge after a wrong answer. |
| autoRefreshOnError | boolean | false | Issue a new challenge after a verify/API failure. |
Chrome & styling#
Chrome
| Prop | Type | Default | Description |
|---|---|---|---|
| label / showLabel / required | string / boolean / boolean | "Security check" / true / false | Accessible field label and required marker. |
| messages | CaptchaMessages | - | Override placeholders and status copy. |
| showRefresh / showCounter / showStatus | boolean | true | Toggle 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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