In Chapter 11 you built signup with passwords. In 2026, fewer users want to invent a password. "Sign in with Google" feels like magic to them and removes the entire password-hashing attack surface from you.
This chapter implements that exact button, step by step, in a Cloudflare Worker — the same protocol behind "Sign in with GitHub," "Sign in with Apple," "Sign in with Microsoft," and every other one-click login you've ever used. OAuth 2.0 is the standard; Google is just today's issuer. Once you've built it for one, you can ship the others in 20 minutes each.
What OAuth Is Actually Solving
Before OAuth (pre-2010), apps that wanted your Gmail contacts asked for your Gmail password. You'd paste it in. That's horrifying — they could then read your email, change your password, lock you out. Every third-party app with your password was a breach waiting to happen.
OAuth 2.0 is the protocol that lets a user prove "I am this Google account" to your app without ever giving you their Google password. Instead, the user authenticates to Google, and Google hands your app a short-lived access token that says "yes, this is william@gmail.com, confirmed."
Figure 1 — The three actors in OAuth. The user never types their password into your app. Your app never sees their Google password. The trust chain is: user → Google, then Google → your app.
The OAuth Vocabulary You Need
Six terms. Memorise them; every OAuth tutorial assumes you know them.
| Term | Meaning |
|---|---|
| Client (a.k.a. "your app") | The thing initiating the flow — your Worker |
| Identity provider (IdP) | The thing that knows who the user is — Google, GitHub, Apple |
| Client ID | Public ID Google assigns to your app when you register |
| Client secret | Confidential password Google also gives you — never ship to the browser |
| Redirect URI | The URL on your site Google bounces the user back to with the auth code |
| Scopes | What you're asking permission for — openid, email, profile for login |
Plus three moving parts in flight:
- Authorization code — single-use, short-lived code Google gives you to redeem.
- Access token — the final credential. Lets you call Google APIs on the user's behalf.
- State — a random string you send out and get back, to prevent CSRF.
Step 1 — Register Your App in Google Cloud
Before any code, you need a Client ID and Client Secret. Google hands these out in the Google Cloud Console.
- Create a project (or pick one). Free.
- Go to APIs & Services → OAuth consent screen.
- Choose External user type.
- Fill in App Name, User Support Email, Developer Email. Save + continue until you get back to the dashboard.
- Go to Credentials → Create Credentials → OAuth client ID.
- Application type: Web application.
- Authorised redirect URIs: add your dev URL and your production URL:
http://localhost:8787/auth/google/callbackhttps://your-api.yourname.workers.dev/auth/google/callback
- Click Create. Copy the Client ID and Client Secret from the modal.
Store them as Worker secrets:
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
Also add a plain var for the redirect URI:
// wrangler.jsonc
{
"vars": {
"GOOGLE_REDIRECT_URI": "https://your-api.yourname.workers.dev/auth/google/callback"
}
}
Step 2 — The Start Endpoint
The "Sign in with Google" button on your frontend hits GET /auth/google/start. Your Worker builds the Google authorization URL and redirects there.
// GET /auth/google/start
if (pathname === "/auth/google/start" && method === "GET") {
// Generate a random "state" to detect reply-attacks / CSRF
const state = crypto.randomUUID();
// Stash it in a short-lived cookie so we can verify it on the return trip
const stateCookie = `oauth_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`;
const params = new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
redirect_uri: env.GOOGLE_REDIRECT_URI,
response_type: "code",
scope: "openid email profile",
state,
access_type: "online", // "offline" if you need a refresh token
prompt: "select_account", // nice UX — always show the account picker
});
return new Response(null, {
status: 302,
headers: {
Location: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
"Set-Cookie": stateCookie,
},
});
}
Three things to notice:
stateis generated per-request and stored in a short-lived cookie. On the return trip we'll verify it matches.scope: "openid email profile"— we want the user's OpenID identity (uniquesub), email, and basic profile. That's enough to sign them in. Ask for as little as possible.- Redirect via
302 + Location— no JS, works even with scripts disabled.
The user sees Google's familiar account picker. They click their account, consent, and Google redirects them back to your redirect_uri with an authorization code in the URL.
Step 3 — The Callback Endpoint
This is where the real work happens. Four steps in one handler: verify state, exchange code for token, fetch profile, log them in.
// GET /auth/google/callback
if (pathname === "/auth/google/callback" && method === "GET") {
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
// --- 1. Verify state matches the cookie (CSRF defence) ---
const cookie = request.headers.get("Cookie") ?? "";
const expectedState = cookie.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1];
if (!code || !returnedState || returnedState !== expectedState) {
return new Response("Invalid OAuth state", { status: 400 });
}
// --- 2. Exchange the code for an access token ---
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: env.GOOGLE_REDIRECT_URI,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
console.error("Token exchange failed:", await tokenRes.text());
return new Response("OAuth exchange failed", { status: 500 });
}
const { access_token, id_token } = await tokenRes.json();
// --- 3. Fetch the user's profile ---
const profileRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${access_token}` },
});
const profile = await profileRes.json();
// { id: "108...", email: "w@gmail.com", verified_email: true, name: "William", picture: "..." }
if (!profile.verified_email) {
return new Response("Email not verified with Google", { status: 403 });
}
// --- 4. Find or create the user in D1 ---
const email = profile.email.toLowerCase();
let user = await env.DB
.prepare("SELECT id, email FROM users WHERE email = ?")
.bind(email)
.first();
if (!user) {
const id = "u_" + crypto.randomUUID().slice(0, 8);
await env.DB
.prepare("INSERT INTO users (id, email, google_sub, name, picture) VALUES (?, ?, ?, ?, ?)")
.bind(id, email, profile.id, profile.name, profile.picture)
.run();
user = { id, email };
} else {
// Optionally update profile info on every login
await env.DB
.prepare("UPDATE users SET google_sub = ?, name = ?, picture = ? WHERE id = ?")
.bind(profile.id, profile.name, profile.picture, user.id)
.run();
}
// --- 5. Issue our own JWT cookie + redirect home ---
const token = await signJWT({ sub: user.id }, env.JWT_SECRET);
const session = `sas_session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${30 * 86400}`;
const clearState = `oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
return new Response(null, {
status: 302,
headers: {
Location: env.WEB_ORIGIN + "/account",
"Set-Cookie": [session, clearState].join(", "),
},
});
}
Five distinct phases, each small. Read them in order:
- Validate state. If an attacker tricks the user into hitting the callback with a forged code, the state won't match the cookie from the start request, and we abort.
- Exchange the code. This is the only request where the client secret leaves your Worker. The code is single-use and dies after ~10 minutes.
- Fetch the profile. Uses the fresh access token. Returns email + name + picture + a stable Google
id. - Find-or-create the user in D1. If you've seen the email before, link up; otherwise create a new row. Some teams key by
google_sub(Google's stable user ID) instead of email. - Issue your own session cookie. After this point the user is identified by your JWT, not Google's token. Google's tokens are discarded — you don't need them again until the next login.
Figure 2 — The full OAuth Authorization Code Flow. Four network round-trips (browser↔Google, Worker↔Google×2, browser back to you) but the user only sees one form: Google's consent screen.
The Schema — Add OAuth Columns
-- migrations/0004_oauth.sql
ALTER TABLE users ADD COLUMN google_sub TEXT UNIQUE;
ALTER TABLE users ADD COLUMN name TEXT;
ALTER TABLE users ADD COLUMN picture TEXT;
-- Users signed up via OAuth don't have a password — make it nullable if not already
Apply local and remote with the usual wrangler d1 execute.
Frontend — The Button
No JavaScript needed:
<a href="/auth/google/start" class="btn btn--primary">
<svg><!-- Google logo --></svg>
Sign in with Google
</a>
That's the whole frontend change. A plain <a> to your start endpoint.
What About Apple / GitHub / Microsoft?
All OAuth 2.0. The only differences from Google:
- Registration dashboard (Apple's is the most annoying; GitHub's is easiest).
- Authorization URL (the
accounts.google.com/o/oauth2/v2/authequivalent). - Token URL (different host).
- User-info URL (different endpoint, slightly different JSON shape).
Abstract Google's callback code into a handleOAuthCallback(config, request, env) helper and you can add GitHub in 30 lines. That's exactly how real auth libraries (e.g., openid-client, Auth.js) organise their providers — a thin config per IdP + shared flow logic.
PKCE — When You Can't Keep a Client Secret
OAuth 2.0 was designed for server-side apps with a secret. For public clients (mobile apps, single-page apps with no backend) there's no safe place to keep the secret. PKCE (Proof Key for Code Exchange) solves this.
Instead of a client secret, the client generates a random code_verifier, hashes it with SHA-256 to make a code_challenge, and sends the challenge with the initial redirect. When exchanging the code, the client sends the original verifier. Only the client that started the flow can complete it.
Since our Worker can keep a secret (it's server-side), we don't need PKCE for this tutorial. If you ever build a mobile app or SPA that talks directly to Google, add PKCE — the extra params are straightforward.
Security Checklist
Before shipping OAuth, tick every box:
- [ ] Client secret never in frontend code. Worker secrets only.
- [ ]
stateparameter generated, stored, verified. This alone blocks most OAuth CSRF. - [ ]
redirect_urimatches registered list exactly. - [ ] Verify
verified_email: truebefore trusting the email address. - [ ] Cookie flags:
HttpOnly; Secure; SameSite=Lax. - [ ] Use
prompt=select_accountso the wrong Google account isn't silently used. - [ ] Idempotent user creation: email normalised to lowercase, unique constraint on email or google_sub.
- [ ] Log on failure but not on success. Successful logins are high-volume; failures are worth monitoring.
Troubleshooting — The Five Errors You'll Hit
| Error | Usually means |
|---|---|
| redirect_uri_mismatch | The URI in your code isn't in Google Cloud Console's list — or has a trailing slash |
| invalid_client | Client ID or secret is wrong, or you're hitting the wrong Google URL |
| invalid_grant | Code already used, or expired (they live ~10 min) |
| access_denied | User hit "Cancel" on the consent screen — handle gracefully |
| Email not verified | Profile came back with verified_email: false — your code correctly refuses |
Exercise — Sign In With Google
Take the Ch 11 Worker and add:
- Google Cloud project + OAuth client ID + redirect URIs (local + prod).
wrangler secret put GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET.GOOGLE_REDIRECT_URIinvars.- Schema migration to add
google_sub,name,picture. - The
/auth/google/start+/auth/google/callbackendpoints above. - A plain
<a href="/auth/google/start">on your frontend. - Click through the flow in dev. Watch the cookies in DevTools → Application → Cookies:
oauth_stateappears, then disappears;sas_sessionshows up at the end.
You now have passwordless sign-in. For many users, this is the last time they'll think about auth on your site.
Next Steps
Your users have accounts. But none of them are paying. Ch 13 hooks up Stripe — Checkout for subscriptions, webhooks for reliable state updates — and lets you finally flip the switch from free trial to paying SaaS.
Next:
- Keep your Client Secret in a password manager too, not just in
wrangler secret. If your account is locked out, you'll need the original. - Read the next chapter — Ch 13: Stripe Checkout + Webhooks, where we wire up real payments end-to-end, no Apple IAP required.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper