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:
- Detect the error
- Log it (for your debugging)
- Redirect the user to a friendly page
- 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?