Adding reCAPTCHA v3 to Laravel, React and Inertia forms

In the EDR forms project, signing in is a small part of a longer form journey. I wanted to reduce automated submissions without asking everyone to solve an image challenge before they could get started.

I used Google reCAPTCHA v3 alongside Laravel, React and Inertia. The useful part of that arrangement is the separation: React gets a token, Laravel decides whether to accept it, and the normal authentication flow carries on afterwards.

This tutorial is an adapted example from that work. I’ve simplified the form markup and tightened the verification so the pattern can be reused across login, registration and forgotten passwords. The snippets are a teaching version, not a verbatim copy of the EDR application.

Implementation notes reviewed: 28 September 2026. Stack: Laravel 12, Inertia 2, React 19, TypeScript and reCAPTCHA v3.

What we’re building

One server-side verifier, used once for each protected request. Each form gets a fresh token when someone submits it. Failed verification returns a readable form error before credentials are checked, an account is created or a reset email is requested.

I’m keeping this on the public authentication screens. Loading a script throughout an application does not protect every endpoint by itself. The server must check each action you choose to protect.

Before you start

  • An existing Laravel 12 application with working session authentication and Inertia’s React adapter. Keep its CSRF protection, validation, password handling and rate limits.
  • A working login, registration and password-reset flow. The code below adds a verification step; it does not replace those flows.
  • A reCAPTCHA v3 site key and server secret for your configured domains. This example uses the siteverify API, not Enterprise assessments. Use separate development credentials and approved local hostnames.

The EDR frontend uses @google-recaptcha/react. There are several similarly named packages, so check the import before copying a hook from another example.

Terminal

npm install @google-recaptcha/react

1. Configure the keys

Only the site key belongs in the browser. I keep the secret in Laravel’s environment configuration. It should never become an Inertia prop or a variable with a VITE_ prefix.

.env, placeholder values

RECAPTCHA_SITE_KEY=your-public-v3-site-key
RECAPTCHA_SECRET=your-server-side-secret
RECAPTCHA_HOSTNAMES=forms.example.org
RECAPTCHA_MINIMUM_SCORE=0.5

config/services.php

// Add this entry to the array returned by config/services.php.
'recaptcha' => [
    'site_key' => env('RECAPTCHA_SITE_KEY'),
    'secret' => env('RECAPTCHA_SECRET'),
    'hostnames' => array_values(array_filter(array_map(
        'trim', explode(',', env('RECAPTCHA_HOSTNAMES', ''))
    ))),
    'minimum_score' => (float) env('RECAPTCHA_MINIMUM_SCORE', 0.5),
],

Replace the hostname with your real domain, without a scheme or path. Add the local hostname to a development configuration when testing. The expected hostname comes from this configuration, not from a value sent by the visitor.

The EDR app already shares the public key through its Inertia middleware. Add this alongside your existing shared props, keeping the rest of the method intact:

app/Http/Middleware/HandleInertiaRequests.php, share() return array

'recaptchaSiteKey' => config('services.recaptcha.site_key'),

After changing environment settings, clear stale local configuration with php artisan config:clear. Rebuild the configuration cache as part of your normal production deployment.

2. Load reCAPTCHA on auth pages

I like keeping the provider close to the screens that use it. In this application the Inertia components are named auth/login, auth/register and auth/forgot-password. A small boundary can mount the provider for that group and remove it when someone moves into the application.

resources/js/components/RecaptchaBoundary.tsx

import { GoogleReCaptchaProvider } from '@google-recaptcha/react';
import { usePage } from '@inertiajs/react';
import { type ReactNode } from 'react';

export function RecaptchaBoundary({ children }: { children: ReactNode }) {
    const { component, props } = usePage<{
        recaptchaSiteKey: string;
    }>();

    if (!component.startsWith('auth/')) return children;

    return (
        <GoogleReCaptchaProvider type="v3" siteKey={props.recaptchaSiteKey}>
            {children}
        </GoogleReCaptchaProvider>
    );
}

In resources/js/app.tsx, import that component and wrap the page inside Inertia’s render callback. Keep it inside App so usePage() has the correct context. Keep your existing resolver, theme setup and other providers.

resources/js/app.tsx, inside root.render()

<App {...props}>
    {({ Component, key, props: pageProps }) => (
        <RecaptchaBoundary>
            <Component key={key} {...pageProps} />
        </RecaptchaBoundary>
    )}
</App>

That boundary controls script placement. The middleware in step five will control which requests need a valid token. They have different jobs.

3. Get a token on submission

I want the token to be fresh when Laravel receives it. Google’s verification documentation says tokens expire after two minutes and can only be verified once. Generating one when a page first opens is a poor fit for someone who takes a while to fill out a form.

This small hook uses the form’s submit event, so clicking the button and pressing Enter follow the same path. A ref prevents a second submission while the first token is being requested. The token is added directly to FormData, avoiding a race with React state updates.

resources/js/hooks/use-recaptcha-form.ts

import { useGoogleReCaptcha } from '@google-recaptcha/react';
import { router } from '@inertiajs/react';
import { useRef, useState, type FormEvent } from 'react';

type Action = 'login' | 'register' | 'forgot_password';

export function useRecaptchaForm(action: Action, url: string) {
    const { executeV3, isLoading } = useGoogleReCaptcha();
    const inFlight = useRef(false);
    const [busy, setBusy] = useState(false);
    const [error, setError] = useState<string | null>(null);

    async function submit(event: FormEvent<HTMLFormElement>) {
        event.preventDefault();
        if (inFlight.current) return;

        const form = event.currentTarget;
        const data = new FormData(form);
        inFlight.current = true;
        setBusy(true);
        setError(null);
        const release = () => {
            inFlight.current = false;
            setBusy(false);
        };

        let timeout: number | undefined;
        try {
            if (isLoading || !executeV3) throw new Error('Not ready');
            const token = await Promise.race([
                executeV3(action),
                new Promise<string>((_, reject) => {
                    timeout = window.setTimeout(() => reject(new Error('Timeout')), 10000);
                }),
            ]);
            window.clearTimeout(timeout);
            if (!token) throw new Error('No token');
            data.set('recaptcha_token', token);

            router.post(url, data, {
                preserveScroll: true,
                onError: (errors) => setError(errors.recaptcha ?? null),
                onFinish: () => {
                    form.querySelectorAll<HTMLInputElement>('input[type="password"]')
                        .forEach((input) => { input.value = ''; });
                    release();
                },
            });
        } catch {
            window.clearTimeout(timeout);
            setError('Verification is unavailable. Please try again in a moment.');
            release();
        }
    }

    return { submit, busy, error };
}

Here is the forgotten-password form with styling left out. It still has a label, a field error, an announced verification error and a clear busy state. In the actual interface I keep those details consistent with the other account screens.

resources/js/pages/auth/forgot-password.tsx

import { usePage } from '@inertiajs/react';
import { useRecaptchaForm } from '@/hooks/use-recaptcha-form';

export default function ForgotPassword({ status }: { status?: string }) {
    const { errors } = usePage().props;
    const { submit, busy, error } = useRecaptchaForm(
        'forgot_password', '/forgot-password',
    );

    return (
        <form onSubmit={submit} aria-busy={busy}>
            {status && <p role="status">{status}</p>}
            <label htmlFor="email">Email address</label>
            <input
                id="email" name="email" type="email"
                autoComplete="email" required
                placeholder="you@example.org"
                aria-invalid={Boolean(errors.email)}
                aria-describedby={errors.email ? 'email-error' : undefined}
            />
            {errors.email && <p id="email-error">{errors.email}</p>}
            <p role="alert">{error}</p>
            <button type="submit" disabled={busy}>
                {busy ? 'Checking…' : 'Email password reset link'}
            </button>
        </form>
    );
}

Apply the same hook to login and registration, preserving their existing field names and field-error messages:

  • Login: useRecaptchaForm('login', '/login').
  • Registration: useRecaptchaForm('register', '/register').
  • Forgotten password: useRecaptchaForm('forgot_password', '/forgot-password').

Use a normal <form onSubmit={submit}> with this hook. Do not also leave an Inertia Form or a button click handler submitting the same form. Keep one submission path and retain your existing layout, links and validation messages. No credentials are passed to Google by this code; the verification call receives the token.

4. Verify it in Laravel

The browser can tell me that it obtained a token. It cannot tell me that I should trust it. I put that decision in a service so all three endpoints use the same checks.

The original project separates verification into RecaptchaService. For this example I use Laravel’s HTTP client with a form-encoded POST and explicit timeouts. It is also straightforward to fake in tests. The secret stays in the request body rather than the URL.

app/Services/RecaptchaService.php

<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class RecaptchaService
{
    public function verify(Request $request, string $action): bool
    {
        $token = $request->input('recaptcha_token');
        $secret = config('services.recaptcha.secret');
        $hostnames = config('services.recaptcha.hostnames', []);
        $minimum = (float) config('services.recaptcha.minimum_score', 0.5);

        if (!is_string($token) || $token === '' || strlen($token) > 4096
            || !is_string($secret) || $secret === '' || !$hostnames) {
            return false;
        }

        try {
            $response = Http::asForm()
                ->connectTimeout(2)
                ->timeout(5)
                ->post('https://www.google.com/recaptcha/api/siteverify', [
                    'secret' => $secret,
                    'response' => $token,
                ]);
        } catch (ConnectionException) {
            return false;
        }

        $result = $response->json();
        if (!$response->successful() || !is_array($result)) {
            return false;
        }

        $score = $result['score'] ?? null;

        return ($result['success'] ?? false) === true
            && ($result['action'] ?? null) === $action
            && in_array($result['hostname'] ?? null, $hostnames, true)
            && is_numeric($score)
            && (float) $score >= $minimum
            && (float) $score <= 1.0
            && (float) $score >= 0.0;
    }
}

This checks success, score, the expected action and a configured hostname. The action is supplied by the route, not the submitted form. A token created for registration should not pass a login check. A missing response or a network timeout returns a failure instead of quietly allowing the request.

The example starts at a score of 0.5. That is a starting configuration, not proof that every visitor below it is a bot. Review real traffic and false positives before deciding on a threshold or a stronger verification step. Google explains that trade-off in its v3 guidance.

5. Protect the three routes

I’m using route middleware in the teaching version to make the coverage easy to see. Laravel can resolve this service automatically. If your project already has a service provider or an alias for it, you do not need a second instance or a second verification call.

app/Http/Middleware/VerifyRecaptcha.php

<?php

namespace App\Http\Middleware;

use App\Services\RecaptchaService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;

class VerifyRecaptcha
{
    public function handle(Request $request, Closure $next, string $action): Response
    {
        if (!app(RecaptchaService::class)->verify($request, $action)) {
            throw ValidationException::withMessages([
                'recaptcha' => 'We could not verify this request. Please try again.',
            ]);
        }

        return $next($request);
    }
}

Add the alias inside your existing withMiddleware callback in bootstrap/app.php. Keep any aliases already registered there.

bootstrap/app.php, existing withMiddleware callback

$middleware->alias([
    'recaptcha' => \App\Http\Middleware\VerifyRecaptcha::class,
]);

Update the existing POST route declarations inside the guest group in routes/auth.php. Do not register duplicate routes. These controller imports are the ones used by the Laravel starter-kit structure in this project:

routes/auth.php, replace the existing POST declarations

use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::post('login', [AuthenticatedSessionController::class, 'store'])
        ->middleware(['throttle:10,1', 'recaptcha:login'])
        ->name('login.store');

    Route::post('register', [RegisteredUserController::class, 'store'])
        ->middleware(['throttle:10,1', 'recaptcha:register'])
        ->name('register.store');

    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
        ->middleware(['throttle:10,1', 'recaptcha:forgot_password'])
        ->name('password.email');
});

The throttle shown here is an example of an outer request limit. Keep your existing credential and reset-email limits, and choose limits that suit the service. If a controller or request class already verifies reCAPTCHA, move that responsibility to the middleware and remove the old call. Verify each token once.

The GET routes stay as they are. The password broker should still return a generic confirmation such as “A reset link will be sent if the account exists.” Do not expose whether an email address belongs to an account.

To cover another action, give it an explicit action name, generate a fresh token in that form and attach the verifier to its POST route. Avoid putting this middleware on every request: an authenticated page visit, webhook or background request should not accidentally become dependent on a browser challenge.

Check the behaviour

I start with the server decision. Faking Google’s response lets me test the checks without making real requests or sending reset emails. This Pest example belongs in a Laravel test suite:

tests/Feature/RecaptchaServiceTest.php

<?php
// Pest example for tests/Feature/RecaptchaServiceTest.php.
use App\Services\RecaptchaService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

it('accepts only a matching action and hostname', function () {
    config()->set('services.recaptcha', [
        'secret' => 'test-secret',
        'hostnames' => ['forms.example.org'],
        'minimum_score' => 0.5,
    ]);
    Http::preventStrayRequests();
    Http::fake([
        'www.google.com/recaptcha/api/siteverify' => Http::response([
            'success' => true, 'score' => 0.9,
            'action' => 'login', 'hostname' => 'forms.example.org',
        ]),
    ]);
    $request = Request::create('/login', 'POST', [
        'recaptcha_token' => 'test-token',
    ]);
    $service = app(RecaptchaService::class);

    expect($service->verify($request, 'login'))->toBeTrue();
    expect($service->verify($request, 'register'))->toBeFalse();
});

The fake deliberately returns the same payload each time so those assertions can isolate the action check. Real tokens cannot be reused. Extend the tests to cover these cases before using the integration:

  • No token, an expired or already-used token, a low score, a wrong hostname and an unexpected action all fail.
  • A timeout, non-JSON response or error response from Google fails without reaching the controller.
  • A rejected registration creates no account. A rejected reset request sends no notification. Use database isolation and Notification::fake() in those feature tests.
  • A valid token reaches the existing authentication flow once. Normal validation errors still return to the correct fields.

Then I check the interface with a keyboard as well as a mouse: submit with Enter, click twice quickly, wait on the page before submitting, retry after a field error, and block the Google script. The user should get a useful error and a way to retry, not a spinner that runs forever.

Navigate from an auth page into the dashboard and back without a full reload too. That catches provider-lifecycle problems that a single hard refresh can miss.

Things to watch

  • Verify once. Calling Google in both middleware and a controller can turn a valid request into a duplicate-token failure.
  • Keep the token out of long-lived state. Generate a new one for every submission attempt, including retries after validation errors.
  • Do not log raw authentication requests. Keep passwords, tokens, secrets and form content out of diagnostics. Record coarse failure categories if you need operational visibility.
  • Keep an alternative way to get help. Script blocking and false positives happen. An unavailable verification service should not leave someone guessing what to do next.
  • Keep Google’s required attribution visible. Do not hide the badge simply to tidy up the layout. Include reCAPTCHA in the site’s privacy information.

What I like about this structure is that the account screens stay fairly ordinary. The extra behaviour has a clear beginning and end: get a token, verify it once, then let Laravel handle the account task. It gives me a small piece of code I can reason about without rewriting the authentication system around it.

References

More about me