Hello! Welcome back.
In our last lesson, we successfully retrieved a user's verified profile from Google by validating the id_token. We now have trustworthy information like the user's Google ID, email, and name.
This lesson tackles the crucial next step: integrating this external identity into our own application's authentication system. Our goal is to map the OAuth identity to an application-specific JWT. This will decouple our application's session management from Google, giving us full control over session duration, token contents, and security, creating a unified authentication strategy regardless of the login method.
By the end of this lesson, you will have a complete Google login flow that ends with your server issuing its own secure JWT, stored in an HTTP-only cookie, to manage the user's session.
1. The "Why": From Google's Tokens to Our JWT
First, let's clarify why we generate our own token instead of just passing Google's tokens around. While Google's id_token confirms identity, it's designed for that specific purpose. Our application needs a session token tailored to its own needs.
- Control: We control the payload, including our internal user ID, roles, and permissions.
- Consistency: All users, whether they log in with a password, Google, or another method, will end up with the same type of application JWT. This simplifies protected routes and API calls.
- Decoupling: Our application's sessions are not dependent on the lifecycle of Google's tokens. We can set our own expiration policies.
This process involves using the OAuth protocol to get a verified identity and then using the JWT standard to create our own session token.
The article 'OAuth vs JWT' from Frontegg clearly explains how these two standards can be used together. It reinforces that OAuth is a protocol, while JWT is a token format that can be used within it.
Please read the section 'Using JWT with OAuth2'. Focus on the idea that the OAuth2 protocol doesn't specify a token format, and a JWT can be used to carry information, which is exactly what we are about to do.
2. The Authentication Flow: A High-Level View
The complete flow, from the user clicking the Google button to our server issuing a JWT, can be summarized in a few steps.
Integrating JWT Authentication with Google OAuth in NestJS
This article on integrating Google OAuth with NestJS provides a perfect, framework-agnostic overview of the flow we are implementing. It clearly outlines the responsibilities of the client and the server.
Read the 'Overview of the Flow' section. This diagrammatically lays out the exact steps we are taking: the client sends the Google ID token, and the server verifies it, finds or creates a user, and returns its own JWT.
This flow introduces a key step: "creates a new user or retrieves an existing one." Let's formalize this.
3. The "Find or Create" User Pattern
Before we can issue a JWT for a user, we need a record of that user in our own system. The verified profile from Google is the source of truth for creating this record.
The standard pattern is:
- Receive the verified user profile from Google (containing
subas the unique Google ID). - Query our application's database for a user where
googleIdmatches thesubfrom the payload. - If a user is found: We've identified an existing user. We can proceed to log them in. You might also update their profile picture or name from the latest Google data here.
- If no user is found: This is their first time logging in with Google. We create a new user record in our database, storing their
googleId,email,name, etc.
For this lesson, we'll simulate this database interaction with a simple in-memory store to keep the focus on the authentication logic.
4. Implementation: Generating and Storing the JWT
Let's put this all together in our callback handler. We'll use the jose library, a modern and robust solution for all things JWT.
First, install jose:
npm install jose
Next, add your JWT secret to your environment file. This secret is critical for signing and verifying your tokens.
File: .env
# ... existing variables
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
GOOGLE_REDIRECT_URI="http://localhost:3000/api/auth/google/callback"
# Add this
JWT_SECRET="a-very-strong-and-long-secret-key-that-is-at-least-32-characters"
Now, let's watch a video that demonstrates this exact implementation pattern.
How to integrate Google Sign-In with Expo I Expo Router API Routes
The video 'How to integrate Google Sign-In with Expo' provides an excellent walkthrough of the server-side logic for creating a custom JWT. Although it's in an Expo context, the server-side logic using jose is directly applicable to our TanStack Start project.
Please watch these two segments: Detailed OAuth Flow Diagram and JWT Generation (08:27 - 14:22): Pay close attention to the diagram and the explanation of why a custom JWT is generated. This visualizes the concept perfectly. Implementing the Token API Endpoint (01:03:29 - 01:09:28): Focus on how the code uses jose to first decode the Google id_token and then create and sign a new, custom JWT with a specific payload and expiration. This is the code pattern we will be implementing.
Inspired by that video and our plan, let's update our callback file. We will add the logic to find/create a user, generate a JWT, store it in a cookie, and redirect the user to the application's homepage.
Update the file: src/routes/api/auth/google/callback.ts
import {
eventHandler,
getQuery,
getCookie,
deleteCookie,
setCookie,
sendRedirect,
} from "vinxi/http";
import { oauth2Client } from "../../../../lib/google-auth";
import * as jose from "jose";
// --- In-memory user store (simulation) ---
// In a real app, this would be a database (e.g., PostgreSQL, MongoDB)
interface User {
id: string;
googleId: string;
email: string;
name: string;
}
const users: User[] = [];
let nextUserId = 1;
async function findOrCreateUser(profile: {
sub: string;
email?: string | null;
name?: string | null;
}): Promise<User> {
let user = users.find((u) => u.googleId === profile.sub);
if (user) {
return user;
}
// Create new user
const newUser: User = {
id: (nextUserId++).toString(),
googleId: profile.sub,
email: profile.email || "No Email",
name: profile.name || "No Name",
};
users.push(newUser);
console.log("New user created:", newUser);
return newUser;
}
// --- End of simulation ---
export default eventHandler(async (event) => {
const query = getQuery(event);
const storedState = getCookie(event, "oauth_state");
if (!query.state || !storedState || query.state !== storedState) {
event.node.res.statusCode = 400;
return { error: "State mismatch" };
}
// Clean up cookies
const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === "production", path: "/" };
deleteCookie(event, "oauth_state", cookieOptions);
deleteCookie(event, "pkce_code_verifier", cookieOptions);
if (query.error) {
// Handle user cancellation or other errors from Google
// We will explore this more in the next lesson.
return sendRedirect(event, `/?error=${query.error}`);
}
const authCode = query.code as string;
const storedCodeVerifier = getCookie(event, "pkce_code_verifier");
if (!authCode || !storedCodeVerifier) {
event.node.res.statusCode = 400;
return { error: "Missing authorization code or PKCE verifier" };
}
try {
const { tokens } = await oauth2Client.getToken({
code: authCode,
codeVerifier: storedCodeVerifier,
});
if (!tokens.id_token) {
throw new Error("ID token not found");
}
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload) {
throw new Error("Failed to get token payload");
}
// --- NEW LOGIC STARTS HERE ---
// 1. Find or create the user in our system
const user = await findOrCreateUser(payload);
// 2. Create our application-specific JWT
const secret = new TextEncoder().encode(process.env.VITE_JWT_SECRET);
const alg = "HS256";
const appJwt = await new jose.SignJWT({
sub: user.id, // Our internal user ID
email: user.email,
name: user.name,
})
.setProtectedHeader({ alg })
.setIssuedAt()
.setIssuer("urn:example:issuer") // Your app's identifier
.setAudience("urn:example:audience") // The intended recipient
.setExpirationTime("2h") // Token lifetime
.sign(secret);
// 3. Store the JWT in a secure, httpOnly cookie
setCookie(event, "auth_token", appJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 2, // 2 hours
});
// 4. Redirect the user to the home page
return sendRedirect(event, "/");
} catch (error: any) {
console.error("Authentication process failed:", error.response?.data || error.message);
return sendRedirect(event, "/?error=authentication_failed");
}
});
Code Breakdown
findOrCreateUserSimulation: We added a simple function that mimics finding a user by their Google ID or creating a new one with an internal, auto-incrementingid. This stands in for your database logic.- JWT Generation (
jose):- We encode our
JWT_SECRETfor the signing algorithm. new jose.SignJWT()creates the token. The payload now contains our internaluser.idas the subject (sub), which is a critical best practice.- We set standard claims like
iat(Issued At),iss(Issuer),aud(Audience), andexp(Expiration Time). .sign(secret)completes the process, producing the JWT string.
- We encode our
- Set Cookie: We use
setCookieto store our newappJwtin a cookie namedauth_token. ThehttpOnlyflag is crucial for security, as it prevents client-side JavaScript from accessing the cookie. - Redirect: Finally,
sendRedirect(event, "/")sends the user back to the main application page, now with the session cookie set. Your application's auth context (which we built in Module 3) can now read this cookie on the server to determine the user's login state.
Conclusion
Congratulations! You have successfully bridged the gap between an external OAuth provider and your internal authentication system. You now have a complete, secure Google login flow.
Key Takeaways:
- Mapping an OAuth identity to an application-specific JWT is a standard pattern that provides control, consistency, and security.
- The "find or create" logic is essential for linking the external provider's user ID to your internal user records.
- The
joselibrary provides a modern, standards-compliant way to create and sign JWTs. - Storing the application JWT in a secure,
httpOnlycookie is the final step to establishing a server-managed session.
Next Lesson Preview:
Our "happy path" is complete, but what happens when things go wrong? A user might cancel the login on the Google consent screen, or Google might return an error. In the next lesson, we will focus on handling user-initiated cancellations and provider-side errors gracefully to provide a robust and user-friendly experience.
Can't find a good explanation? Sign up and we'll make it for you
Sign up