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 emailis 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
- Open Google Cloud Console.
- Create a new project (or pick an existing one).
- 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
- 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)
- 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-hereNever 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
composer require google/apiclientStep 1 — redirect user to Google
Create /auth/google endpoint:
<?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
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
npm install passport passport-google-oauth20 express-sessionSet up Passport
// 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
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
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_subnullable — users who register with email/password have this NULLpassword_hashnullable — 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:
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
- Missing `state` verification. Without it, CSRF-style attacks can trick users into linking attackers' Google accounts.
- Trusting the email without `email_verified`. Some OAuth providers allow unverified emails. Only use if verified.
- HTTP on production redirect URI. Google rejects. Always HTTPS.
- Hardcoding redirect URI in code. Make it an env var — different values for dev / staging / production.
- Client secret in frontend JS. Catastrophic. Anyone can extract and impersonate your app.
- Storing the Google access_token long-term. Unless you use Google APIs on the user's behalf, discard it after the flow.
- Forgetting to update `last_login_at`. For audit + session analytics; worth tracking.
- 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.