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:
- We received the token directly from Google’s token endpoint over HTTPS (not from the browser)
- The token came in the same response as the access token, which we trust
- 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
joselibrary to verify the ID token: fetch Google’s JWKS (JSON Web Key Set) fromhttps://www.googleapis.com/oauth2/v3/certsand usejwtVerify. 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
- Visit
http://localhost:3000 - Click “Log in with Google”
- Choose your Google account and approve
- You should be redirected back to
/ - Visit
/meto 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?