Google OAuth Login

Lesson 6 · Cloudflare Workers · ~15 minutes

Add "Sign in with Google" to your Worker app. No auth libraries, no frameworks — just the raw OAuth 2.0 flow running at the edge. By the end you'll have a Worker that authenticates users via their Google account and stores sessions in KV.

How OAuth 2.0 Works

The flow has 4 steps:

  1. User clicks "Sign in with Google" → your Worker redirects them to Google's auth page
  2. User authorizes → Google redirects back to your Worker with an authorization code
  3. Worker exchanges code for tokens → calls Google's token endpoint server-side
  4. Worker reads user info from the token → creates a session, sets a cookie
Why This Is Secure

The code → token exchange happens server-side (in your Worker), so your client secret never reaches the browser. The Worker runs over HTTPS automatically — Cloudflare handles the certificate.

Step 1: Create Google OAuth Credentials

  1. Go to Google Cloud Console → Credentials
  2. Create a new project (or select existing)
  3. Click Create Credentials → OAuth client ID
  4. Application type: Web application
  5. Add an Authorized redirect URI:
  6. Copy the Client ID and Client Secret
Configure OAuth Consent Screen

You also need to configure the OAuth consent screen (APIs & Services → OAuth consent screen). For testing, set it to "External" with your email as a test user. You can publish it later when ready.

Step 2: Set Up the Worker Project

npm create cloudflare@latest -- google-auth-worker
cd google-auth-worker

Store your secrets securely (never in code):

npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET

Add a KV namespace for sessions:

npx wrangler kv namespace create SESSIONS

Update wrangler.jsonc:

{
  "name": "google-auth-worker",
  "main": "src/index.js",
  "compatibility_date": "2024-01-01",
  "kv_namespaces": [
    {
      "binding": "SESSIONS",
      "id": "<your-namespace-id>"
    }
  ]
}
Local Dev Secrets

For local development, create a .dev.vars file (git-ignored) with your secrets:

# .dev.vars (add to .gitignore!)
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here

Step 3: The Complete OAuth Worker

Here's the full implementation — 4 routes handling the entire flow:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const REDIRECT_URI = `${url.origin}/auth/callback`;

    // Route: Start OAuth flow
    if (url.pathname === "/auth/login") {
      const params = new URLSearchParams({
        client_id: env.GOOGLE_CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: "code",
        scope: "openid email profile",
        access_type: "offline",
        prompt: "consent",
      });
      return Response.redirect(
        `https://accounts.google.com/o/oauth2/v2/auth?${params}`
      );
    }

    // Route: OAuth callback — exchange code for tokens
    if (url.pathname === "/auth/callback") {
      const code = url.searchParams.get("code");
      if (!code) {
        return new Response("Missing authorization code", { status: 400 });
      }

      // Exchange code for tokens
      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: REDIRECT_URI,
          grant_type: "authorization_code",
        }),
      });

      const tokens = await tokenRes.json();
      if (!tokens.access_token) {
        return new Response("Token exchange failed", { status: 400 });
      }

      // Fetch user info from Google
      const userRes = await fetch(
        "https://www.googleapis.com/oauth2/v2/userinfo",
        { headers: { Authorization: `Bearer ${tokens.access_token}` } }
      );
      const user = await userRes.json();

      // Create a session
      const sessionId = crypto.randomUUID();
      await env.SESSIONS.put(sessionId, JSON.stringify({
        email: user.email,
        name: user.name,
        picture: user.picture,
        created: Date.now(),
      }), { expirationTtl: 86400 }); // 24 hours

      // Set session cookie and redirect to app
      return new Response(null, {
        status: 302,
        headers: {
          Location: "/",
          "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`,
        },
      });
    }

    // Route: Logout
    if (url.pathname === "/auth/logout") {
      const session = getSession(request);
      if (session) await env.SESSIONS.delete(session);
      return new Response(null, {
        status: 302,
        headers: {
          Location: "/",
          "Set-Cookie": "session=; Path=/; Max-Age=0",
        },
      });
    }

    // Route: Home page — show login state
    const session = getSession(request);
    const user = session
      ? JSON.parse(await env.SESSIONS.get(session) || "null")
      : null;

    if (user) {
      return new Response(`
        <!DOCTYPE html>
        <html>
        <body style="font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 0 1rem;">
          <h1>Welcome, ${user.name}!</h1>
          <p>Email: ${user.email}</p>
          <img src="${user.picture}" width="80" style="border-radius: 50%;">
          <p><a href="/auth/logout">Sign out</a></p>
        </body>
        </html>
      `, { headers: { "Content-Type": "text/html" } });
    }

    return new Response(`
      <!DOCTYPE html>
      <html>
      <body style="font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 0 1rem;">
        <h1>My App</h1>
        <p><a href="/auth/login">Sign in with Google</a></p>
      </body>
      </html>
    `, { headers: { "Content-Type": "text/html" } });
  },
};

// Helper: extract session ID from cookie
function getSession(request) {
  const cookie = request.headers.get("Cookie") || "";
  const match = cookie.match(/session=([^;]+)/);
  return match ? match[1] : null;
}

Understanding the Flow

RouteWhat It Does
/auth/loginRedirects user to Google's consent screen
/auth/callbackReceives the code from Google, exchanges it for tokens, fetches user info, creates session in KV, sets cookie
/auth/logoutDeletes session from KV, clears cookie
/Shows logged-in user info or login link

Step 4: Test Locally

npx wrangler dev

Visit http://localhost:8787 and click "Sign in with Google". You'll be redirected to Google, then back to your local Worker with your profile info displayed.

Localhost Redirect URI

Make sure http://localhost:8787/auth/callback is in your Google OAuth authorized redirect URIs. Google allows localhost for development without HTTPS.

Step 5: Deploy

npx wrangler deploy

After deploying, add your production URL (https://google-auth-worker.<your-account>.workers.dev/auth/callback) to Google's authorized redirect URIs.

Security Notes

Production Hardening

For a real app, also add: a CSRF state parameter (store in KV, verify on callback), scope restriction to only what you need, and rate limiting on /auth/login to prevent abuse.

Free Tier Impact

ResourceUsage per LoginDaily Budget (Free)
Worker requests3 (login redirect + callback + home)100,000
KV writes1 (create session)1,000
KV reads1 per page load (verify session)100,000

That's ~1,000 new logins per day and 100,000 authenticated page views — more than enough for personal projects.

Why does the code→token exchange happen in the Worker (server-side) rather than in the browser?
Correct! The client secret must never be exposed to the browser. By exchanging the code on the server (Worker), the secret stays in your environment variables and is never sent to the client.
Not quite. The real reason is security — the client secret must stay server-side. If it were in the browser, anyone could use it to impersonate your app.
📖 Primary Source

Google OAuth 2.0 for Web Server Applications — the official spec. Covers all parameters, scopes, and error handling in detail.

💬 Questions? Ask me about adding a CSRF state parameter, restricting to your Google Workspace domain only, or storing user data in D1 instead of just sessions in KV.
← Back Next →