Why "Login with Google" Is More Complex Than It Looks
Every time you click "Continue with Google" or "Sign in with GitHub," a carefully choreographed handshake happens behind the scenes. OAuth 2.0 is the authorization protocol that makes this work, and OpenID Connect (OIDC) is the identity layer built on top of it. Together they power nearly every modern authentication flow on the web.
This guide cuts through the confusion. OAuth and OIDC are frequently misunderstood — even by developers who use them daily.
OAuth 2.0: Authorization, Not Authentication
The single most important thing to understand: OAuth 2.0 is an authorization protocol, not an authentication protocol. It was designed to let a user grant a third-party application limited access to their resources — without sharing their password.
The classic example: a photo editing app wants to access your Google Photos. You don't want to give the app your Google password. OAuth lets Google issue the app a time-limited access token with read-only access to photos.
The Four OAuth 2.0 Roles
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data | You |
| Client | The app requesting access | Photo editor app |
| Authorization Server | Issues tokens after auth | Google's auth server |
| Resource Server | Hosts the protected data | Google Photos API |
The Authorization Code Flow (Most Common)
This is the flow you should use for web applications and mobile apps with a backend.
User → Client App → Authorization Server → User Login → Redirect with Code
→ Client exchanges Code for Tokens → Client calls Resource Server with Token
Step by step:
# Step 1: Client redirects user to authorization server
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=openid%20email%20profile&
state=RANDOM_STATE_VALUE
# Step 2: User authenticates and approves
# Authorization server redirects back:
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_VALUE
# Step 3: Exchange code for tokens (server-to-server, never in browser!)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
# Response:
{
"access_token": "ya29.a0AfH6...",
"id_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3599,
"refresh_token": "1//0gLh..."
}
The state parameter is critical — it prevents CSRF attacks. Generate a random value, store it in session, and verify it matches when the callback arrives.
PKCE: Authorization Code Flow for SPAs and Mobile
Single-page apps and mobile apps cannot safely store a client_secret (it would be visible to users). PKCE (Proof Key for Code Exchange, pronounced "pixy") solves this.
// Generate PKCE code verifier (random string)
const codeVerifier = generateRandomString(64);
// sha256("verifier") → base64url
const codeChallenge = base64url(sha256(codeVerifier));
// Step 1: Authorization request includes code_challenge
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params
// Step 2: Token exchange includes code_verifier
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
code_verifier: codeVerifier, // proves we initiated the request
// NO client_secret needed!
})
});
PKCE works because an attacker who intercepts the authorization code cannot exchange it for tokens without the code_verifier that only the legitimate client has.
Other OAuth 2.0 Grant Types
Client Credentials (Machine-to-Machine)
For server-to-server API calls where there's no user involved:
// Your service authenticates directly with its own credentials
const response = await fetch('https://api.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'read:data write:data'
})
});
const { access_token } = await response.json();
// Use token to call API
await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${access_token}` }
});
Refresh Token Flow
Access tokens are short-lived. When they expire, use a refresh token:
async function getValidToken(refreshToken) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
const data = await response.json();
if (!response.ok) {
throw new Error('Refresh failed: ' + data.error);
}
return data.access_token;
}
OpenID Connect: Adding Authentication to OAuth
OAuth only says "here's an access token to call this API." It says nothing about who the user is. OpenID Connect adds an ID Token — a JWT containing the user's identity claims.
When you add openid to your OAuth scope, the authorization server returns an ID token alongside the access token:
// ID Token is a JWT with claims about the user
// Decode it (but always verify the signature first!)
const idTokenPayload = {
"iss": "https://accounts.google.com", // Issuer
"sub": "110169484474386276334", // Subject (user ID)
"aud": "YOUR_CLIENT_ID", // Audience
"exp": 1516239022, // Expiry
"iat": 1516235422, // Issued at
"nonce": "abc123", // Replay protection
// Standard OIDC claims with profile scope:
"name": "Jane Doe",
"email": "jane@example.com",
"picture": "https://...",
"email_verified": true
}
Verifying the ID Token
Never trust an ID token without verifying it:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/openid-configuration')
// Jose automatically fetches the actual jwks_uri from the discovery doc
);
async function verifyIdToken(idToken) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: YOUR_CLIENT_ID,
});
// Check nonce to prevent replay attacks
if (payload.nonce !== sessionNonce) {
throw new Error('Nonce mismatch');
}
return payload; // Trusted user info
}
OIDC Discovery Document
Every OIDC provider exposes a discovery document at /.well-known/openid-configuration:
curl https://accounts.google.com/.well-known/openid-configuration | jq .
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
...
}
Use the discovery document rather than hardcoding endpoints — they can change.
Common Security Mistakes
1. Storing tokens in localStorage
// ❌ Vulnerable to XSS attacks
localStorage.setItem('access_token', token);
// ✅ Use httpOnly cookies (not accessible from JavaScript)
// Or store in memory and use refresh tokens in httpOnly cookies
2. Not validating state parameter
// ❌ CSRF vulnerability
router.get('/callback', async (req) => {
const { code } = req.query;
await exchangeCode(code); // state not checked!
});
// ✅ Always verify state
router.get('/callback', async (req) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).send('State mismatch');
}
await exchangeCode(code);
});
3. Using implicit flow (deprecated) The implicit flow returned tokens directly in URL fragments — they ended up in browser history and server logs. Always use authorization code + PKCE instead.
4. Not checking token expiry
function isTokenExpired(token) {
const { exp } = JSON.parse(atob(token.split('.')[1]));
return Date.now() >= exp * 1000;
}
Real Implementation with Passport.js
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
scope: ['openid', 'email', 'profile']
}, async (accessToken, refreshToken, profile, done) => {
// profile contains verified user info from Google
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
});
}
return done(null, user);
}));
// Routes
app.get('/auth/google', passport.authenticate('google'));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
OAuth vs API Keys vs Sessions
| Approach | Best For | Security | User Experience |
|---|---|---|---|
| OAuth 2.0 | Third-party access, SSO | High (limited scope) | Seamless |
| API Keys | Server-to-server, M2M | Medium (no expiry) | Simple |
| Session Cookies | Own web app | High (httpOnly) | Transparent |
| JWT (stateless) | Microservices | Medium (hard to revoke) | Scalable |
→ Use the JWT Parser to decode and inspect OAuth ID tokens and access tokens.