hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses OAuth and Social Login

Why OAuth?

  • The Problem with Passwords
  • OAuth 2.0 in Plain English
  • The Authorization Code Flow, Step by Step
  • Project Setup

GitHub Login

  • Register a GitHub OAuth App
  • The Authorization Redirect
  • The State Parameter
  • The Callback Handler
  • Fetching the User Profile
  • Creating or Linking Accounts
  • The Complete Flow

Google Login

  • Register a Google OAuth App
  • Building Google Login

Production Concerns

  • Multiple Providers, One User
  • Combining OAuth with Password Auth
  • Error Handling
  • Logout and Token Cleanup
  • Common Mistakes
  • Capstone: Multi-Provider Login Page

Building Google Login

The same pattern, different URLs

Google login follows the same three steps: redirect the user, handle the callback, fetch the profile. The code structure is identical to GitHub. Only the URLs, parameters, and profile format change.

The Google routes

Create src/routes/google.ts:

// src/routes/google.ts
import { route, group } from "@hectoday/http";
import { env } from "../env.js";
import { createState, verifyState } from "../oauth-state.js";
import { findOrCreateFromGoogle } from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";

export const googleRoutes = group([
  // Step 1: Redirect to Google
  route.get("/auth/google", {
    resolve: (c) => {
      const state = createState();

      const params = new URLSearchParams({
        client_id: env.googleClientId,
        redirect_uri: `${env.baseUrl}/auth/google/callback`,
        response_type: "code",
        scope: "openid email profile",
        state,
      });

      return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
    },
  }),

  // Step 2: Handle the callback
  route.get("/auth/google/callback", {
    resolve: async (c) => {
      const url = new URL(c.request.url);
      const code = url.searchParams.get("code");
      const state = url.searchParams.get("state");

      // Verify state
      if (!state || !verifyState(state)) {
        return Response.json({ error: "Invalid state" }, { status: 403 });
      }

      const error = url.searchParams.get("error");
      if (error) {
        return Response.json({ error: `Google authorization failed: ${error}` }, { status: 400 });
      }

      if (!code) {
        return Response.json({ error: "Missing authorization code" }, { status: 400 });
      }

      // 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({
          client_id: env.googleClientId,
          client_secret: env.googleClientSecret,
          code,
          grant_type: "authorization_code",
          redirect_uri: `${env.baseUrl}/auth/google/callback`,
        }),
      });

      const tokenData = (await tokenResponse.json()) as {
        access_token?: string;
        id_token?: string;
        error?: string;
      };

      if (tokenData.error) {
        return Response.json(
          { error: `Token exchange failed: ${tokenData.error}` },
          { status: 400 },
        );
      }

      // Decode the ID token to get user info
      const idToken = tokenData.id_token;
      if (!idToken) {
        return Response.json({ error: "No ID token received" }, { status: 400 });
      }

      const payload = decodeIdToken(idToken);

      // Create or find the local user
      let result;
      try {
        result = findOrCreateFromGoogle({
          googleId: payload.sub,
          email: payload.email ?? null,
          name: payload.name ?? null,
          avatarUrl: payload.picture ?? null,
        });
      } catch (err) {
        return Response.json({ error: (err as Error).message }, { status: 409 });
      }

      const sessionId = createSession(result.user.id);

      return new Response(null, {
        status: 302,
        headers: {
          location: "/",
          "set-cookie": sessionCookie(sessionId),
        },
      });
    },
  }),
]);

// Decode a JWT ID token (we only need the payload, not verification)
function decodeIdToken(token: string): {
  sub: string;
  email?: string;
  name?: string;
  picture?: string;
} {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("Invalid ID token format");

  const payload = parts[1];
  const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
  const json = atob(base64);
  return JSON.parse(json);
}

Key differences from GitHub

Different token endpoint format

Google’s token endpoint expects application/x-www-form-urlencoded (URL-encoded form data) instead of JSON. We use URLSearchParams for the body, which produces the correct format. Google also requires a grant_type parameter set to "authorization_code".

OpenID Connect and the ID token

When you request the openid scope, Google returns an ID token alongside the access token. The ID token is a JWT that contains the user’s profile:

{
  "sub": "109472819238174",
  "email": "[email protected]",
  "name": "Alice Smith",
  "picture": "https://lh3.googleusercontent.com/a/..."
}

sub (subject) is the user’s unique Google ID. This is stable and does not change even if the user changes their email or name.

Because the profile data is in the ID token, we do not need a separate API call to fetch the user’s profile. This is one fewer HTTP request compared to GitHub.

Why we decode but do not verify the ID token

In production, you should verify the ID token’s signature to ensure it was actually issued by Google. We skip verification here because:

  1. We received the token directly from Google’s token endpoint over HTTPS (not from the browser)
  2. The token came in the same response as the access token, which we trust
  3. Verifying the ID token requires fetching Google’s public keys, which adds complexity

This is acceptable when the token comes from a trusted server-to-server response. If you ever receive an ID token from a client (like a frontend app sending it in a request body), you must verify the signature.

[!NOTE] In production, use the jose library to verify the ID token: fetch Google’s JWKS (JSON Web Key Set) from https://www.googleapis.com/oauth2/v3/certs and use jwtVerify. This course skips it to keep the focus on the OAuth flow itself.

Add findOrCreateFromGoogle to db.ts

Add this function to src/db.ts:

export function findOrCreateFromGoogle(profile: {
  googleId: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
}): { user: User; created: boolean } {
  const existing = findByGoogleId(profile.googleId);
  if (existing) {
    return { user: existing, created: false };
  }

  if (profile.email) {
    const emailConflict = findByEmail(profile.email);
    if (emailConflict) {
      throw new Error(
        `An account with email ${profile.email} already exists. See the next lesson for account linking.`,
      );
    }
  }

  const user: User = {
    id: crypto.randomUUID(),
    email: profile.email,
    name: profile.name,
    avatarUrl: profile.avatarUrl,
    githubId: null,
    googleId: profile.googleId,
  };

  users.set(user.id, user);
  return { user, created: true };
}

This is nearly identical to findOrCreateFromGithub, just using googleId instead.

Wire it in

Update src/app.ts to add a Google login link and the routes:

import { googleRoutes } from "./routes/google.js";

// In the routes array:
...googleRoutes,

// Update the home page HTML:
route.get("/", {
  resolve: () =>
    new Response(
      `<h1>OAuth Course</h1>
       <p><a href="/auth/github">Log in with GitHub</a></p>
       <p><a href="/auth/google">Log in with Google</a></p>`,
      { headers: { "content-type": "text/html" } },
    ),
}),

Try it out

  1. Visit http://localhost:3000
  2. Click “Log in with Google”
  3. Choose your Google account and approve
  4. You should be redirected back to /
  5. Visit /me to see your Google profile data

Exercises

Exercise 1: Log in with Google and inspect the /me response. Compare it to the GitHub login response. Notice that githubId is null and googleId is set (or vice versa depending on which provider you used).

Exercise 2: Decode the Google ID token yourself. Add a console.log(idToken) before the decodeIdToken call. Copy the token, split it by ., and decode the middle part to see the raw claims. You will see iss (issuer: accounts.google.com), aud (audience: your client ID), exp (expiration), and the profile fields.

Why can we skip ID token signature verification in this case?

What is the `sub` claim in a Google ID token?

← Register a Google OAuth App Multiple Providers, One User →

© 2026 hectoday. All rights reserved.