Skip to main content

Integrating Laravel Captcha into Laravel + React / Next.js

· 23 min read
Mohamed El Amine Meghni
Mohamed El Amine Meghni
Software & DevOps Engineer

Most captcha integrations get bolted onto a login form, annoy every real user forever, and stop the attacker they were meant to stop for about as long as it takes someone to point an AI model at the image.

gts-meghni/laravel-captcha is built around that fact instead of ignoring it. It ships two separate defences that stop two different kinds of attacker, and it expects you to run the invisible one everywhere and the visible one only when a caller has earned it.

This post is in two halves. Part one is the backend, from installing the package to deciding when an image gets demanded. Part two is the frontend, in React, Next.js and Inertia. The two are joined by a small JSON contract, written out at the end of part one, so you can build either side on its own and read only the half you need.

What you get

Two defences. It is worth being precise about what each one is for:

Image challengeProof of work
Asks the user for effortYes, read and typeNo, invisible
Asks the machine for effortNoYes, every attempt
Beaten by an AI model that can seeYesNo, there is nothing to look at
Beaten by fast custom codeNot relevantYes, at a low setting

Proof of work is the one you turn on everywhere. The server hands the browser a random string, and the browser has to find a number that, when hashed together with that string, produces a result starting with a run of zeros. There is no clever shortcut, only guessing, so it costs real CPU time. A real user pays about 45 milliseconds at the default setting and never notices. A script pays the same on every single attempt, which is the whole point.

The image challenge is the one you ration. The package's own testing put an AI model at 4 correct reads out of 10 on the default image, against 0 out of 100 for Tesseract, the classic text-reading software the distortion was originally designed to beat. So the image still filters cheap, unsophisticated bots. It is not a wall.

The honest version

Neither defence proves someone is human, and neither replaces locking an account after too many failed logins. What they buy you is a cost per attempt and a cap per account. Design around that and the settings below make sense. Design around "prove you are human" and they will not.


Part one: the backend

Everything in this half is Laravel. At the end of it you have working endpoints and enforcement, and you can test the whole thing with curl before any frontend exists.

1. Install

composer require gts-meghni/laravel-captcha

You need PHP 8.3 or newer, Laravel 12 or 13, and one of the two PHP image extensions, gd or imagick. Check before you deploy, because if it is missing you find out when someone requests an image, not when the app boots:

php -m | grep -E 'gd|imagick'

Publish the config file:

php artisan vendor:publish --tag="laravel-captcha-config"

That is the only one you need to start. The rest are optional. Publish laravel-captcha-lang to reword the error messages (English, French and Arabic are included), and laravel-captcha-fonts or laravel-captcha-backgrounds only if you want to add your own fonts or background textures. Otherwise they are read straight from the package.

Set driver to whichever extension you have. It is only consulted if your app has not already registered its own Intervention image manager, in which case yours wins:

'driver' => 'gd',   // gd | imagick

2. Pick a cache store, because this one bites

Challenges live in the cache, not in a database table of their own. Each stored challenge is a couple of hundred bytes: which preset it used, a number used to redraw the image, a scrambled copy of the answer, the time it was issued, and optionally the IP address it was issued to.

Two things decide whether a cache store works at all:

Shared by every serverRequired. One request hands out the token, a different request draws the image. If your cache is per-process (array) or a file cache inside one container, you hand out image links that return 404 from the next server.
Cleans up after itselfStrongly preferred. Laravel only removes an expired entry when something tries to read it, and an abandoned challenge is never read again.

Redis does both. If you are on the file or database cache, abandoned challenges pile up, one file or one row each, so schedule the cleanup command:

// routes/console.php
Schedule::command('captcha:prune')->hourly();

It does nothing on Redis and memcached, so it is safe to schedule everywhere.

Give it its own cache store
'cache' => [
'store' => 'captcha', // a dedicated store or Redis connection
'prefix' => 'captcha',
'expire' => 120,
],

If it shares the app's cache, one php artisan cache:clear deletes every challenge currently on someone's screen, and every user halfway through a form is told their answer is wrong.

There is a second reason on the file cache: it scrambles its keys, so the cleanup command cannot tell which entries are captchas and sweeps every expired entry in the folder.

3. Require the invisible check

Installing the package registers three public endpoints, all rate limited per IP address (60, 120 and 60 requests a minute by default):

GET /api/captcha             asks for an image challenge
GET /api/captcha/{token}.png returns that image
GET /api/captcha/pow asks for a proof-of-work challenge

The prefix already includes its own api/ part. These routes are registered by the package rather than from your routes/api.php, so nothing gets added in front of them.

Handing out challenges does nothing on its own. Enforcement is a validation rule:

use GtsMeghni\LaravelCaptcha\Validation\Rules\ProofOfWork;

$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'pow_token' => ['required', 'string'],
'pow_nonce' => ['required', 'string', new ProofOfWork],
]);

The rule reads pow_token off the request itself, so the two fields travel together. If your payload already uses different names, point the config at them instead of changing the frontend:

'pow' => [
'fields' => ['token' => 'pow_token', 'nonce' => 'pow_nonce'],
],

You can verify this half completely from a terminal. Ask for a challenge, send a wrong answer, and confirm you get a 422 back. No browser needed.

4. Let the cost rise with suspicion

One fixed setting cannot serve both a first-time visitor on a cheap phone and a script on its five hundredth attempt. Two counters raise it:

'pow' => [
'enabled' => true,
'difficulty' => 16, // the floor everyone pays
'escalate' => true,
'step' => 2, // added per recorded failure
'max_difficulty' => 24, // ceiling
'volume' => [
'every' => 20, // challenges requested
'step' => 2, // added per that many
],
'expire' => 120,
],

Each step doubles the work:

FailuresSettingCost to a personWhat an attacker gets from one CPU core
016~45 ms, unnoticed443 solves a minute
118~250 ms111 solves a minute
220~1 second22 solves a minute
424~17 seconds1.4 solves a minute

The volume counter is the interesting one. An attacker using an AI model to read the image has an easy winning move: throw away any image it cannot read confidently and ask for another one. Three tries at 40% accuracy succeed 78% of the time. Charging for requests, not just failures, is what stops that from being free.

Solve time varies a lot, it is not a fixed number

The typical time at the default setting is about 45 milliseconds in a desktop browser, but roughly one attempt in a hundred takes ten times that, and a phone is three to five times slower again. Measured at setting 18 on a desktop: 116 ms, 175 ms, and then one at 1437 ms.

Raise the floor only against numbers you measured on the slowest device you support. Part two shows how to keep the slow case reading as "preparing" rather than as a broken form.

The setting a challenge was issued with is stored with it, so raising the floor while people are mid-form never throws away work a browser has already done.

5. Demand an image only when it is earned

A captcha on every login taxes every real user daily to inconvenience a bot that an AI model already beats. Ask after a couple of failures instead. The package keeps the counter. The decision stays in your controller, next to your login logic.

'escalation' => [
'after' => 2, // failures allowed before an image is demanded
'decay' => 900, // seconds a failure is remembered
],
use GtsMeghni\LaravelCaptcha\Facades\Captcha;
use GtsMeghni\LaravelCaptcha\Validation\Rules\Captcha as CaptchaRule;
use GtsMeghni\LaravelCaptcha\Validation\Rules\ProofOfWork;

public function store(Request $request)
{
// Count against the account being attacked, not the address it came from.
$key = 'login:'.$request->input('email');

$rules = [
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'pow_token' => ['required', 'string'],
'pow_nonce' => ['required', 'string', new ProofOfWork],
];

if (Captcha::requiredFor($key)) {
$rules['captcha_token'] = ['required', 'string'];
$rules['captcha'] = ['required', 'string', new CaptchaRule];
}

$request->validate($rules);

if (! Auth::attempt($request->only('email', 'password'))) {
Captcha::recordFailure($key);

return response()->json([
'message' => 'Those credentials do not match our records.',
// Tell the frontend what to show next time instead of making it guess.
'captcha_required' => Captcha::requiredFor($key),
], 422);
}

Captcha::clearFailures($key); // clears both counters

return response()->json(['message' => 'Signed in.']);
}
Count per account, not per IP address

An office shares one address behind its router, and a botnet does not share anything. Keying on 'login:'.$email caps the attack on that account no matter where it comes from. The same counter also raises the invisible cost, so an attacker cannot dodge one defence without paying for the other.

Returning captcha_required in the failed response is the one piece of API design that makes part two easy. Without it the frontend is either loading an image nobody needs or finding out it needed one only after a second rejection.

6. Guarding a whole route instead

Where there is no escalation logic to write, like a public contact form, skip the rules and use the middleware:

Route::post('/contact', ContactController::class)->middleware('captcha');

It reads the same config and throws a normal Laravel validation error, so a failure comes back as a 422 in the usual shape.

And if your form already has names for these fields, point the config at them instead of changing the frontend:

'fields' => ['token' => 'recaptchaToken', 'answer' => 'userInput'],

That is the path off a hosted captcha service without touching the client at all: keep the field names, swap what checks them.

7. Two cheap checks, and one deployment trap

'min_seconds' => 1.5,   // reject answers faster than a person could type them
'bind_ip' => false, // require the answer from the address that asked

min_seconds costs nothing and removes scripts that answer in milliseconds. It does not stop one that waits on purpose.

bind_ip closes the paid-solving-service route, where a page sends the image out and the answer comes back from a different machine. It is off by default for a good reason: a phone that switches from wifi to mobile data mid-form changes address and gets rejected through no fault of its own. Turn it on where clients are stable, like an internal tool or an admin panel, and leave it off for consumer mobile traffic. It applies to the invisible check too.

Behind a load balancer, configure TrustProxies first

Everything that reads an address reads the client IP: bind_ip, the rate limits on all three endpoints, and the request counter. If Laravel sits behind a proxy and TrustProxies is not configured, every request looks like it came from the proxy, and it fails in the wrong direction.

bind_ip compares one shared address to itself, so it passes for everyone while still looking enabled. A limit of 60 a minute becomes 60 a minute for the entire site, so one attacker locks out all your real users. And the request counter climbs on total traffic, so the cost goes up for visitors who have done nothing.

8. Tuning the image, if you have to

One preset ships: default, six characters, capitals ignored. Preview changes without opening a browser or building any frontend:

php artisan captcha:preview --preset=default --count=5

The distortions worth keeping are the ones a bot cannot strip away separately from the letters. Wiping out a background is one easy operation and works on any texture, so the package leans on effects that are tangled up with the letters themselves: letters that touch and overlap (which breaks the step where software splits an image into individual characters before reading them), a wave applied across the whole finished image, speckles drawn in the same colours as the letters so they cannot be filtered out without losing the letters, and a per-letter tilt and wobble.

Add characters, not distortion

More characters multiply a bot's error rate without making any single one harder for a person. More distortion does the opposite. If you need the image to be stronger, raise length before you touch angle.

Registering your own kind of challenge is two lines if the text one does not fit:

app(ChallengeManager::class)->extend('words', new WordChallenge);

// config/captcha.php
'phrase' => ['type' => 'words', 'width' => 260],

The contract between the halves

That is the backend done. Everything part two needs is here, and nothing else:

The frontend callsIt gets back
GET /api/captcha/pow{ token, salt, difficulty, algorithm, expires_in, expires_at }
GET /api/captcha{ token, url, expires_in, expires_at }
The frontend submitsAlongside
pow_token and pow_nonceevery request you want to protect
captcha_token and captchaonly when the server asked for it

Two details from that table are worth carrying into part two.

The image comes back as a link, not as image data. So a strict security policy of img-src 'self' still displays it, the image is only drawn when someone actually requests it, and redrawing gives identical bytes because it is regenerated from a stored number.

And if you show a countdown, use expires_in, the number of seconds, not expires_at. A device whose clock disagrees with the server cannot get a relative number wrong.


Part two: the frontend

Nothing below imports Laravel or PHP. It talks to the contract above.

1. Install the browser client

npm install @gts-meghni/laravel-captcha

The core uses only fetch and the browser's built-in crypto. React is optional and lives behind a separate /react import path, so a Vue, Svelte or plain-JavaScript frontend uses the same package through the main import.

import { obtainPow, fetchImageChallenge } from '@gts-meghni/laravel-captcha';        // any framework
import { usePow, useImageCaptcha } from '@gts-meghni/laravel-captcha/react'; // React hooks

Nothing forces you to use it. The endpoints return plain JSON and you can call them yourself. But it saves you rewriting the guessing loop, and that loop matters more than it looks (see why the package ships its own hashing code).

2. The invisible check, on every submission

Start here, before you touch the image. It is invisible, it costs the user nothing, and it is the half an AI model cannot help with.

'use client';

import { usePow } from '@gts-meghni/laravel-captcha/react';
import { useState, type FormEvent } from 'react';

export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

// Runs on mount. Nothing is rendered for it.
const pow = usePow();

async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();

if (pow.fields === null) return; // still working, or it failed

try {
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, ...pow.fields }),
});
} finally {
// Each token can only be used once. Get a fresh one either way.
await pow.refresh();
}
}

return (
<form onSubmit={submit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
<button disabled={pow.status !== 'ready'}>
{pow.status === 'solving' ? 'Preparing…' : 'Sign in'}
</button>
</form>
);
}

pow.fields is either { pow_token, pow_nonce } or null. The hook also gives you status (idle, solving, ready, error), a running hashes count if you want a progress bar, solution (which carries hashes and ms), and error.

The disabled on the button is doing real work. Solve times vary a lot, so keeping the button disabled until status is ready is what turns the occasional slow case into a button that says "preparing" instead of a form that looks broken.

Refresh in a finally, not only on success

The token is used up by any check, including a failed one. If you only refresh after a success, the second attempt after a mistyped password fails on the invisible check instead of the password, and your user gets a confusing error message about something they never saw.

3. The image challenge component

'use client';

import { useImageCaptcha } from '@gts-meghni/laravel-captcha/react';

export function ImageChallenge() {
const captcha = useImageCaptcha(); // useImageCaptcha('phrase') for another preset

return (
<div>
{captcha.challenge && (
<img src={captcha.challenge.url} alt="Type the characters shown" />
)}

<button type="button" onClick={() => void captcha.refresh()}>
New image
</button>

<input
value={captcha.answer}
onChange={(event) => captcha.setAnswer(event.target.value)}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
/>

{captcha.error && <p role="alert">{captcha.error.message}</p>}
</div>
);
}

captcha.fields gives you { captcha_token, captcha } once the user has typed something, and null before that.

Those three auto* attributes are not decoration. Phone keyboards capitalise the first letter by default, and although the default preset ignores capitals, autocorrect rewriting six random characters into a real word is a genuine source of "but I typed it correctly" support tickets.

4. Putting both into one submission

The backend told you whether an image is needed, in captcha_required. Hold that in state, and spread both sets of fields into the same body:

const [captchaRequired, setCaptchaRequired] = useState(false);

const pow = usePow();
const captcha = useImageCaptcha();

async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();

if (pow.fields === null) return;
if (captchaRequired && captcha.fields === null) return; // image not answered yet

try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
...pow.fields,
...(captchaRequired && captcha.fields !== null ? captcha.fields : {}),
}),
});

if (!response.ok) {
const body = await response.json();
setCaptchaRequired(body.captcha_required === true);
}
} finally {
// Both tokens are single use, so replace them either way.
void pow.refresh();
void captcha.refresh();
}
}

Render <ImageChallenge /> only when captchaRequired is true, and the ratcheting behaviour falls out on its own: nobody sees an image until the backend says the account has failed enough times.

5. Next.js on a separate domain

Both hooks run in the browser, and the package marks itself as client-side, so importing them into a server component is a build error rather than a mystery at runtime. Mark the component that uses them with 'use client'.

If Next.js and Laravel are on the same domain, you are already done. If they are not, you have two options.

Option A: a rewrite, so the calls stay same-domain

// next.config.js
async rewrites() {
return [
{
source: '/api/captcha/:path*',
destination: 'https://api.example.test/api/captcha/:path*',
},
];
}

Prefer this one. No CORS to configure, no extra round trip before each request, and no backend hostname baked into the JavaScript you ship to users. Same reasoning as the reverse proxy pattern.

Option B: point the client at the API domain

const pow = usePow({ baseUrl: 'https://api.example.test' });
const captcha = useImageCaptcha(undefined, { baseUrl: 'https://api.example.test' });

Now you own the CORS setup on the Laravel side, and because the image arrives as a link on the API domain, your content security policy has to allow images from there too.

Both hooks take the same options:

{
baseUrl: 'https://api.example.test', // leave out if same-domain
prefix: 'api/captcha', // must match captcha.routes.prefix
fetchOptions: { credentials: 'include' },
chunkSize: 25_000, // guesses between pauses
maxHashes: 300_000_000, // fail loudly rather than hang
}
prefix is a value both halves share

If you change captcha.routes.prefix in PHP, you have to pass the matching prefix in JavaScript. Nothing catches the mismatch for you. You get a 404, which surfaces as a CaptchaRequestError with status: 404.

6. Two errors worth handling separately

import { CaptchaRequestError, PowGaveUpError } from '@gts-meghni/laravel-captcha';
  • CaptchaRequestError carries status and isRateLimited. A 429 means stop and wait, not retry. The endpoints are rate limited per IP, and a retry loop turns one temporarily blocked user into a permanently blocked one.
  • PowGaveUpError means the browser could not finish within maxHashes. In practice that means the server setting is too high for a browser. Treat it as an alarm about backend config, not as something to show users.

7. The Inertia version

On Laravel with Inertia and React, the two halves share a request, so the server already knows whether an image is needed when it renders the page. That removes the captcha_required state juggling from step 4:

return Inertia::render('login', [
'captchaRequired' => Captcha::requiredFor($key),
'failures' => Captcha::escalation()->attempts($key),
'threshold' => Captcha::escalation()->threshold(),
]);
const { captchaRequired } = usePage<{ captchaRequired: boolean }>().props;

const pow = usePow();
const captcha = useImageCaptcha();

function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();

if (pow.fields === null) return;

router.post('/login', {
email,
password,
...pow.fields,
...(captchaRequired && captcha.fields !== null ? captcha.fields : {}),
}, {
onFinish: () => {
void pow.refresh();
void captcha.refresh();
},
});
}

One wrinkle. The page is loaded by a GET request, which carries no email address, so the counters you display belong to a different key than the one the submission will use. Remember the last address attempted in the session if you want the numbers on screen to be honest:

$request->session()->put('login-email', $request->input('email'));

Why the package ships its own hashing code

Worth knowing, because it looks like a warning sign until you see the numbers. The browser's built-in hashing function is asynchronous, and the overhead of one promise per hash caps you at around 50,000 guesses a second. The hand-written loop in the package reaches roughly a million. At the default setting that is the difference between five seconds and a fifth of a second, which is the difference between an invisible defence and a form people give up on.

It is checked against Node's own crypto library on known test values and at every message length from 50 to 130 bytes, because the padding at block boundaries is the easiest part of SHA-256 to get subtly wrong.


Troubleshooting

Backend symptoms

SymptomLikely cause
Image link 404s, or answers are always wrongThe cache is not shared across servers (array, or a file cache inside one container). One request issues the token, another draws the image.
Everyone mid-form is suddenly told their answer is wrongSomeone ran php artisan cache:clear on a shared cache. Give the captcha its own store.
The image is blank or the endpoint returns a 500Neither gd nor imagick is installed, or driver names the one that is not.
Cache table or storage folder keeps growingcaptcha:prune is not scheduled. Abandoned challenges on file and database are never read, so Laravel never removes them.
Real users hitting rate limits, or bind_ip never rejectsTrustProxies is not configured, so every request looks like it came from the load balancer.
captcha:preview reports the challenge expiredSame cache problem as the first row. Preview draws through the same store a live challenge does.

Frontend symptoms

SymptomLikely cause
CaptchaRequestError with status: 404prefix in JavaScript does not match captcha.routes.prefix in PHP.
CaptchaRequestError with isRateLimitedYou hit the per-IP rate limit. Stop and wait, do not retry. If it is happening to real users, it is the backend TrustProxies row above.
PowGaveUpErrorThe backend setting is too high for a browser. Lower pow.difficulty and max_difficulty.
The second submission fails on the invisible checkrefresh() is not being called after a failed submission. Tokens are used up either way.
Build error about 'use client' when importing the hooksA server component is importing from /react. Both hooks run in the browser only.
Image blocked by the content security policyYour policy does not allow images from the API domain. Prefer a rewrite so it is same-domain.
The button never becomes enabledpow.error is set. Check it, most often a 404 from a prefix mismatch or a blocked cross-domain request.

Production checklist

Backend
  • gd or imagick installed in the production image, matching captcha.driver
  • Cache store shared across servers, and dedicated, not the app's default
  • captcha:prune scheduled if the store is file or database
  • TrustProxies configured, so rate limits and counters see the real client address
  • The invisible check required on every sensitive endpoint, not just login
  • Counters keyed on the account being attacked, not the request IP
  • captcha_required returned from failed attempts so the frontend does not guess
  • pow.difficulty measured on the slowest device you support, not on your laptop
  • Per-account lockout still in place. This raises the cost, it does not replace lockout.
Frontend
  • refresh() called in a finally, so failed submissions get a fresh token
  • Submit button disabled until pow.status is ready
  • prefix matches the backend if you changed it there
  • A 429 response backs off instead of retrying
  • The image challenge rendered only when the backend asked for it
  • autoComplete, autoCapitalize and autoCorrect off on the answer field
  • Content security policy allows the image, or a rewrite keeps it same-domain

Conclusion

The integration itself is small: one Composer package on one side, one npm package and one hook on the other, joined by four field names. What takes thought is the policy, and the package is opinionated about that on purpose. Run the invisible check everywhere, because it costs users nothing. Ask for an image only when a specific account has started failing. Count against what is being attacked rather than where the traffic appears to come from.

The two details that cause the most trouble in production are both on the backend, and neither has anything to do with captchas. The cache store has to be shared and dedicated, and TrustProxies has to be right or every per-IP mechanism quietly collapses onto one key.

The limits are worth repeating too, because they tell you where to spend effort next. An AI model reads the image. The invisible check raises the price of each attempt but does not prove anyone is human, and an attacker who rewrites the hashing loop in fast native code is not slowed down much at browser-friendly settings. The counters only work on the key you hand them, so an attack spread across many accounts starts each one at the floor. None of it replaces locking accounts, watching for unusual behaviour, or asking for something genuinely hard to get in bulk, like a verified phone number, where fake accounts are the real concern.

Reference

  • Composer package: gts-meghni/laravel-captcha (GitHub)
  • npm package: @gts-meghni/laravel-captcha (GitHub)
  • React import path: @gts-meghni/laravel-captcha/react
  • Backend files you will touch: config/captcha.php, routes/console.php, your login controller
  • Frontend files you will touch: the form component, next.config.js