Client Area

Login with Google (OAuth 2.0) in PHP and Node.js

ByDomain India Team
10 min read22 Apr 20262 views

In this article

  • 1OAuth 2.0 vocabulary in two minutes
  • 2The authorisation code flow — five steps
  • 3Google Cloud Console setup
  • 4PHP implementation
  • 5Install

Login with Google (OAuth 2.0) in PHP and Node.js

"Sign in with Google" lifts signup conversion 20–40% on typical web apps — users skip the registration form entirely. This guide walks through the full OAuth 2.0 authorisation-code flow — Google Cloud Console setup, server-side exchange, ID token verification, and the user-upsert pattern — with working code in PHP and Node.js.

OAuth 2.0 vocabulary in two minutes

  • Authorisation Server — Google, in this case. Hands out codes and tokens.
  • Client — your web app.
  • Resource Owner — the user who has a Google account.
  • Client ID — public identifier for your app. Safe to embed in frontend.
  • Client Secret — private. Used server-to-server only. Never in frontend JS.
  • Scope — what data / permissions you request. profile email is minimum for sign-in.
  • Authorisation Code — short-lived (10 min) token Google sends to your callback URL after user approves.
  • Access Token — lets you call Google APIs as the user.
  • ID Token — a JWT containing the user's identity claims. Sign-in relies on this.

The authorisation code flow — five steps

1. User clicks "Sign in with Google" on your site.
2. Your server redirects the browser to Google's authorisation URL with your client_id + redirect_uri + scope.
3. User authenticates at Google, approves your requested scopes.
4. Google redirects back to your callback URL with ?code=...
5. Your server POSTs the code (plus client_id + client_secret) to Google's token endpoint,
   receives access_token + id_token, decodes the id_token to get user identity,
   upserts the user in your DB, creates a session, redirects to your app.

This happens every time a user clicks "Sign in with Google" — including repeat sign-ins.

Google Cloud Console setup

  1. Open Google Cloud Console.
  2. Create a new project (or pick an existing one).
  3. APIs & Services → OAuth consent screen:

- User Type: External

- App name, support email, developer email

- Authorised domains: yourdomain.com

- Scopes: add userinfo.email and userinfo.profile (or leave defaults)

- Test users: add your own Google account while the app is in "Testing" mode

  1. APIs & Services → Credentials → Create Credentials → OAuth client ID:

- Application type: Web application

- Authorised redirect URIs:

- http://localhost:3000/auth/google/callback (for local dev)

- https://yourdomain.com/auth/google/callback (for production)

  1. Copy the Client ID and Client Secret. Save them as env vars:
GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here

Never commit the Client Secret. See Environment Variables & Secrets Management.

Going live: when you're ready for real users, submit the OAuth consent screen for verification (required if you exceed ~100 users). Google may ask for homepage + privacy policy URLs.

PHP implementation

Install

bash
composer require google/apiclient

Step 1 — redirect user to Google

Create /auth/google endpoint:

php
<?php
require __DIR__ . '/vendor/autoload.php';

session_start();

$client = new Google\Client();
$client->setClientId($_ENV['GOOGLE_CLIENT_ID']);
$client->setClientSecret($_ENV['GOOGLE_CLIENT_SECRET']);
$client->setRedirectUri('https://yourdomain.com/auth/google/callback');
$client->addScope('openid');
$client->addScope('email');
$client->addScope('profile');
$client->setAccessType('offline');
$client->setState(bin2hex(random_bytes(16)));

// Save state in session so we can verify on callback — prevents CSRF
$_SESSION['oauth_state'] = $client->getState();

header('Location: ' . $client->createAuthUrl());
exit;

Step 2 — handle the callback

php
<?php
require __DIR__ . '/vendor/autoload.php';

session_start();

// Verify state to prevent CSRF
if (!isset($_GET['state'], $_SESSION['oauth_state']) ||
    !hash_equals($_SESSION['oauth_state'], $_GET['state'])) {
    http_response_code(400);
    exit('Invalid state');
}

if (!isset($_GET['code'])) {
    http_response_code(400);
    exit('Missing code');
}

$client = new Google\Client();
$client->setClientId($_ENV['GOOGLE_CLIENT_ID']);
$client->setClientSecret($_ENV['GOOGLE_CLIENT_SECRET']);
$client->setRedirectUri('https://yourdomain.com/auth/google/callback');

try {
    // Exchange the code for tokens
    $token = $client->fetchAccessTokenWithAuthCode($_GET['code']);

    if (isset($token['error'])) {
        throw new Exception($token['error_description'] ?? 'Token exchange failed');
    }

    // Verify and decode the ID token
    $idToken = $token['id_token'] ?? null;
    $payload = $client->verifyIdToken($idToken);

    if (!$payload) {
        throw new Exception('Invalid ID token');
    }

    // Claims we care about
    $googleId      = $payload['sub'];                                   // stable user ID
    $email         = $payload['email'];
    $emailVerified = $payload['email_verified'] ?? false;
    $name          = $payload['name'] ?? '';
    $picture       = $payload['picture'] ?? null;

    if (!$emailVerified) {
        throw new Exception('Google account email not verified');
    }

    // Upsert user — key by google_id, not email (email can change)
    $user = findOrCreateUserByGoogleId($googleId, $email, $name, $picture);

    // Log in — set your session cookie
    $_SESSION['user_id'] = $user->id;

    unset($_SESSION['oauth_state']);
    header('Location: /dashboard');
    exit;

} catch (Exception $e) {
    error_log("Google OAuth error: " . $e->getMessage());
    header('Location: /login?error=oauth');
    exit;
}

function findOrCreateUserByGoogleId(string $googleId, string $email, string $name, ?string $picture) {
    // Replace with your actual user model / DB query
    $user = User::findByGoogleId($googleId);
    if ($user) {
        // Update profile fields if changed
        $user->update(['name' => $name, 'picture' => $picture]);
        return $user;
    }

    // No user yet — check if email is taken by an existing non-Google account
    $existing = User::findByEmail($email);
    if ($existing) {
        // Link the existing account to this Google ID
        $existing->update(['google_id' => $googleId]);
        return $existing;
    }

    // Brand new user
    return User::create([
        'google_id' => $googleId,
        'email'     => $email,
        'name'      => $name,
        'picture'   => $picture,
    ]);
}

Node.js implementation (Passport.js)

Passport is the de-facto authentication middleware for Express. passport-google-oauth20 handles the OAuth flow.

Install

bash
npm install passport passport-google-oauth20 express-session

Set up Passport

javascript
// passport.config.js
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { findOrCreateUserByGoogleId } from './users.js';

passport.use(new GoogleStrategy(
  {
    clientID:      process.env.GOOGLE_CLIENT_ID,
    clientSecret:  process.env.GOOGLE_CLIENT_SECRET,
    callbackURL:   '/auth/google/callback',
    scope:         ['profile', 'email'],
  },
  async (accessToken, refreshToken, profile, done) => {
    try {
      const user = await findOrCreateUserByGoogleId(
        profile.id,                          // google_id
        profile.emails[0].value,              // email
        profile.displayName,
        profile.photos[0]?.value,
      );
      done(null, user);
    } catch (err) {
      done(err, null);
    }
  }
));

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
  const user = await User.findById(id);
  done(null, user);
});

Express routes

javascript
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import './passport.config.js';

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure:   true,
    httpOnly: true,
    sameSite: 'lax',
    maxAge:   24 * 60 * 60 * 1000,
  },
}));

app.use(passport.initialize());
app.use(passport.session());

// Redirect to Google
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
);

// Handle the callback
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login?error=oauth' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

// Logout
app.post('/logout', (req, res) => {
  req.logout(() => {
    req.session.destroy(() => res.redirect('/'));
  });
});

Security-critical items

Always verify the ID token signature

The ID token is a JWT. Don't just base64-decode and trust the claims — Google's signature proves it came from Google and wasn't tampered with. The google/apiclient (PHP) and google-auth-library (Node) both do this automatically. If you DIY the OAuth flow without a library, use the public keys at https://www.googleapis.com/oauth2/v3/certs and verify RS256 signatures.

Use the state parameter

Before redirecting to Google, generate a random state value, save it in the user's session, include it in the auth URL. On callback, verify the returned state matches the session value. Prevents CSRF-style attacks on your OAuth flow.

HTTPS everywhere

Google does not allow HTTP redirect URIs except http://localhost. Production MUST be HTTPS. Install Let's Encrypt via cPanel → SSL/TLS Status → Run AutoSSL.

Minimal scopes

Don't request scopes you don't use. profile + email is the minimum for sign-in. Adding https://www.googleapis.com/auth/drive means your app wants access to the user's Drive — users see this on the consent screen and may abandon. Only ask for what you actually need.

Key by sub, not email

The Google sub claim is a stable identifier — never changes. Email CAN change (user marries, changes handle). Store google_sub as your key field; email as a lookup / display field.

Handling edge cases

User already has an account with the same email

Two flows to offer:

A. Auto-link — if Google's email_verified: true claim holds, you can trust the email and link the Google ID to the existing account. Done silently.

B. Explicit link prompt — redirect the user to a "There's an existing account with this email. Sign in with your password to link Google to it" flow. Safer; users know what's happening.

Most apps go with A — less friction. Just require email_verified: true.

Google account with no email

Rare but possible (Google Workspace accounts with specific admin configs). Reject gracefully: if (!$email) throw new Exception('No email from Google');. Display a friendly error.

User revokes access later

The user visits myaccount.google.com/permissions and revokes your app. Your stored access_token stops working. Next time they try to authenticate, they go through the flow again.

You don't need to do anything special — the access_token was only useful if you call Google APIs on their behalf (Gmail, Drive, etc.). For pure sign-in, the access_token is usually discarded after the initial exchange.

What to store in your DB

sql
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    google_sub      VARCHAR(50) UNIQUE,         -- stable Google ID
    email           VARCHAR(255) NOT NULL,       -- may change; keep indexed
    email_verified  BOOLEAN DEFAULT FALSE,
    name            VARCHAR(255),
    picture         TEXT,
    password_hash   VARCHAR(255),                -- nullable if Google-only
    created_at      TIMESTAMP DEFAULT NOW(),
    last_login_at   TIMESTAMP
);

CREATE INDEX idx_users_google_sub ON users(google_sub);
CREATE INDEX idx_users_email ON users(email);

Notes:

  • google_sub nullable — users who register with email/password have this NULL
  • password_hash nullable — Google-only users don't need a password
  • Handle the "user has both Google login and a password" case carefully

Extending to Facebook, GitHub, LinkedIn

Same OAuth 2.0 pattern, different URLs. Passport has strategies for each (passport-facebook, passport-github2, passport-linkedin-oauth2). For PHP, Google's library is Google-specific; for other providers, use league/oauth2-client with the appropriate package.

Abstract common code:

php
interface SocialAuthProvider {
    public function getAuthUrl(): string;
    public function handleCallback(string $code): array;
}

class GoogleProvider implements SocialAuthProvider { /* ... */ }
class FacebookProvider implements SocialAuthProvider { /* ... */ }

Your controller doesn't need to know which provider it's using.

Common pitfalls

  1. Missing `state` verification. Without it, CSRF-style attacks can trick users into linking attackers' Google accounts.
  2. Trusting the email without `email_verified`. Some OAuth providers allow unverified emails. Only use if verified.
  3. HTTP on production redirect URI. Google rejects. Always HTTPS.
  4. Hardcoding redirect URI in code. Make it an env var — different values for dev / staging / production.
  5. Client secret in frontend JS. Catastrophic. Anyone can extract and impersonate your app.
  6. Storing the Google access_token long-term. Unless you use Google APIs on the user's behalf, discard it after the flow.
  7. Forgetting to update `last_login_at`. For audit + session analytics; worth tracking.
  8. Not offering email/password as a fallback. If Google OAuth breaks, your users can't log in. Always offer password login too.

Frequently asked questions

How do I test Google OAuth locally?

Add http://localhost:3000/auth/google/callback as an authorised redirect URI in Google Console. Run your dev server, click the sign-in button — it works identically to production. Use your own Google account while the OAuth consent screen is in Testing mode.

What's the consent screen verification process?

Google requires verification for apps exceeding 100 users or requesting sensitive scopes. You submit: privacy policy URL, terms of service URL, list of scopes, homepage. Review takes 1–6 weeks. Start the process early.

How do I log out a user from Google too?

You typically don't — it would confuse users by logging them out of other Google properties (Gmail, YouTube). Just invalidate your own session. The user can log out of Google separately if they want.

What about Google One Tap?

Google One Tap is an alternative UX — a popup prompts the user to sign in without redirecting. Newer, smoother, but more JS-heavy. Uses the same backend verification. Consider once your basic flow is working.

Do I need Google's JavaScript SDK?

No. You can do everything server-side. The JS SDK is useful for Google One Tap and richer client-side integrations, but is optional for basic "Sign in with Google".

Can I use this for mobile apps too?

Mobile apps use a different OAuth flow — "Installed Application" flow with PKCE. Google provides native SDKs for iOS / Android. Don't use the "Web application" flow in a mobile app.

How do I test if my tokens are valid?

Decode a sample ID token at jwt.io. Valid Google ID tokens have iss: "accounts.google.com" or "https://accounts.google.com". Use $client->verifyIdToken($token) in PHP to verify programmatically.


Need help setting up Google OAuth for your DI-hosted app? [email protected] — we help with OAuth setup as part of standard support.

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket