Google Integration & API Services

Google One Tap Sign-In with Laravel 12 & Vue 3: Complete Authentication Guide

E encoderbaseAugust 2, 20267 min read
Google One Tap Sign-In with Laravel 12 & Vue 3: Complete Authentication Guide

Google One Tap Authentication with Laravel 12 and Vue 3

1. Prerequisites

PHP 8.2+, Composer, Laravel 12 project (API or Laravel + Inertia/Vite SPA)
Node 18+, Vue 3 project (via Vite)
A Google Cloud project

2. Google Cloud Console setup

Go to console.cloud.google.com → create/select a project.
APIs & Services → OAuth consent screen → configure app name, support email, and add your domain(s) once you're ready for production (localhost works fine for testing).
APIs & Services → Credentials → Create Credentials → OAuth client ID.
Application type: Web application
Authorized JavaScript origins: add http://localhost:5173 (Vite dev server) and your production domain, e.g. https://example.com
Authorized redirect URIs: not required for One Tap (only needed for full OAuth redirect flow), but you can leave it blank or add your app URL.
Copy the generated Client ID — you'll need it in both the Vue frontend and Laravel backend.

3. Laravel 12 backend

3.1 Install packages

composer require laravel/sanctum
composer require google/apiclient

google/apiclient gives you Google_Client::verifyIdToken(), which validates the JWT's signature, expiry, and audience against Google's servers — don't verify the JWT manually.

Publish Sanctum's config if not already present:

php artisan vendor:publish --tag=sanctum-config
php artisan migrate

3.2 Environment variables

Add to .env:

GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com

SANCTUM_STATEFUL_DOMAINS=localhost:5173
SESSION_DOMAIN=localhost
FRONTEND_URL=http://localhost:5173

Add to config/services.php:

'google' => [
    'client_id' => env('GOOGLE_CLIENT_ID'),
],

3.3 Migration — allow nullable password, store Google fields

php artisan make:migration add_google_fields_to_users_table --table=users
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('google_id')->nullable()->unique()->after('id');
            $table->string('avatar')->nullable()->after('email');
            $table->string('password')->nullable()->change();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['google_id', 'avatar']);
        });
    }
};
php artisan migrate

3.4 The controller

php artisan make:controller Api/GoogleOneTapController
<?php
// app/Http/Controllers/Api/GoogleOneTapController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Google_Client;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;

class GoogleOneTapController extends Controller
{
    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'credential' => ['required', 'string'],
        ]);

        if ($validator->fails()) {
            return response()->json(['message' => 'Missing credential'], 422);
        }

        $client = new Google_Client(['client_id' => config('services.google.client_id')]);

        $payload = $client->verifyIdToken($request->input('credential'));

        if (! $payload) {
            return response()->json(['message' => 'Invalid Google token'], 401);
        }

        // $payload now contains verified claims: sub, email, email_verified, name, picture, ...
        if (empty($payload['email_verified']) && $payload['email_verified'] !== true) {
            return response()->json(['message' => 'Google email not verified'], 401);
        }

        $user = User::where('google_id', $payload['sub'])
            ->orWhere('email', $payload['email'])
            ->first();

        if (! $user) {
            $user = User::create([
                'name' => $payload['name'] ?? explode('@', $payload['email'])[0],
                'email' => $payload['email'],
                'google_id' => $payload['sub'],
                'avatar' => $payload['picture'] ?? null,
                'password' => Hash::make(Str::random(32)), // unusable random password
                'email_verified_at' => now(),
            ]);
        } elseif (! $user->google_id) {
            // Existing account created via normal signup — link it
            $user->update([
                'google_id' => $payload['sub'],
                'avatar' => $user->avatar ?? ($payload['picture'] ?? null),
            ]);
        }

        // Revoke old tokens for this device/session if you want single-token behavior:
        // $user->tokens()->delete();

        $token = $user->createToken('one-tap-token')->plainTextToken;

        return response()->json([
            'user' => $user,
            'token' => $token,
        ]);
    }
}

Why verify server-side? Never trust the JWT payload decoded on the frontend. verifyIdToken() checks the signature against Google's rotating public keys and confirms the token was issued for your client ID, so a forged or replayed token from elsewhere is rejected.

3.5 Routes

// routes/api.php

use App\Http\Controllers\Api\GoogleOneTapController;
use Illuminate\Support\Facades\Route;

Route::post('/auth/google-one-tap', [GoogleOneTapController::class, 'login']);

Route::middleware('auth:sanctum')->get('/user', function (Illuminate\Http\Request $request) {
    return $request->user();
});

3.6 CORS + Sanctum config

config/cors.php:

'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'supports_credentials' => true,

config/sanctum.php — confirm:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:5173')),

If your Vue app is a separate SPA hitting a token endpoint (as coded above), you're using Sanctum's API token mode, so cookies/CSRF aren't strictly required for this endpoint — just send the returned token as a Bearer token afterward. If instead you want cookie-based SPA auth, call GET /sanctum/csrf-cookie before this request and switch to Auth::login($user) instead of issuing a token.

4. Vue 3 frontend

4.1 Load the Google script once

Add to index.html (simplest, avoids extra npm packages):

<!-- index.html -->
<script src="https://accounts.google.com/gsi/client" async defer></script>

4.2 Environment variable

.env (Vite):

VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
VITE_API_BASE_URL=http://localhost:8000

4.3 A small API helper

// src/api.js
import axios from 'axios'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  withCredentials: true,
})

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

export default api

4.4 The One Tap component

<!-- src/components/GoogleOneTap.vue -->
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import api from '../api'

const emit = defineEmits(['login-success', 'login-error'])

const CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID

function waitForGoogle() {
  return new Promise((resolve) => {
    if (window.google?.accounts?.id) return resolve()
    const interval = setInterval(() => {
      if (window.google?.accounts?.id) {
        clearInterval(interval)
        resolve()
      }
    }, 50)
  })
}

async function handleCredentialResponse(response) {
  try {
    // response.credential is the signed JWT from Google
    const { data } = await api.post('/api/auth/google-one-tap', {
      credential: response.credential,
    })

    localStorage.setItem('auth_token', data.token)
    emit('login-success', data.user)
  } catch (err) {
    emit('login-error', err)
  }
}

onMounted(async () => {
  await waitForGoogle()

  window.google.accounts.id.initialize({
    client_id: CLIENT_ID,
    callback: handleCredentialResponse,
    auto_select: false,       // set true to auto sign-in returning users silently
    cancel_on_tap_outside: false,
  })

  // Renders the floating One Tap prompt (top-right corner by default)
  window.google.accounts.id.prompt((notification) => {
    if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
      // One Tap was blocked/skipped (e.g. user dismissed it before, browser settings, etc.)
      console.log('One Tap not shown:', notification.getNotDisplayedReason?.() ?? notification.getSkippedReason?.())
    }
  })
})

onBeforeUnmount(() => {
  window.google?.accounts?.id?.cancel()
})
</script>

<template>
  <!-- This component renders nothing visible itself; the prompt is injected by Google's script.
       Optionally render a real button as a fallback for browsers/users that dismiss One Tap. -->
  <div id="g_id_signin_fallback"></div>
</template>

4.5 Optional: a classic "Sign in with Google" button as a fallback

One Tap can be silently skipped (e.g. the user closed it earlier, or the browser blocks third-party prompts). Render Google's standard button too, so people always have a way in:

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  window.google.accounts.id.renderButton(
    document.getElementById('g_id_signin_fallback'),
    { theme: 'outline', size: 'large', width: 280 }
  )
})
</script>

<template>
  <div id="g_id_signin_fallback"></div>
</template>

You can merge this into the same component as GoogleOneTap.vue — call both prompt() and renderButton() in onMounted.

4.6 Using it in a page

<!-- src/views/Login.vue -->
<script setup>
import { useRouter } from 'vue-router'
import GoogleOneTap from '../components/GoogleOneTap.vue'

const router = useRouter()

function onLoginSuccess(user) {
  console.log('Logged in as', user)
  router.push('/dashboard')
}

function onLoginError(err) {
  console.error('Google login failed', err)
}
</script>

<template>
  <div class="login-page">
    <h1>Sign in</h1>
    <GoogleOneTap @login-success="onLoginSuccess" @login-error="onLoginError" />
  </div>
</template>

4.7 Fetching the authenticated user later

import api from '../api'

async function fetchUser() {
  const { data } = await api.get('/api/user')
  return data
}

4.8 Logout

function logout() {
  localStorage.removeItem('auth_token')
  window.google?.accounts?.id?.disableAutoSelect() // prevents instant re-prompt with same account
}

Add a matching Laravel route if you want to revoke the token server-side too:

Route::middleware('auth:sanctum')->post('/auth/logout', function (Illuminate\Http\Request $request) {
    $request->user()->currentAccessToken()->delete();
    return response()->json(['message' => 'Logged out']);
});

5. Testing the flow

php artisan serve (backend) and npm run dev (frontend).
Visit the Vue app at http://localhost:5173 — the One Tap card should appear in the top-right corner asking you to continue with a Google account already signed into your browser.
Pick an account → check your Network tab for a POST /api/auth/google-one-tap call returning { user, token }.
Refresh and call GET /api/user with Authorization: Bearer <token> to confirm the session persists.

6. Common issues

Problem: One Tap prompt never appears
Cause / Fix: Origin not in "Authorized JavaScript origins", or you're testing in an Incognito/browser profile with no Google session, or you dismissed it recently (Google suppresses re-prompts for a cooldown period)

Problem: verifyIdToken() returns false
Cause / Fix: Client ID mismatch between frontend initialize() and backend GOOGLE_CLIENT_ID, or expired token (tokens are short-lived — verify immediately after receiving)

Problem: CORS errors on the POST
Cause / Fix: Check config/cors.php allowed_origins matches your Vite dev URL exactly (including port), and supports_credentials is true if you use cookies

Problem: Works locally, not in production
Cause / Fix: Add the production domain to Authorized JavaScript origins in Google Cloud Console, and update FRONTEND_URL / SANCTUM_STATEFUL_DOMAINS

Problem: One Tap keeps reappearing after logout
Cause / Fix: Call google.accounts.id.disableAutoSelect() on logout

7. Optional: combine with full OAuth redirect (Socialite)

One Tap is great for frictionless returning-user login, but some users will still want a full "Sign in with Google" redirect flow (e.g. mobile browsers where One Tap support is limited). For that, pair this with laravel/socialite's standard Google driver as a fallback button, sharing the same google_id column and user-creation logic from GoogleOneTapController.

Share this article
E
encoderbase

Insights and guides from the Encoderbase team on web, apps, software and SEO.

Have a project in mind?

Let's build something great together.

Get in Touch