Contact forms are one of the most common targets for automated bots. Spam submissions can fill your inbox, trigger unnecessary emails, and abuse public endpoints.
One practical way to protect a Laravel contact form is Google reCAPTCHA v2 Checkbox, which adds the familiar:
☐ I'm not a robot
verification before allowing the request to be processed.
In this guide, we'll integrate Google reCAPTCHA v2 into a Laravel form, verify the token securely on the server, reset the CAPTCHA after submission, and troubleshoot the most common production errors.
1. Create Google reCAPTCHA Keys
First, register your website with Google reCAPTCHA.
Open the official reCAPTCHA administration page and create a new site.
Choose:
Challenge (v2) → "I'm not a robot" Checkbox
Then add your website domain.
For example:
example.com
Do not enter:
https://example.com/contact
Google expects a hostname/domain rather than a complete URL. A registered domain also covers its first-level subdomains.
After registration, Google provides two credentials:
- Site Key
- Secret Key
The Site Key is used in frontend HTML.
The Secret Key must remain private and should only be used by your backend.
2. Store the Keys in Laravel .env
Never hard-code the secret key inside your Blade template, JavaScript, controller, or Git repository.
Add the credentials to .env:
BASHRECAPTCHA_SITE_KEY=your_site_key_hereRECAPTCHA_SECRET_KEY=your_secret_key_here
Do not commit your production .env file to GitHub.
Never hard-code the secret key inside your Blade template, JavaScript, controller, or Git repository.
Add the credentials to .env:
RECAPTCHA_SITE_KEY=your_site_key_hereRECAPTCHA_SECRET_KEY=your_secret_key_hereDo not commit your production .env file to GitHub.
3. Add reCAPTCHA Configuration
Open:
config/services.php
Add:
BASH'recaptcha' => [ 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret_key' => env('RECAPTCHA_SECRET_KEY'),],
You can now access the credentials through:
BASHconfig('services.recaptcha.site_key')
and:
BASHconfig('services.recaptcha.secret_key')
After changing environment/configuration values on production, clear Laravel's cached configuration:
BASHphp artisan optimize:clear
Open:
config/services.php
Add:
'recaptcha' => [ 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret_key' => env('RECAPTCHA_SECRET_KEY'),],You can now access the credentials through:
config('services.recaptcha.site_key')and:
config('services.recaptcha.secret_key')After changing environment/configuration values on production, clear Laravel's cached configuration:
php artisan optimize:clear4. Load the Google reCAPTCHA JavaScript
Add Google's reCAPTCHA API script to the page containing your contact form:
BASH<script src="https://www.google.com/recaptcha/api.js" async defer></script>
Google recommends asynchronous loading because it avoids unnecessarily blocking page rendering.
Add Google's reCAPTCHA API script to the page containing your contact form:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>Google recommends asynchronous loading because it avoids unnecessarily blocking page rendering.
5. Add the reCAPTCHA Checkbox to the Form
Inside your Laravel Blade form, place the widget before the submit button:
BASH<form method="POST" action="{{ route('contact.submit') }}"> @csrf <div> <label for="name">Name</label> <input type="text" id="name" name="name" required > </div> <div> <label for="email">Email</label> <input type="email" id="email" name="email" required > </div> <div> <label for="message">Message</label> <textarea id="message" name="message" required ></textarea> </div> <div class="g-recaptcha" data-sitekey="{{ config('services.recaptcha.site_key') }}"> </div> @error('g-recaptcha-response') <p class="error"> {{ $message }} </p> @enderror <button type="submit"> Send Message </button></form>
When the visitor completes the CAPTCHA, Google automatically provides a field named:
BASHg-recaptcha-response
That value must be verified by your backend.
Inside your Laravel Blade form, place the widget before the submit button:
<form method="POST" action="{{ route('contact.submit') }}"> @csrf <div> <label for="name">Name</label> <input type="text" id="name" name="name" required > </div> <div> <label for="email">Email</label> <input type="email" id="email" name="email" required > </div> <div> <label for="message">Message</label> <textarea id="message" name="message" required ></textarea> </div> <div class="g-recaptcha" data-sitekey="{{ config('services.recaptcha.site_key') }}"> </div> @error('g-recaptcha-response') <p class="error"> {{ $message }} </p> @enderror <button type="submit"> Send Message </button></form>When the visitor completes the CAPTCHA, Google automatically provides a field named:
g-recaptcha-responseThat value must be verified by your backend.
6. Validate That a CAPTCHA Token Exists
Laravel should reject submissions where the visitor hasn't completed reCAPTCHA.
For example:
BASH$request->validate([ 'name' => ['required', 'string', 'max:100'], 'email' => ['required', 'email', 'max:150'], 'message' => ['required', 'string', 'max:5000'], 'g-recaptcha-response' => [ 'required', 'string', ],]);
However, this alone is not sufficient.
A bot could manually send any fake value:
BASHg-recaptcha-response=fake-token
Therefore, the response must also be verified with Google's server.
Laravel should reject submissions where the visitor hasn't completed reCAPTCHA.
For example:
$request->validate([ 'name' => ['required', 'string', 'max:100'], 'email' => ['required', 'email', 'max:150'], 'message' => ['required', 'string', 'max:5000'], 'g-recaptcha-response' => [ 'required', 'string', ],]);However, this alone is not sufficient.
A bot could manually send any fake value:
g-recaptcha-response=fake-tokenTherefore, the response must also be verified with Google's server.
7. Create a Server-Side reCAPTCHA Validator
A clean Laravel architecture is to keep CAPTCHA verification inside a dedicated service.
Create:
app/Services/RecaptchaValidator.php
Add:
BASH<?phpnamespace App\Services;use Illuminate\Support\Facades\Http;class RecaptchaValidator{ public static function verify( string $token, ?string $remoteIp = null ): bool { $secret = config('services.recaptcha.secret_key'); if (empty($secret) || empty($token)) { return false; } try { $response = Http::asForm() ->timeout(10) ->post( 'https://www.google.com/recaptcha/api/siteverify', [ 'secret' => $secret, 'response' => $token, 'remoteip' => $remoteIp, ] ); if (!$response->successful()) { return false; } $data = $response->json(); return ($data['success'] ?? false) === true; } catch (\Throwable $e) { report($e); return false; } }}
Google's verification endpoint expects the secret key and user response token. remoteip is optional.
A clean Laravel architecture is to keep CAPTCHA verification inside a dedicated service.
Create:
app/Services/RecaptchaValidator.php
Add:
<?phpnamespace App\Services;use Illuminate\Support\Facades\Http;class RecaptchaValidator{ public static function verify( string $token, ?string $remoteIp = null ): bool { $secret = config('services.recaptcha.secret_key'); if (empty($secret) || empty($token)) { return false; } try { $response = Http::asForm() ->timeout(10) ->post( 'https://www.google.com/recaptcha/api/siteverify', [ 'secret' => $secret, 'response' => $token, 'remoteip' => $remoteIp, ] ); if (!$response->successful()) { return false; } $data = $response->json(); return ($data['success'] ?? false) === true; } catch (\Throwable $e) { report($e); return false; } }}Google's verification endpoint expects the secret key and user response token. remoteip is optional.
8. Verify reCAPTCHA Before Processing the Form
Now use the validator before sending email or saving anything to the database.
BASHuse App\Services\RecaptchaValidator;use Illuminate\Http\Request;public function submit(Request $request){ $validated = $request->validate([ 'name' => ['required', 'string', 'max:100'], 'email' => ['required', 'email', 'max:150'], 'message' => ['required', 'string', 'max:5000'], 'g-recaptcha-response' => ['required', 'string'], ]); $verified = RecaptchaValidator::verify( $request->input('g-recaptcha-response'), $request->ip() ); if (!$verified) { return back() ->withErrors([ 'g-recaptcha-response' => 'reCAPTCHA verification failed. Please try again.', ]) ->withInput(); } // CAPTCHA passed. // Send email or save the form here. return back()->with( 'success', 'Your message has been sent successfully.' );}
The important rule is:
Form validation
↓
reCAPTCHA server verification
↓
Process request
↓
Send email / save data
Never send the email before CAPTCHA verification.
Now use the validator before sending email or saving anything to the database.
use App\Services\RecaptchaValidator;use Illuminate\Http\Request;public function submit(Request $request){ $validated = $request->validate([ 'name' => ['required', 'string', 'max:100'], 'email' => ['required', 'email', 'max:150'], 'message' => ['required', 'string', 'max:5000'], 'g-recaptcha-response' => ['required', 'string'], ]); $verified = RecaptchaValidator::verify( $request->input('g-recaptcha-response'), $request->ip() ); if (!$verified) { return back() ->withErrors([ 'g-recaptcha-response' => 'reCAPTCHA verification failed. Please try again.', ]) ->withInput(); } // CAPTCHA passed. // Send email or save the form here. return back()->with( 'success', 'Your message has been sent successfully.' );}The important rule is:
Form validation
↓
reCAPTCHA server verification
↓
Process request
↓
Send email / save data
Never send the email before CAPTCHA verification.
9. Reset reCAPTCHA After AJAX Submission
If your form uses JavaScript/AJAX and remains on the same page after successful submission, reset the widget:
BASHif (typeof grecaptcha !== 'undefined') { grecaptcha.reset();}
For example:
BASHif (data.success) { form.reset(); if (typeof grecaptcha !== 'undefined') { grecaptcha.reset(); }}
This gives the visitor a fresh CAPTCHA for another submission.
This is especially important because reCAPTCHA response tokens are short-lived and single-use. Google states that tokens must be verified within two minutes and cannot be verified more than once.
If your form uses JavaScript/AJAX and remains on the same page after successful submission, reset the widget:
if (typeof grecaptcha !== 'undefined') { grecaptcha.reset();}For example:
if (data.success) { form.reset(); if (typeof grecaptcha !== 'undefined') { grecaptcha.reset(); }}This gives the visitor a fresh CAPTCHA for another submission.
This is especially important because reCAPTCHA response tokens are short-lived and single-use. Google states that tokens must be verified within two minutes and cannot be verified more than once.
10. Add Callback Functions for Better UX
You can also detect successful, failed, or expired challenges.
Blade:
BASH<div class="g-recaptcha" data-sitekey="{{ config('services.recaptcha.site_key') }}" data-callback="onRecaptchaSuccess" data-error-callback="onRecaptchaError" data-expired-callback="onRecaptchaExpired"></div>
JavaScript:
BASHwindow.onRecaptchaSuccess = function () { console.log('reCAPTCHA verified.');};window.onRecaptchaError = function () { console.error('reCAPTCHA verification failed.');};window.onRecaptchaExpired = function () { console.warn('reCAPTCHA expired.'); if (typeof grecaptcha !== 'undefined') { grecaptcha.reset(); }};
For production, replace console messages with user-friendly inline messages.
You can also detect successful, failed, or expired challenges.
Blade:
<div class="g-recaptcha" data-sitekey="{{ config('services.recaptcha.site_key') }}" data-callback="onRecaptchaSuccess" data-error-callback="onRecaptchaError" data-expired-callback="onRecaptchaExpired"></div>JavaScript:
window.onRecaptchaSuccess = function () { console.log('reCAPTCHA verified.');};window.onRecaptchaError = function () { console.error('reCAPTCHA verification failed.');};window.onRecaptchaExpired = function () { console.warn('reCAPTCHA expired.'); if (typeof grecaptcha !== 'undefined') { grecaptcha.reset(); }};For production, replace console messages with user-friendly inline messages.
Common Google reCAPTCHA v2 Errors and Their Fixes
ERROR 1 — ERROR for site owner: Invalid domain for site key
This usually means your current domain isn't authorized for that site key.
Go to your reCAPTCHA configuration and verify the domain.
For production:
example.com
For local development, add:
localhost
Google specifically requires localhost to be added when you want to use the key for local development.
This usually means your current domain isn't authorized for that site key.
Go to your reCAPTCHA configuration and verify the domain.
For production:
example.com
For local development, add:
localhost
Google specifically requires localhost to be added when you want to use the key for local development.
ERROR 2 — Invalid site key
Check:
RECAPTCHA_SITE_KEY=
Make sure you haven't accidentally placed the secret key there.
Then run:
BASHphp artisan optimize:clear
You can also verify Laravel sees the configuration:
BASHphp artisan tinker
Then:
BASHconfig('services.recaptcha.site_key');
It should not return:
null
Check:
RECAPTCHA_SITE_KEY=
Make sure you haven't accidentally placed the secret key there.
Then run:
php artisan optimize:clearYou can also verify Laravel sees the configuration:
php artisan tinkerThen:
config('services.recaptcha.site_key');It should not return:
null
ERROR 3 — invalid-input-secret
Google's verification API can return:
invalid-input-secret
This means the secret is invalid or malformed.
Check:
RECAPTCHA_SECRET_KEY=
and:
BASHconfig('services.recaptcha.secret_key')
Then clear cached configuration:
BASHphp artisan optimize:clear
Google's verification API can return:
invalid-input-secret
This means the secret is invalid or malformed.
Check:
RECAPTCHA_SECRET_KEY=
and:
config('services.recaptcha.secret_key')Then clear cached configuration:
php artisan optimize:clearERROR 4 — missing-input-secret
Your application isn't sending the secret key to Google.
Check:
BASH$secret = config('services.recaptcha.secret_key');
Also verify that config/services.php contains:
BASH'recaptcha' => [ 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret_key' => env('RECAPTCHA_SECRET_KEY'),],
Your application isn't sending the secret key to Google.
Check:
$secret = config('services.recaptcha.secret_key');Also verify that config/services.php contains:
'recaptcha' => [ 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret_key' => env('RECAPTCHA_SECRET_KEY'),],ERROR 5 — missing-input-response
This means the backend received no CAPTCHA response.
Check that your form contains:
BASH<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
Then inspect the submitted request for:
BASHg-recaptcha-response
This means the backend received no CAPTCHA response.
Check that your form contains:
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>Then inspect the submitted request for:
g-recaptcha-responseERROR 6 — invalid-input-response
The token sent to Google is invalid or malformed.
Common causes include:
BASHWrong site/secret key pairExpired tokenIncorrect frontend integrationToken modified before verification
Generate a fresh CAPTCHA response and try again.
The token sent to Google is invalid or malformed.
Common causes include:
Wrong site/secret key pairExpired tokenIncorrect frontend integrationToken modified before verificationGenerate a fresh CAPTCHA response and try again.
ERROR 7 — timeout-or-duplicate
This is one of the most important reCAPTCHA errors.
It means the token has either:
- Expired
or:
- Already been verified once
Google response tokens are valid for two minutes and can only be verified once.
Reset the widget:
BASHgrecaptcha.reset();
Then ask the user to complete the challenge again.
This is one of the most important reCAPTCHA errors.
It means the token has either:
- Expired
or:
- Already been verified once
Google response tokens are valid for two minutes and can only be verified once.
Reset the widget:
grecaptcha.reset();Then ask the user to complete the challenge again.
ERROR 8 — grecaptcha is not defined
This normally happens when your JavaScript calls:
BASHgrecaptcha.reset();
before Google's script has loaded.
Avoid:
BASHgrecaptcha.reset();
without checking availability.
Use:
BASHif (typeof grecaptcha !== 'undefined') { grecaptcha.reset();}
Because the API loads asynchronously, code that depends on grecaptcha must wait until the API is available. Google specifically warns about this race condition.
This normally happens when your JavaScript calls:
grecaptcha.reset();before Google's script has loaded.
Avoid:
grecaptcha.reset();without checking availability.
Use:
if (typeof grecaptcha !== 'undefined') { grecaptcha.reset();}Because the API loads asynchronously, code that depends on grecaptcha must wait until the API is available. Google specifically warns about this race condition.
ERROR 9 — reCAPTCHA Checkbox Doesn't Appear
First verify the Google script exists:
BASH<script src="https://www.google.com/recaptcha/api.js" async defer></script>
Then verify the widget:
BASH<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
Also check the browser DevTools console for JavaScript or CSP errors.
First verify the Google script exists:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>Then verify the widget:
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>Also check the browser DevTools console for JavaScript or CSP errors.
11. Content Security Policy Can Block reCAPTCHA
Sites with a strict Content-Security-Policy (CSP) can accidentally block Google's scripts, iframe, or API requests.
If the browser console reports something similar to:
- Refused to load the script because it violates
- Content Security Policy
your CSP needs to allow the required Google reCAPTCHA resources.
For example, depending on your implementation:
BASHscript-src:https://www.google.com/recaptcha/https://www.gstatic.com/recaptcha/frame-src:https://www.google.com/recaptcha/connect-src:https://www.google.com/recaptcha/
Don't disable CSP completely just to make CAPTCHA work. Update only the necessary directives.
Sites with a strict Content-Security-Policy (CSP) can accidentally block Google's scripts, iframe, or API requests.
If the browser console reports something similar to:
- Refused to load the script because it violates
- Content Security Policy
your CSP needs to allow the required Google reCAPTCHA resources.
For example, depending on your implementation:
script-src:https://www.google.com/recaptcha/https://www.gstatic.com/recaptcha/frame-src:https://www.google.com/recaptcha/connect-src:https://www.google.com/recaptcha/Don't disable CSP completely just to make CAPTCHA work. Update only the necessary directives.
12. Test the Protection Properly
Don't consider the integration finished just because the checkbox appears.
Test at least these scenarios:
Test Expected Result Submit without CAPTCHA ❌ Rejected Complete CAPTCHA ✅ Accepted Valid form + CAPTCHA ✅ Email sent Invalid form fields ❌ Validation shown Expired CAPTCHA ❌ Rejected / reset Reuse old token ❌ Rejected Refresh page ✅ New CAPTCHA available Mobile browser ✅ Widget works Production domain ✅ No domain error
Most importantly, manually sending a POST request without a valid CAPTCHA token must not bypass the protection.
Don't consider the integration finished just because the checkbox appears.
Test at least these scenarios:
| Test | Expected Result |
|---|---|
| Submit without CAPTCHA | ❌ Rejected |
| Complete CAPTCHA | ✅ Accepted |
| Valid form + CAPTCHA | ✅ Email sent |
| Invalid form fields | ❌ Validation shown |
| Expired CAPTCHA | ❌ Rejected / reset |
| Reuse old token | ❌ Rejected |
| Refresh page | ✅ New CAPTCHA available |
| Mobile browser | ✅ Widget works |
| Production domain | ✅ No domain error |
Most importantly, manually sending a POST request without a valid CAPTCHA token must not bypass the protection.
13. Production Security Checklist
Before deploying, verify:
-
Site key is allowed to appear in frontend code.
-
Secret key exists only on the server.
-
Production
.env isn't committed to Git.
-
Correct production domain is registered.
-
CAPTCHA is verified server-side.
-
Email/database processing occurs only after verification.
-
Expired/duplicate tokens are handled.
-
AJAX forms reset CAPTCHA after submission.
-
CSP permits required reCAPTCHA resources.
-
Laravel configuration cache has been refreshed.
-
Errors shown to visitors don't expose secret keys or internal exceptions.
The critical concept is simple:
Browser verification ≠ security
Browser verification
+
Server-side Google verification
=
Proper reCAPTCHA protection
A frontend checkbox alone cannot protect a Laravel endpoint because an attacker can bypass the browser and call the endpoint directly.
Before deploying, verify:
- Site key is allowed to appear in frontend code.
- Secret key exists only on the server.
-
Production
.envisn't committed to Git. - Correct production domain is registered.
- CAPTCHA is verified server-side.
- Email/database processing occurs only after verification.
- Expired/duplicate tokens are handled.
- AJAX forms reset CAPTCHA after submission.
- CSP permits required reCAPTCHA resources.
- Laravel configuration cache has been refreshed.
- Errors shown to visitors don't expose secret keys or internal exceptions.
The critical concept is simple:
Browser verification ≠ security
Browser verification
+
Server-side Google verification
=
Proper reCAPTCHA protection
A frontend checkbox alone cannot protect a Laravel endpoint because an attacker can bypass the browser and call the endpoint directly.
Conclusion
Google reCAPTCHA v2 is relatively straightforward to integrate into Laravel, but a production-ready implementation requires more than displaying the checkbox.
The complete flow should be:
User fills form
↓
Google reCAPTCHA challenge
↓
g-recaptcha-response generated
↓
Laravel receives request
↓
Laravel validates fields
↓
Backend sends token to Google
↓
Google confirms success
↓
Application processes form
↓
Email/database operation
↓
CAPTCHA reset
With server-side verification, proper key management, error handling, and token reset logic, reCAPTCHA v2 provides a solid additional layer of protection against automated contact-form spam.
Google reCAPTCHA v2 is relatively straightforward to integrate into Laravel, but a production-ready implementation requires more than displaying the checkbox.
The complete flow should be:
User fills form
↓
Google reCAPTCHA challenge
↓
g-recaptcha-response generated
↓
Laravel receives request
↓
Laravel validates fields
↓
Backend sends token to Google
↓
Google confirms success
↓
Application processes form
↓
Email/database operation
↓
CAPTCHA reset
With server-side verification, proper key management, error handling, and token reset logic, reCAPTCHA v2 provides a solid additional layer of protection against automated contact-form spam.