OAuth 2.0 Explained: How 'Sign In with Google' Actually Works
Understand OAuth 2.0 from first principles. Authorization code flow, tokens, scopes, implementing Google OAuth in Node.js, PKCE, and security.
You click "Sign in with Google." A popup appears. You pick your account. Suddenly the third-party app knows your name and email. No password shared, no account created manually. It just works. But how?
OAuth 2.0 is the protocol behind every "Sign in with X" button. It's also one of the most misunderstood protocols in web development. Developers use libraries that abstract it away, then can't debug it when something breaks. Let's fix that by understanding what actually happens at every step.
The Problem OAuth Solves
Imagine you're building an app that needs to read a user's Google Calendar. Before OAuth, the user would give you their Google password. You'd log in as them and read the calendar. This is terrible for obvious reasons: the app has full access to the user's entire Google account, the user can't revoke access without changing their password, and if the app gets hacked, the password is compromised.
OAuth solves this with a delegation model. Instead of sharing credentials, the user tells Google: "Hey, let this app read my calendar, but nothing else." Google gives the app a limited-access token. The app never sees the user's password.
The Cast of Characters
OAuth 2.0 has four roles:
Resource Owner: The user. The person who owns the data. Client: Your application. The thing that wants access to the user's data. Authorization Server: The service that authenticates the user and issues tokens. For Google OAuth, this isaccounts.google.com.
Resource Server: The API that holds the user's data. For Google Calendar, this is www.googleapis.com/calendar.
In practice, the authorization server and resource server are often run by the same company (Google, GitHub, etc.), but they're conceptually separate.
The Authorization Code Flow
This is the most common and most secure OAuth flow. It's what you use for server-rendered web apps and is the basis for most "Sign in with X" implementations.
Here's the entire flow, step by step:
Step 1: Redirect the User to the Authorization Server
Your app sends the user to Google's authorization endpoint:
https://accounts.google.com/o/oauth2/v2/auth?
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/auth/callback&
response_type=code&
scope=openid%20email%20profile&
state=random-csrf-token
Let's break down each parameter:
client_id: Your app's ID, obtained when you registered your app with Googleredirect_uri: Where Google sends the user after they approve (must match what you registered)response_type=code: Tells Google you want an authorization code (not a token directly)scope: What permissions you're requesting.openid email profilemeans "let me know who they are"state: A random string you generate to prevent CSRF attacks. You'll verify this when the user comes back
Step 2: User Logs In and Approves
Google shows the user a consent screen: "This app wants to see your email and profile info. Allow?" The user clicks "Allow."
Step 3: Google Redirects Back with an Authorization Code
Google redirects the user's browser to your redirect_uri:
https://yourapp.com/auth/callback?code=4/P7q7W91a-oMsCeLvIa&state=random-csrf-token
The code is a short-lived authorization code. It's not an access token -- you can't use it to call APIs. It's an intermediate step.
Step 4: Exchange the Code for Tokens
Your server (not the browser) makes a POST request to Google's token endpoint:
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
code=4/P7q7W91a-oMsCeLvIa&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
redirect_uri=https://yourapp.com/auth/callback&
grant_type=authorization_code
This is a server-to-server call. The client_secret never leaves your backend.
Step 5: Receive Tokens
Google responds with:
{
"access_token": "ya29.a0AfH6SMBx...",
"expires_in": 3600,
"refresh_token": "1//0gdB...",
"scope": "openid email profile",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}
access_token: Use this to call Google APIs on behalf of the userrefresh_token: Use this to get a new access token when the current one expiresid_token: A JWT containing the user's identity (email, name, etc.)expires_in: The access token expires in 3600 seconds (1 hour)
Step 6: Use the Access Token
GET https://www.googleapis.com/oauth2/v2/userinfo
Authorization: Bearer ya29.a0AfH6SMBx...
Google returns the user's profile data. You create a session, store the user in your database, and log them in.
Access Tokens vs Refresh Tokens
Access tokens are short-lived (minutes to hours). They're what you attach to API requests. If one is stolen, the damage is limited by its short lifespan. Refresh tokens are long-lived (days to months). They're used to get new access tokens without making the user log in again. They should be stored securely on your server, never exposed to the browser.// When the access token expires, use the refresh token
async function refreshAccessToken(refreshToken) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
const data = await response.json();
return data.access_token; // New access token
}
Scopes: Requesting Specific Permissions
Scopes define what your app can do. The user sees them on the consent screen and decides whether to approve.
// Just identity
scope=openid email profile
// Read Google Calendar
scope=https://www.googleapis.com/auth/calendar.readonly
// Read and write Google Drive
scope=https://www.googleapis.com/auth/drive
// Multiple scopes
scope=openid email https://www.googleapis.com/auth/calendar.readonly
Request the minimum scopes you need. Users are less likely to approve an app that asks for everything, and it's a security best practice.
Implementing Google OAuth in Node.js
Let's build this for real. No magic libraries -- just HTTP requests so you understand every step.
Register Your App
Go to the Google Cloud Console, create a project, enable the OAuth consent screen, and create OAuth 2.0 credentials. Set the redirect URI to http://localhost:3000/auth/callback for development.
The Code
// server.js
const express = require('express');
const crypto = require('crypto');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: false,
}));
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/callback';
// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state: state,
access_type: 'offline', // Get a refresh token
prompt: 'consent', // Always show consent screen
});
res.redirect(https://accounts.google.com/o/oauth2/v2/auth?${params});
});
// Step 3: Handle the callback
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
// Check for errors
if (error) {
return res.status(400).send(OAuth error: ${error});
}
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
delete req.session.oauthState;
// Step 4: Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
}),
});
const tokens = await tokenResponse.json();
if (tokens.error) {
return res.status(400).send(Token error: ${tokens.error_description});
}
// Step 6: Get user info
const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: Bearer ${tokens.access_token} },
});
const user = await userResponse.json();
// Create session
req.session.user = {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
};
// Store refresh token securely (in your database, not the session)
// await saveRefreshToken(user.id, tokens.refresh_token);
res.redirect('/dashboard');
});
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/auth/google');
}
res.send(Hello, ${req.session.user.name}! (${req.session.user.email}));
});
app.get('/auth/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
That's a complete, working Google OAuth implementation. No Passport.js, no abstractions. Just HTTP requests following the spec.
PKCE: OAuth for Single-Page Apps
The authorization code flow assumes your server can keep a secret (the client_secret). But what about single-page applications where all code runs in the browser? You can't embed a secret in JavaScript -- anyone can read it.
PKCE (Proof Key for Code Exchange, pronounced "pixie") solves this. Instead of a client secret, the app generates a random "code verifier" for each request and sends a hashed version ("code challenge") to the authorization server.
// Browser-side PKCE implementation
// Generate a random code verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Hash it to create the code challenge
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// Start the flow
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store the verifier for later
sessionStorage.setItem('code_verifier', codeVerifier);
const params = new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'http://localhost:5173/callback',
response_type: 'code',
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = https://accounts.google.com/o/oauth2/v2/auth?${params};
}
// Handle the callback
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'http://localhost:5173/callback',
grant_type: 'authorization_code',
code_verifier: codeVerifier, // Prove we started this flow
}),
});
const tokens = await response.json();
// Use the tokens...
}
The authorization server verifies that the code_verifier matches the code_challenge that was sent earlier. An attacker who intercepts the authorization code can't exchange it without the verifier.
Common Security Mistakes
Not validating the state parameter. Without state verification, an attacker can craft a malicious URL that links their account to the victim's session. Always generate a random state, store it in the session, and verify it on the callback. Storing tokens in localStorage. localStorage is accessible to any JavaScript on the page, including XSS attacks. For SPAs, store tokens in memory and use short-lived access tokens with silent refresh. For server apps, keep tokens server-side. Requesting too many scopes. Don't ask for write access to Google Drive when you only need to read a user's email. Users will reject overly broad permission requests, and unnecessary scopes increase your attack surface. Not using HTTPS. OAuth tokens in transit over HTTP can be intercepted. Always use HTTPS in production. Always. Leaking tokens in URLs. Never put access tokens in query parameters. They end up in server logs, browser history, and Referer headers. Use the Authorization header instead. Not validating redirect URIs. Register exact redirect URIs, not wildcards. An open redirect vulnerability combined with OAuth can leak authorization codes. Treating the access token as authentication. An access token proves authorization (this token can read calendar data), not authentication (this token belongs to user X). For authentication, use OpenID Connect (which adds theid_token) and validate it properly.
OAuth vs OpenID Connect
OAuth 2.0 is about authorization: "Can this app read my calendar?" OpenID Connect (OIDC) is a layer on top of OAuth for authentication: "Who is this user?"
When you use scope=openid, you're using OIDC. The id_token you receive is a signed JWT containing the user's identity:
// Decode the id_token (after verifying the signature)
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334", // Unique user ID
"email": "user@gmail.com",
"email_verified": true,
"name": "Jane Doe",
"picture": "https://lh3.googleusercontent.com/...",
"iat": 1711526400,
"exp": 1711530000
}
For "Sign in with Google" flows, you should verify the id_token's signature and claims rather than making a separate API call to get user info. It's faster and more secure.
What's Next
You now understand what happens behind every "Sign in with X" button. The concepts transfer across providers -- GitHub, Facebook, Apple, Discord, and every other OAuth provider follow the same flow with minor variations.
For building authentication systems and other backend projects from scratch, check out CodeUp.