Create your own
Lesson illustration

Handling OAuth Callbacks Securely

Hello! Welcome back to our course on mastering TanStack Start and modern authentication.

In our previous lesson, we successfully implemented the first half of the Google OAuth flow. We created a server-side route that generates a state value and PKCE parameters, stores them in secure cookies, and redirects the user to Google's authentication page.

Today, we will implement the crucial second half of this process. The learning outcome for this lesson is to create a server endpoint to handle the OAuth callback, validating the state parameter to prevent Cross-Site Request Forgery (CSRF) attacks. This endpoint will be the destination for users returning from Google, and its primary job is to ensure the integrity and security of the authentication flow before we proceed.

1. The Purpose of the Callback Endpoint

After a user successfully authenticates with Google and grants consent, Google redirects their browser back to the redirect_uri we specified in our Google Cloud project and in the authorization URL we constructed. This is our callback endpoint.

The redirect from Google will include two important query parameters:

  • code: A temporary, one-time authorization code.
  • state: The same unique, random string we generated and sent in the initial request.

Our callback handler's first and most important job is to verify that the incoming state value matches the one we stored in the user's cookie.

2. Why State Validation is Critical: Preventing CSRF

The state parameter is not just for tracking; it's a vital security measure against Cross-Site Request Forgery (CSRF). In an OAuth context, a CSRF attack could trick a logged-in user into unknowingly linking an attacker's account (e.g., their Google account) to their profile on your application. This could lead to account takeover.

To understand the mechanism and the risk, let's review a few resources.

Prevent Attacks and Redirect Users with OAuth 2.0 State Parameters

First, let's get a clear definition of this attack vector from Auth0's documentation. This will explain how the state parameter acts as a defense mechanism.

Please read the short section titled 'CSRF attacks'. Focus on how the state parameter is used to correlate the authentication request you initiated with the response you receive.

Now, let's see what happens when this validation is missing or flawed.

Flawed CSRF Protection - State Param - Hacking Oauth Pt . 2 | Live Demo on Medium.com

The video 'Flawed CSRF Protection - State Param' by Hacking Simplified provides a powerful demonstration of this vulnerability. It shows how an attacker can exploit a missing or unvalidated state parameter.

Please watch the following two segments: Theoretical Explanation (2:22 - 3:53): This explains the attack flow where an attacker links their own social media account to a victim's application account. Real-world Example: Medium.com (8:53 - 10:48): This part is particularly insightful. It shows the attack failing against Medium.com because Medium correctly implements and validates the state parameter, demonstrating the effectiveness of the defense we are about to build.

As you can see, correctly validating the state is non-negotiable for a secure OAuth implementation.

This diagram provides a clear visual of the step we are about to implement. Notice the "Verify State" check, where the application compares the state from the callback with the one it previously stored.

This diagram illustrates the OAuth flow, highlighting the critical step where the application receives the callback. It verifies that the `state` parameter from the callback URL matches the `state` value stored in its memory (in our case, a cookie) to prevent CSRF attacks.

3. Implementing the Callback Handler

Let's create the server endpoint to handle the callback. In your TanStack Start project, create a new file at src/routes/api/auth/google/callback.ts. This path should match the GOOGLE_REDIRECT_URI you defined in your .env file.

Our implementation will follow the best practices outlined in Google's official documentation.

Using OAuth 2.0 for Web Server Applications | Authorization

Google's documentation provides a concise Node.js example for handling the callback. We will model our code on this pattern.

Review the Node.js code snippet under 'Step 5: Exchange authorization code...'. Focus on how it parses the request URL (req.url), checks for q.error, and most importantly, compares q.state with the value stored in the session (req.session.state).

Here is the implementation in our TanStack Start project, using Vinxi/H3 server utilities.

// src/routes/api/auth/google/callback.ts

import {
  eventHandler,
  getQuery,
  getCookie,
  deleteCookie,
} from "vinxi/http";

export default eventHandler(async (event) => {
  // 1. Get query parameters and the state cookie
  const query = getQuery(event);
  const storedState = getCookie(event, "oauth_state");

  // 2. Validate the state parameter
  if (!query.state || !storedState || query.state !== storedState) {
    // If states don't match, it's a potential CSRF attack.
    // Abort the flow and respond with an error.
    event.node.res.statusCode = 400;
    return { error: "State mismatch", description: "Possible CSRF attack." };
  }

  // 3. Check for an error from Google
  if (query.error) {
    // The user may have denied access or another error occurred.
    event.node.res.statusCode = 401;
    return { error: query.error, description: query.error_description };
  }

  // 4. Clean up the state cookie
  // The state has served its purpose and should be removed.
  deleteCookie(event, "oauth_state", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
  });

  // 5. Get the authorization code
  const authCode = query.code as string;
  if (!authCode) {
    event.node.res.statusCode = 400;
    return { error: "Missing authorization code" };
  }

  // At this point, validation is successful.
  // We have the authorization code needed for the next step.
  // For now, we will just return it to confirm success.
  return {
    message: "OAuth callback successful. State validated.",
    authorizationCode: authCode,
  };
});

Code Breakdown:

  1. Get Parameters: We use getQuery to parse the URL's query string and getCookie to retrieve the oauth_state cookie we set in the previous lesson.
  2. Validate State: This is the most critical step. We check that both the query state and the cookie state exist and that they are identical. If not, we immediately stop and return a 400 Bad Request error.
  3. Check for Errors: We check if Google returned an error parameter. This happens if the user denies the consent request or if there's a configuration issue.
  4. Clean Up: Once the state is successfully validated, it has served its one-time purpose. We delete the cookie to maintain good security hygiene.
  5. Get Code: We extract the authorization_code from the query. This code is the key to getting our access token.

If you run your application now and go through the "Sign in with Google" flow, you should be redirected back to /api/auth/google/callback and see a JSON response confirming that the state was validated and showing you the authorization code.

Conclusion

In this lesson, we completed a critical security component of our OAuth 2.0 implementation. You have successfully built a callback endpoint that securely handles the user's return from Google.

Key Takeaways:

  • The OAuth callback endpoint receives the authorization_code and state from the provider.
  • The primary security function of the callback handler is to validate the state parameter by comparing the value from the URL query with the value stored from the initial request (e.g., in a cookie).
  • Failing to validate the state parameter exposes your application to Cross-Site Request Forgery (CSRF) attacks, which can lead to account takeover.
  • Once validated, the state value is a single-use token and its corresponding cookie should be deleted.

Next Lesson Preview:
We have now securely obtained the authorization_code. In our next lesson, we will exchange the received authorization code for access and refresh tokens from Google. This involves making a server-to-server request to Google's token endpoint, using the code, our client secret, and the PKCE code_verifier that is still waiting in its cookie.

Can't find a good explanation? Sign up and we'll make it for you

Sign up