Tutorials Ultimate Web Development Series › Chapter 12

Google OAuth 2.0, Step by Step — Sign In With Google From Scratch

WebChapter 12 of the Ultimate Web Development Series35 minApril 20, 2026Intermediate

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."

Loading diagram…

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:

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.

  1. Create a project (or pick one). Free.
  2. Go to APIs & Services → OAuth consent screen.
  3. Choose External user type.
  4. Fill in App Name, User Support Email, Developer Email. Save + continue until you get back to the dashboard.
  5. Go to Credentials → Create Credentials → OAuth client ID.
  6. Application type: Web application.
  7. Authorised redirect URIs: add your dev URL and your production URL:
    • http://localhost:8787/auth/google/callback
    • https://your-api.yourname.workers.dev/auth/google/callback
  8. 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:

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:

  1. 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.
  2. 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.
  3. Fetch the profile. Uses the fresh access token. Returns email + name + picture + a stable Google id.
  4. 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.
  5. 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.
Loading diagram…

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:

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:

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:

  1. Google Cloud project + OAuth client ID + redirect URIs (local + prod).
  2. wrangler secret put GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET. GOOGLE_REDIRECT_URI in vars.
  3. Schema migration to add google_sub, name, picture.
  4. The /auth/google/start + /auth/google/callback endpoints above.
  5. A plain <a href="/auth/google/start"> on your frontend.
  6. Click through the flow in dev. Watch the cookies in DevTools → Application → Cookies: oauth_state appears, then disappears; sas_session shows 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:

  1. 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.
  2. Read the next chapter — Ch 13: Stripe Checkout + Webhooks, where we wire up real payments end-to-end, no Apple IAP required.
Ch 11: Authentication — Sessions, Cookies, JWTCh 13: Stripe Checkout + Webhooks

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
5 free articles remainingSubscribe for unlimited access