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

Error Handling

Things go wrong

The OAuth flow involves multiple HTTP requests across three parties (your server, the browser, the provider). Each step can fail. A production app needs to handle every failure gracefully.

Failure 1: User denies consent

The user clicks “Log in with GitHub” but then clicks “Cancel” on GitHub’s consent screen. GitHub redirects back with an error instead of a code:

/auth/github/callback?error=access_denied&error_description=The+user+has+denied+your+application+access.&state=abc123

Our callback handler already checks for this:

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

In a real app, you would redirect to a friendly page instead of returning JSON:

if (error) {
  return Response.redirect(`${env.baseUrl}/login?error=consent_denied`, 302);
}

The login page can show a message: “You cancelled the login. Click below to try again.”

Failure 2: Authorization code expired or already used

Codes expire (typically within 10 minutes) and are single-use. If the user takes too long, refreshes the callback page, or if the code has already been exchanged, GitHub’s token endpoint returns:

{
  "error": "bad_verification_code",
  "error_description": "The code passed is incorrect or expired."
}

Our handler checks tokenData.error. In production, redirect the user to start the flow over:

if (tokenData.error) {
  return Response.redirect(`${env.baseUrl}/login?error=code_expired`, 302);
}

Failure 3: Invalid state

If the state does not match (CSRF attack or stale browser tab), our handler returns 403. In production, redirect:

if (!state || !verifyState(state)) {
  return Response.redirect(`${env.baseUrl}/login?error=invalid_state`, 302);
}

Failure 4: Provider API failure

The fetch calls to GitHub’s API can fail due to network issues, rate limiting, or provider downtime. Wrap them in error handling:

let githubUser;
let githubEmails;
try {
  [githubUser, githubEmails] = await Promise.all([
    fetchGitHubUser(accessToken),
    fetchGitHubEmails(accessToken),
  ]);
} catch (err) {
  console.error("GitHub API error:", err);
  return Response.redirect(`${env.baseUrl}/login?error=provider_error`, 302);
}

Log the error for debugging but show the user a friendly message. “Something went wrong. Please try again.” is better than a stack trace.

Failure 5: Missing email

Some users have no verified email on their GitHub account. When email is null after both the profile and emails checks, decide what to do:

const email = githubUser.email ?? getPrimaryEmail(githubEmails);
if (!email) {
  return Response.redirect(`${env.baseUrl}/login?error=no_email`, 302);
}

You could also allow accounts without email, but then you lose the ability to link providers by email. The right choice depends on whether your app needs email addresses.

Error handling pattern

All of these failures follow the same pattern:

  1. Detect the error
  2. Log it (for your debugging)
  3. Redirect the user to a friendly page
  4. The friendly page explains what happened and offers a retry

In JSON APIs (no HTML), return a clear error response with an appropriate status code instead of redirecting.

The onError hook

For unexpected errors (bugs in your code, unhandled exceptions), use Hectoday HTTP’s onError hook as a safety net:

const app = setup({
  routes: [...],
  onError: ({ error }) => {
    console.error("Unhandled error:", error);
    return Response.json({ error: "Internal error" }, { status: 500 });
  },
});

This catches anything your handlers did not catch.

Exercises

Exercise 1: Trigger the “user denies consent” error. Start the OAuth flow with GitHub, but click “Cancel” instead of “Authorize.” Observe the error parameter in the callback URL and your handler’s response.

Exercise 2: Trigger the “code expired” error. Start the flow, authorize on GitHub, then wait on the callback page for a few minutes before loading it. Or, load the callback URL twice (the code is single-use, so the second load fails).

Exercise 3: Add proper redirect-based error handling to your callback routes. Create a GET /login route that reads the error query parameter and returns an HTML page with a friendly message and a “Try again” link.

Why should you redirect to a friendly error page instead of returning a JSON error response?

← Combining OAuth with Password Auth Logout and Token Cleanup →

© 2026 hectoday. All rights reserved.