react-easy-captcha
v1.2.0
Published
First-party image CAPTCHA for React: typed EasyCaptcha widget plus Node challenge issue/verify with pluggable storage. No third-party CAPTCHA provider.
Maintainers
Readme
react-easy-captcha
First-party image CAPTCHA for React — one typed widget (EasyCaptcha) plus Node helpers to issue and verify challenges. No Turnstile / hCaptcha / reCAPTCHA. Answers are never stored in plaintext (HMAC digests only).
Current version: 1.2.0 — optional click-to-refresh on the CAPTCHA image; generation charset / case options from 1.1.
Install
npm install react-easy-captchaFor PNG images (recommended), also install the optional peer:
npm install sharpWithout sharp, pass imageFormat: "svg" when creating challenges.
Quick start
1. Client widget
import { useState } from "react";
import { EasyCaptcha } from "react-easy-captcha";
import "react-easy-captcha/styles.css";
export function LoginForm() {
const [answer, setAnswer] = useState("");
const [challengeId, setChallengeId] = useState<string | null>(null);
return (
<form
onSubmit={(event) => {
event.preventDefault();
// POST { captchaAnswer: answer, captchaChallenge: challengeId, ... }
}}
>
<EasyCaptcha
challengeUrl="/api/captcha/challenge"
value={answer}
onChange={setAnswer}
onChallengeChange={(challenge) =>
setChallengeId(challenge?.challengeId ?? null)
}
labels={{
label: "Security code",
refresh: "Get a new code",
}}
/>
<button type="submit" disabled={!challengeId || !answer.trim()}>
Sign in
</button>
</form>
);
}2. Issue endpoint (Next.js App Router example)
import { NextResponse } from "next/server";
import {
createChallenge,
createMemoryStore,
getChallengeCookieOptions,
isCaptchaError,
} from "react-easy-captcha/server";
// Replace with Redis / DB / Payload-backed CaptchaStore in production.
const store = createMemoryStore();
export async function GET(request: Request) {
try {
const { cookieValue, ...challenge } = await createChallenge({
headers: request.headers,
secret: process.env.CAPTCHA_SECRET!, // >= 32 chars
store,
// Generation policy (all optional):
length: 6,
caseSensitive: false,
charset: "mixed", // "mixed" | "letters" | "numbers"
letterPercent: 70,
numberPercent: 30,
// upperPercent: 50, // only when caseSensitive: true
// imageFormat: "svg", // if sharp is not installed
});
const cookie = getChallengeCookieOptions();
const response = NextResponse.json(challenge);
response.cookies.set({ ...cookie, value: cookieValue });
return response;
} catch (error) {
if (isCaptchaError(error)) {
return NextResponse.json(
{ message: error.message },
{ status: error.status },
);
}
return NextResponse.json({ message: "Unavailable" }, { status: 500 });
}
}3. Verify on login / submit
import { verifyChallenge, isCaptchaError } from "react-easy-captcha/server";
await verifyChallenge(
{
answer: body.captchaAnswer,
challengeId: body.captchaChallenge,
headers: request.headers,
honeypot: body.website, // optional; must be empty
},
{
secret: process.env.CAPTCHA_SECRET!,
store,
// Must match createChallenge:
expectedLength: 6,
caseSensitive: false,
},
);Generation options (createChallenge)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| length | number | 6 | Character count (3–12) |
| caseSensitive | boolean | false | Exact case required; enables lowercase glyphs |
| charset | "mixed" \| "letters" \| "numbers" | "mixed" | Which classes to draw from |
| letterPercent | number | 70 | Chance per char to pick a letter when mixed |
| numberPercent | number | 30 | Chance per char to pick a digit when mixed |
| upperPercent | number | 50 | When case-sensitive, chance a letter is uppercase |
If both letterPercent and numberPercent are set, they are treated as weights and normalized to 100.
EasyCaptcha props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| challengeUrl | string | — | GET endpoint that returns { challengeId, expiresAt, image } |
| value | string | — | Controlled answer |
| onChange | (value: string) => void | — | Answer changes |
| onChallengeChange | (challenge \| null) => void | — | Loaded / cleared challenge |
| disabled | boolean | false | Disable input + refresh |
| labels | EasyCaptchaLabels | English defaults | i18n strings |
| classNames | EasyCaptchaClassNames | — | Slot class overrides |
| autoLoad | boolean | true | Fetch on mount |
| autoRefreshOnExpiry | boolean | true | Refresh at expiresAt |
| maxLength | number | 16 | Input maxlength (match server length) |
| caseSensitive | boolean | false | Input UX only (autoCapitalize) |
| showRefresh | boolean | true | Separate refresh button in the label row |
| refreshOnImageClick | boolean | false | Click the image to load a new challenge |
| ref | Ref<EasyCaptchaHandle> | — | refresh(), getSubmission(), … |
Refresh can be done three ways (combine as you like):
// 1) Built-in button (default)
<EasyCaptcha showRefresh challengeUrl={url} value={v} onChange={setV} />
// 2) Click the image
<EasyCaptcha
refreshOnImageClick
showRefresh={false}
challengeUrl={url}
value={v}
onChange={setV}
/>
// 3) Your own control via ref
const captchaRef = useRef<EasyCaptchaHandle>(null);
<button type="button" onClick={() => void captchaRef.current?.refresh()}>
New code
</button>
<EasyCaptcha ref={captchaRef} showRefresh={false} ... />Imperative API:
type EasyCaptchaHandle = {
getChallenge: () => CaptchaChallenge | null;
getStatus: () => "loading" | "ready" | "error";
getAnswer: () => string;
getSubmission: () => {
captchaAnswer: string;
captchaChallenge: string | null;
};
refresh: () => Promise<void>;
focus: () => void;
};Server API (react-easy-captcha/server)
createChallenge(options)→{ challengeId, expiresAt, image, cookieValue }verifyChallenge(input, options)→ consumes challenge or throwsCaptchaErrorcreateMemoryStore()/CaptchaStore— pluggable persistencecreateCaptchaSvg(answer)/renderChallengeImage(svg, format)normalizeCaptchaAnswer, digests, cookie helpers
Custom store
Implement CaptchaStore against your database:
type CaptchaStore = {
create(record: CaptchaRecord): Promise<void>;
findByChallengeId(id: string): Promise<CaptchaRecord | null>;
countRecent(filter: CaptchaCountFilter): Promise<number>;
consume(challengeId: string, consumedAt: Date): Promise<boolean>;
};consume must be atomic (only one concurrent caller gets true).
Theming
CSS variables on .rec-root:
.rec-root {
--rec-accent: #0f766e;
--rec-accent-strong: #115e59;
--rec-border: #d1d5db;
--rec-surface: #f9fafb;
--rec-text: #111827;
--rec-muted: #6b7280;
--rec-radius: 12px;
}Security notes
- Keep
secret≥ 32 characters; never commit it. - Set the binding cookie (
cookieValue) as httpOnly from your issue route. - Prefer same-origin fetches (
credentials: "include"). - Rate limits default to 8 / client and 30 / network per 10 minutes.
- Minimum solve time defaults to 750ms; challenges expire after 2 minutes.
License
MIT
