Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Handling OAuth Server-Side Failures

Recommended resources

The freely available resources this lesson will direct you to.

Hello! Welcome back to our final lesson in this course on production-ready authentication patterns.

In our last session, we built a powerful, type-safe, and reusable authorization system by composing checks at the route and API levels. We focused on the "happy path"—what happens when a user is correctly authenticated and authorized. But as any experienced developer knows, a truly robust system is defined by how it handles failure.

Today, we'll address the other side of that coin. Your goal is to learn how to manage and log server-side failures during the OAuth token exchange and user info retrieval process. We'll focus on the critical moment in the OAuth flow: when your server receives a callback from Google and attempts to exchange an authorization code for tokens. Many things can go wrong here, and simply letting your application crash or fail silently is not an option in a production environment.

We will explore how to anticipate specific failure modes, handle them gracefully from the user's perspective, and—most importantly—create a detailed, structured log of these events so you can diagnose and resolve issues quickly.

1. The Anatomy of an OAuth Callback Failure

Let's pinpoint the exact moment of risk. After a user approves your application on Google's consent screen, Google redirects them back to your specified callback URL (e.g., /api/auth/google/callback). This request includes an authorization_code and a state parameter.

Your server-side handler for this route is responsible for several critical steps:

  1. Validating the state parameter to prevent Cross-Site Request Forgery (CSRF).
  2. Exchanging the authorization_code for an access_token and refresh_token by making a POST request to Google's token endpoint.
  3. Using the access_token to fetch the user's profile information from Google's user info endpoint.

Steps 2 and 3 involve network requests to an external service (Google), and this is where a multitude of issues can arise. A failure here could be due to a user action (like taking too long), a configuration error on your part, or a problem on Google's end.

2. Identifying and Categorizing Failure Modes

To handle errors effectively, you first need to know what you're up against. Generic catch (e) blocks aren't enough. We need to understand the specific error responses Google provides.

Google's documentation is your best friend here. It provides a detailed list of potential errors that can occur during the token exchange.

Using OAuth 2.0 for Server to Server Applications

The Google Identity documentation provides a crucial table of error codes that their OAuth 2.0 endpoint can return. Understanding these is fundamental to building a resilient error-handling strategy.

Please review the section titled 'JWT error codes'. While the title refers to JWTs for service accounts, these error codes are standard for the token endpoint and are highly relevant to our web server flow. Pay close attention to the error field, its meaning, and the suggested resolution. This table is the foundation for our error handling logic.

After reviewing the table, you can see that errors fall into a few broad categories:

  • Client Configuration Errors (invalid_client, unauthorized_client): These happen if your GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET are incorrect, or if your app isn't authorized for the requested scopes in the Google Cloud Console. These are developer-fixable problems.
  • Invalid Grant Errors (invalid_grant): These are common. They can mean the authorization code is expired, has already been used, or is malformed. The user might have taken too long on the consent screen, or tried to refresh the callback page. These are often user-recoverable.
  • Scope & Policy Errors (invalid_scope, admin_policy_enforced): These indicate a mismatch between the scopes your application requests and what's allowed, either by configuration or by the user's Google Workspace administrator policies. These are configuration or environmental issues.

3. Implementing Graceful Error Handling

Now, let's translate this knowledge into code within our TanStack Start server function for the OAuth callback. The core strategy is to wrap the external calls in a try...catch block and inspect the error object to make an informed decision.

Most OAuth client libraries (like lucia-auth's providers or oauth4webapi) will throw an error object that contains the specific code from the provider.

Here is a conceptual implementation of an OAuth callback handler:

// Example: src/routes/api/auth/google/callback.ts
import { server$ } from '@tanstack/bling';
import { exchangeCodeForTokens, getUserInfo } from '~/server/google-auth'; // Your auth functions
import { log }s from '~/server/logging'; // Our new logger

export const GET = server$(async (c) => {
  const { code, state, error: oauthError } = c.req.query();
  const storedState = getCookie(c, 'oauth_state');

  // 1. Handle user cancellation or direct errors from Google
  if (oauthError) {
    log('WARN', 'OAuth flow cancelled by user or provider error', {
      error: oauthError,
    });
    return c.redirect('/login?error=access_denied');
  }

  // 2. Validate CSRF state token
  if (!state || !storedState || state !== storedState) {
    log('ERROR', 'OAuth state mismatch (potential CSRF)', {
      receivedState: state,
      expectedState: storedState,
    });
    return c.redirect('/login?error=invalid_state');
  }

  try {
    // 3. Attempt token exchange
    const tokens = await exchangeCodeForTokens(code);

    // 4. Attempt to fetch user info
    const googleUser = await getUserInfo(tokens.accessToken);
    
    // ... success path: create session, set cookie, redirect to dashboard ...
    return c.redirect('/dashboard');

  } catch (e) {
    // 5. THIS IS OUR FOCUS: The catch block
    if (e instanceof OAuth2RequestError) { // Assuming a typed error from a library
      log('ERROR', 'OAuth token exchange failed', {
        errorCode: e.error, // e.g., 'invalid_grant'
        errorDescription: e.error_description,
        // Add a traceId if you have one from a middleware
      });

      // Decide what to tell the user based on the error
      switch (e.error) {
        case 'invalid_grant':
        case 'invalid_request':
          // User-recoverable error. Guide them back to the start.
          return c.redirect('/login?error=auth_failed');
        
        case 'invalid_client':
        case 'unauthorized_client':
        case 'invalid_scope':
          // Developer/configuration error. Show a generic error page.
          // The detailed log is for the dev team.
          return c.redirect('/login?error=server_config_error');
        
        default:
          // Unknown error, log it and show a generic message.
          log('ERROR', 'Unknown OAuth error', { originalError: e });
          return c.redirect('/login?error=unknown');
      }
    }

    // Handle non-OAuth errors (e.g., network failure)
    log('ERROR', 'An unexpected error occurred during OAuth callback', { error: e });
    return c.redirect('/login?error=unknown');
  }
});

This approach achieves two goals:

  1. Manages the failure: It provides a reasonable user experience by redirecting them with a hint about what went wrong, rather than showing a broken page.
  2. Sets the stage for logging: It identifies and categorizes the error, preparing the exact information we need to log for observability.

4. Implementing Structured Logging

console.log is useful for development, but in production, you need structured, searchable logs. Plain text messages are difficult to parse, filter, and alert on automatically. This is where structured logging in a format like JSON comes in.

Logging Best Practices

To understand why and how to structure our logs, let's look at some industry best practices. This article from Curity provides excellent guidance on production-grade logging for authentication systems.

Please read the sections 'Use Technical Support Logs', 'JSON Log Formats', and 'Log Ingestion'. Focus on how error events are logged with specific fields, the benefits of a JSON structure, and the example of a clean, type-safe log entry after processing.

From the resource, we can extract several key principles for our logger:

  • Use Log Levels: Differentiate the severity of events (INFO, WARN, ERROR).
  • Use a Structured Format (JSON): This makes logs machine-readable for platforms like Datadog, Splunk, or Elastic.
  • Include Rich Context: A log message like "Error" is useless. A good log entry includes a timestamp, log level, a clear message, and a payload with relevant data like error codes, trace IDs, and user identifiers (if safe to do so).

Let's create a very simple structured logger for our TanStack Start application. In a real-world project, you'd use a battle-tested library like pino, but this demonstrates the core concept.

// src/server/logging.ts

/**
 * A simple structured JSON logger.
 * In production, use a dedicated library like pino for performance and features.
 */
export function log(
  level: 'INFO' | 'WARN' | 'ERROR',
  message: string,
  context: Record<string, any> = {}
) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...context,
  };

  // In a real app, you might write to different streams based on level
  if (level === 'ERROR') {
    console.error(JSON.stringify(logEntry));
  } else {
    console.log(JSON.stringify(logEntry));
  }
}

Now, when we use log() in our catch block from the previous section, we're no longer just printing a string. We're generating a structured, data-rich event.

A failed token exchange due to an expired code would produce a log entry like this in your server's output:

{
  "timestamp": "2023-10-27T10:30:00.123Z",
  "level": "ERROR",
  "message": "OAuth token exchange failed",
  "errorCode": "invalid_grant",
  "errorDescription": "Bad Request"
}

This is infinitely more valuable than a simple console.error('oops'). Your log aggregation platform can now parse this, and you can build dashboards to track the frequency of invalid_grant errors or set up alerts for critical errors like invalid_client.

Test your understanding!

Your application starts seeing a spike in admin_policy_enforced errors from the Google OAuth callback.

  1. According to the documentation, what is the likely meaning of this error?
  2. What log level would be appropriate for this event?
  3. What specific pieces of contextual information would be most valuable to include in the structured log entry to help a developer or administrator debug this?
Show answer
  1. Meaning: The user's Google Workspace administrator has policies that prevent your application from being granted one or more of the requested scopes.
  2. Log Level: ERROR is appropriate. While not a bug in your code per se, it's a hard failure preventing a user from logging in due to an environmental constraint.
  3. Contextual Information:
    • The errorCode itself: admin_policy_enforced.
    • The scopes that your application requested. This is crucial for the admin to check against their policies.
    • The user's email or domain (if you have it and it's permissible under your privacy policy) to identify which organization is affected.
    • A traceId to correlate this event with other logs from the same request.

Conclusion

Congratulations on completing the course! In this final lesson, you've tackled a crucial aspect of building production-ready applications: robust failure handling and observability. You have learned that simply catching errors is not enough; you must understand, categorize, and log them in a structured way to maintain a healthy and debuggable system.

Key Takeaways:

  • Anticipate Failures: The OAuth callback is a critical failure point. Use provider documentation to understand the specific errors that can occur.
  • Handle Gracefully: Differentiate between user-recoverable errors (e.g., expired code) and system/configuration errors (e.g., invalid client ID). Guide the user accordingly.
  • Log with Structure: Avoid plain text console.log. Use structured JSON logging to make server-side events machine-readable and easy to analyze.
  • Context is King: Your logs should include not just an error message, but also the severity (log level), a timestamp, and rich contextual data (error codes, trace IDs) to make debugging effective.

By combining the advanced composition patterns from our last lesson with the resilient error handling and logging strategies from today, you are now equipped to build sophisticated, secure, and maintainable authentication systems with TanStack Start. You've covered the entire lifecycle, from project setup and routing to advanced OAuth flows and production-grade observability.

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

Sign up