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

Fetching the User Profile

What the access token gives you

The access token is a key to GitHub’s API. With it, your server can read data about the user who authorized your app. The scopes you requested (read:user user:email) determine what you can access.

Fetch the profile

Add a function to call GitHub’s API. Create src/github.ts:

// src/github.ts
export interface GitHubUser {
  id: number;
  login: string;
  name: string | null;
  email: string | null;
  avatar_url: string;
}

export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser> {
  const response = await fetch("https://api.github.com/user", {
    headers: {
      authorization: `Bearer ${accessToken}`,
      "user-agent": "oauth-course",
      accept: "application/vnd.github+json",
    },
  });

  if (!response.ok) {
    throw new Error(`GitHub API error: ${response.status}`);
  }

  return response.json() as Promise<GitHubUser>;
}

Three headers:

authorization: Bearer <token> — The access token, using the same Bearer scheme that JWTs use. GitHub’s API reads this header, verifies the token, and returns data for the associated user.

user-agent — GitHub’s API requires this header. Requests without it are rejected with 403 Forbidden. Any non-empty string works.

accept: application/vnd.github+json — Tells GitHub to return the latest version of their JSON API format. This is optional but recommended by GitHub’s docs.

The email problem

GitHub’s /user endpoint returns the user’s public profile. The email field is the user’s publicly visible email. Many GitHub users set their email to private, in which case this field is null.

To get the user’s actual email, you need to call a second endpoint:

// src/github.ts
export interface GitHubEmail {
  email: string;
  primary: boolean;
  verified: boolean;
}

export async function fetchGitHubEmails(accessToken: string): Promise<GitHubEmail[]> {
  const response = await fetch("https://api.github.com/user/emails", {
    headers: {
      authorization: `Bearer ${accessToken}`,
      "user-agent": "oauth-course",
      accept: "application/vnd.github+json",
    },
  });

  if (!response.ok) {
    throw new Error(`GitHub emails API error: ${response.status}`);
  }

  return response.json() as Promise<GitHubEmail[]>;
}

This returns an array of the user’s email addresses with metadata. We want the primary, verified email:

export function getPrimaryEmail(emails: GitHubEmail[]): string | null {
  const primary = emails.find((e) => e.primary && e.verified);
  return primary?.email ?? null;
}

We only trust verified emails. An unverified email could be anything the user typed in without confirming.

Wire it into the callback

Update the callback handler in src/routes/github.ts. Replace the TODO at the end:

const accessToken = tokenData.access_token as string;

// Fetch user profile and email
const [githubUser, githubEmails] = await Promise.all([
  fetchGitHubUser(accessToken),
  fetchGitHubEmails(accessToken),
]);

const email = githubUser.email ?? getPrimaryEmail(githubEmails);

// TODO: create or find local user, create session
return Response.json({
  githubUser: {
    id: githubUser.id,
    login: githubUser.login,
    name: githubUser.name,
    email,
    avatarUrl: githubUser.avatar_url,
  },
});

Add the imports at the top of the file:

import { fetchGitHubUser, fetchGitHubEmails, getPrimaryEmail } from "../github.js";

We fetch the profile and emails in parallel with Promise.all. This saves a round trip since the two requests are independent.

For the email, we first try the profile’s email field. If it is null (private), we fall back to the primary verified email from the emails endpoint.

Try it out

Go through the full flow again:

  1. Visit http://localhost:3000
  2. Click “Log in with GitHub”
  3. Authorize the app

You should now see your GitHub profile data including your name, email, and avatar URL.

The access token’s job is done

After fetching the profile and email, the access token has served its purpose. We do not need to store it (unless we plan to make more GitHub API calls later, like reading the user’s repos).

For login purposes, we only needed the token long enough to fetch the user’s identity. In the next lesson, we will create a local user account from this data and start a session.

Exercises

Exercise 1: Log in and examine the response. What fields does the GitHub profile contain? Try logging in with a GitHub account that has a private email. Does the email come from the profile or the emails endpoint?

Exercise 2: Remove the user:email scope from the authorization redirect and log in again. What happens to the email? Without that scope, the emails endpoint returns 403 Forbidden, and you only get the public profile email (which might be null).

Exercise 3: Check what happens if you use an expired or invalid access token. Change the token string before calling fetchGitHubUser and observe the error.

Why do we call both /user and /user/emails instead of just /user?

Why do we only trust verified emails from GitHub?

← The Callback Handler Creating or Linking Accounts →

© 2026 hectoday. All rights reserved.