DEV Community

Cover image for Implement Google reCAPTCHA v2 (“I’m not a robot”) in Laravel + Vue (Vite) - step-by-step
Tahsin Abrar
Tahsin Abrar

Posted on

Implement Google reCAPTCHA v2 (“I’m not a robot”) in Laravel + Vue (Vite) - step-by-step

  • how to expose the site key to your Vue frontend (Vite)
  • how to render and get the reCAPTCHA token in Vue
  • how to verify the token in a Laravel controller using your secret key
  • testing tips and common troubleshooting

Why this pattern?

reCAPTCHA v2 ("I'm not a robot") requires a site key on the frontend and a secret key on the server. The site key can safely be bundled into the frontend (build-time env). The secret key must never be sent to the browser — verify tokens server-side with Google.


Prerequisites

  • Laravel (9/10+) using Vite for frontend assets
  • Vue 3 (inside Laravel or as a separate SPA that talks to the Laravel API)
  • Google reCAPTCHA v2 keys (Site Key + Secret Key)

Register your site at Google reCAPTCHA admin and pick reCAPTCHA v2 → “I’m not a robot”.


1) Add keys to .env

# frontend (Vite) — exposed to browser (build time)
VITE_RECAPTCHA_SITE_KEY=your_site_key_here

# backend — keep secret, server-side only
RECAPTCHA_SECRET_KEY=your_secret_key_here
Enter fullscreen mode Exit fullscreen mode

After changing .env, restart your dev server (npm run dev / pnpm dev) so Vite picks up the env variables.

Optionally add to config/services.php:

// config/services.php
return [
    // ...
    'recaptcha' => [
        'secret' => env('RECAPTCHA_SECRET_KEY'),
    ],
];
Enter fullscreen mode Exit fullscreen mode

2) Frontend — render reCAPTCHA and get token (manual, no extra package)

This is robust and avoids package compatibility issues. The example is a Vue 3 Single File Component (Options API) that dynamically loads the Google script and renders an explicit widget.

<!-- resources/js/components/RecaptchaV2.vue -->
<template>
  <div>
    <div ref="recaptcha"></div>

    <p v-if="!recaptchaToken">Please check “I’m not a robot” to continue.</p>
    <p v-else>reCAPTCHA token ready ✅</p>

    <button @click="submitForm">Submit</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";

// ✅ Site key from .env
const siteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY;

// Refs (reactive data)
const recaptcha = ref(null);
const recaptchaToken = ref(null);
let widgetId = null;

// ✅ Load Google reCAPTCHA script
const loadRecaptchaScript = () => {
  return new Promise((resolve) => {
    if (window.grecaptcha) return resolve();
    const src = "https://www.google.com/recaptcha/api.js?render=explicit";
    const s = document.createElement("script");
    s.src = src;
    s.async = true;
    s.defer = true;
    s.onload = () => resolve();
    document.head.appendChild(s);
  });
};

// ✅ Render widget when script is ready
const renderRecaptcha = () => {
  if (!window.grecaptcha) return;
  widgetId = window.grecaptcha.render(recaptcha.value, {
    sitekey: siteKey,
    callback: onVerify,
    "expired-callback": onExpired,
  });
};

// ✅ Callback: success
const onVerify = (token) => {
  recaptchaToken.value = token;
  console.log("reCAPTCHA token:", token);
};

// ✅ Callback: expired
const onExpired = () => {
  recaptchaToken.value = null;
};

// ✅ Submit form with token
const submitForm = async () => {
  if (!recaptchaToken.value) {
    alert("Please complete reCAPTCHA first.");
    return;
  }

  try {
    const res = await fetch("/api/verify-recaptcha", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ token: recaptchaToken.value }),
    });

    const data = await res.json();
    if (res.ok && data.success) {
      alert("Verification passed ✅ Proceed with your action.");
    } else {
      console.error("reCAPTCHA verify failed:", data);
      alert("reCAPTCHA verification failed. Try again.");
    }
  } catch (err) {
    console.error(err);
    alert("Server error while verifying reCAPTCHA.");
  } finally {
    if (window.grecaptcha && widgetId !== null) {
      window.grecaptcha.reset(widgetId);
      recaptchaToken.value = null;
    }
  }
};

// ✅ Load on mount
onMounted(async () => {
  await loadRecaptchaScript();
  renderRecaptcha();
});
</script>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • We use import.meta.env.VITE_RECAPTCHA_SITE_KEY — Vite exposes envs that start with VITE_.
  • render=explicit lets us call grecaptcha.render() manually and attach callbacks.

3) Backend — Laravel controller to verify token with Google

Create a controller:

// app/Http/Controllers/RecaptchaController.php
<?php

namespace App\Http\Controllers;

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

class RecaptchaController extends Controller
{
    public function verify(Request $request)
    {
        $request->validate(['token' => 'required|string']);

        $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
            'secret' => config('services.recaptcha.secret') ?? env('RECAPTCHA_SECRET_KEY'),
            'response' => $request->input('token'),
            'remoteip' => $request->ip(),
        ]);

        $body = $response->json();

        // return Google response directly (you can normalize it as you like)
        if (!empty($body) && isset($body['success']) && $body['success'] === true) {
            return response()->json(['success' => true, 'score' => $body['score'] ?? null]);
        }

        return response()->json([
            'success' => false,
            'error_codes' => $body['error-codes'] ?? $body['error_codes'] ?? []
        ], 422);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the route (usually routes/api.php):

use App\Http\Controllers\RecaptchaController;

Route::post('/verify-recaptcha', [RecaptchaController::class, 'verify']);
Enter fullscreen mode Exit fullscreen mode

Security tips

  • Use config('services.recaptcha.secret') or env() on the server — never expose the secret to the frontend.
  • Optionally add rate limiting middleware to this endpoint.

Top comments (0)