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

The Callback Handler

What the callback receives

After the user approves your app on GitHub, GitHub redirects them to:

http://localhost:3000/auth/github/callback?code=abc123def456&state=random_string

Your server needs to:

  1. Read the code and state from the URL
  2. Verify the state
  3. Exchange the code for an access token

The callback route

Add to src/routes/github.ts:

// src/routes/github.ts
import { route, group } from "@hectoday/http";
import { env } from "../env.js";
import { createState, verifyState } from "../oauth-state.js";

export const githubRoutes = group([
  route.get("/auth/github", {
    resolve: (c) => {
      const state = createState();

      const params = new URLSearchParams({
        client_id: env.githubClientId,
        redirect_uri: `${env.baseUrl}/auth/github/callback`,
        scope: "read:user user:email",
        state,
      });

      return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
    },
  }),

  route.get("/auth/github/callback", {
    resolve: async (c) => {
      const url = new URL(c.request.url);
      const code = url.searchParams.get("code");
      const state = url.searchParams.get("state");

      // Step 1: Verify state
      if (!state || !verifyState(state)) {
        return Response.json({ error: "Invalid state" }, { status: 403 });
      }

      // Step 2: Check for errors from GitHub
      const error = url.searchParams.get("error");
      if (error) {
        return Response.json({ error: `GitHub authorization failed: ${error}` }, { status: 400 });
      }

      if (!code) {
        return Response.json({ error: "Missing authorization code" }, { status: 400 });
      }

      // Step 3: Exchange code for access token
      const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          accept: "application/json",
        },
        body: JSON.stringify({
          client_id: env.githubClientId,
          client_secret: env.githubClientSecret,
          code,
          redirect_uri: `${env.baseUrl}/auth/github/callback`,
        }),
      });

      const tokenData = await tokenResponse.json();

      if (tokenData.error) {
        return Response.json(
          { error: `Token exchange failed: ${tokenData.error}` },
          { status: 400 },
        );
      }

      const accessToken = tokenData.access_token as string;

      // TODO: fetch user profile and create session
      return Response.json({ message: "Token received", accessToken });
    },
  }),
]);

Let’s walk through each step.

Step 1: Verify state

const state = url.searchParams.get("state");
if (!state || !verifyState(state)) {
  return Response.json({ error: "Invalid state" }, { status: 403 });
}

We extract the state from the URL and pass it to verifyState. If the state is missing, expired, or already used, we reject the request with 403. This stops the CSRF attack described in the previous lesson.

Step 2: Check for errors

If the user denies consent (clicks “Cancel” on GitHub’s page), GitHub redirects back with an error parameter instead of a code:

/auth/github/callback?error=access_denied&state=random_string

We check for this and return a meaningful error.

Step 3: Exchange the code for a token

This is the server-to-server POST request. We use the standard fetch API to call GitHub’s token endpoint:

const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    accept: "application/json",
  },
  body: JSON.stringify({
    client_id: env.githubClientId,
    client_secret: env.githubClientSecret,
    code,
    redirect_uri: `${env.baseUrl}/auth/github/callback`,
  }),
});

The accept: "application/json" header is important. Without it, GitHub returns the token as a URL-encoded string (access_token=gho_...&token_type=bearer). With the header, GitHub returns JSON, which is easier to parse.

The client secret is in the request body. This is the only request in the entire flow where the secret is sent. It goes directly from your server to GitHub. The browser never sees it.

Token exchange errors

The exchange can fail for several reasons:

  • The code has already been used (codes are single-use)
  • The code has expired (typically 10 minutes)
  • The client_secret is wrong
  • The redirect_uri does not match

All of these produce an error field in the response. We check for it and return a clear error message.

What we have so far

The accessToken is currently returned in the response body. This is temporary (and insecure in production). In the next lesson, we will use it to fetch the user’s profile, then discard it.

[!WARNING] Never return an access token to the browser in a real application. The token grants access to the user’s GitHub data. It should stay server-side. We return it here only to verify the exchange works while developing.

Try it out

  1. Visit http://localhost:3000
  2. Click “Log in with GitHub”
  3. Authorize the app on GitHub
  4. You should be redirected back and see { "message": "Token received", "accessToken": "gho_..." }

If you see the token, the OAuth flow is working. The next step is using it.

Exercises

Exercise 1: After completing the flow once, copy the callback URL from your browser’s address bar and paste it into a new tab. You should get “Invalid state” because the state has already been verified and deleted (single-use). This confirms the replay protection is working.

Exercise 2: Look at the tokenData response from GitHub. What other fields does it include besides access_token? You should see token_type (always “bearer”) and scope (the scopes that were granted).

Why does the token exchange include the redirect_uri, even though the redirect already happened?

What does the `accept: application/json` header do in the token exchange request?

← The State Parameter Fetching the User Profile →

© 2026 hectoday. All rights reserved.