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

Project Setup

A fresh project

This course uses a standalone project. You do not need to have completed the “Authentication with Hectoday HTTP” course, but if you did, the patterns will be familiar: sessions, cookies, and the authenticate() function all work the same way.

Create the project

mkdir oauth-course
cd oauth-course
npm init -y

Install dependencies

npm install @hectoday/http zod srvx
npm install -D typescript @types/node tsx

These are the same tools from the auth course. Hectoday HTTP for the framework, Zod for validation, srvx to run on Node.js, tsx to run TypeScript directly.

We do not need bcrypt or jose. OAuth replaces password verification with provider-based auth, and we will use sessions (not JWTs) for ongoing identity.

Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "./src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

Environment variables

OAuth requires secrets (the client secret from GitHub and Google). These must not be committed to source code. We will use a .env file for development and process.env for production.

Install a .env loader:

npm install dotenv

Create .env in the project root:

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
BASE_URL=http://localhost:3000

Leave the GitHub and Google values empty for now. We will fill them in when we register the OAuth apps.

[!WARNING] Add .env to your .gitignore immediately. This file contains secrets that must never be committed.

Create .gitignore:

node_modules
dist
.env

Load environment variables

Create src/env.ts:

// src/env.ts
import "dotenv/config";

function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export const env = {
  githubClientId: required("GITHUB_CLIENT_ID"),
  githubClientSecret: required("GITHUB_CLIENT_SECRET"),
  baseUrl: required("BASE_URL"),
};

import "dotenv/config" loads the .env file into process.env as a side effect. The required function ensures the variable exists and throws a clear error at startup if it does not.

We start with just the GitHub variables. We will add Google later.

[!NOTE] The env object is imported wherever secrets are needed. This keeps environment variable access in one place and makes it obvious what configuration the app requires.

Session and cookie helpers

We need session management. These are the same helpers from the auth course, included here so this project is self-contained.

Create src/sessions.ts:

// src/sessions.ts
export interface Session {
  userId: string;
  createdAt: number;
}

const store = new Map<string, Session>();

export function createSession(userId: string): string {
  const id = crypto.randomUUID();
  store.set(id, { userId, createdAt: Date.now() });
  return id;
}

export function getSession(id: string): Session | undefined {
  const session = store.get(id);
  if (!session) return undefined;

  const maxAge = 24 * 60 * 60 * 1000;
  if (Date.now() - session.createdAt > maxAge) {
    store.delete(id);
    return undefined;
  }

  return session;
}

export function deleteSession(id: string): void {
  store.delete(id);
}

Create src/cookies.ts:

// src/cookies.ts
const COOKIE_NAME = "session";

export function parseCookies(header: string | null): Record<string, string> {
  if (!header) return {};

  const cookies: Record<string, string> = {};
  for (const pair of header.split(";")) {
    const [name, ...rest] = pair.trim().split("=");
    if (name) {
      cookies[name] = rest.join("=");
    }
  }
  return cookies;
}

export function getSessionId(request: Request): string | undefined {
  const cookies = parseCookies(request.headers.get("cookie"));
  return cookies[COOKIE_NAME];
}

export function sessionCookie(sessionId: string): string {
  return `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400`;
}

export function clearSessionCookie(): string {
  return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
}

User store

Create src/db.ts:

// src/db.ts
export interface User {
  id: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
  githubId: number | null;
  googleId: string | null;
}

export const users = new Map<string, User>();

export function findByGithubId(githubId: number): User | undefined {
  return Array.from(users.values()).find((u) => u.githubId === githubId);
}

export function findByGoogleId(googleId: string): User | undefined {
  return Array.from(users.values()).find((u) => u.googleId === googleId);
}

export function findByEmail(email: string): User | undefined {
  return Array.from(users.values()).find((u) => u.email === email);
}

Notice this User type is different from the auth course. There is no passwordHash or role field. Instead, there are githubId and googleId fields for linking OAuth accounts. Both are nullable because a user might only have one provider linked.

The lookup functions search by provider ID or email. These are linear scans over the Map, which is fine for learning. In production with a database, these would be indexed queries.

The app shell

Create src/app.ts:

// src/app.ts
import { setup, route } from "@hectoday/http";

export const app = setup({
  routes: [
    route.get("/", {
      resolve: () =>
        new Response(`<h1>OAuth Course</h1><p><a href="/auth/github">Log in with GitHub</a></p>`, {
          headers: { "content-type": "text/html" },
        }),
    }),

    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
  ],
});

A minimal HTML home page with a “Log in with GitHub” link. We will add routes as we build them.

Create src/server.ts:

// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";

serve({ fetch: app.fetch, port: 3000 });

The dotenv/config import is in env.ts, which gets imported when the app loads its routes. We do not need it in server.ts too.

Add scripts to package.json:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}

Verify it works

The server will fail to start because the GitHub environment variables are empty. That is expected. We will fill them in the next lesson when we register the GitHub OAuth App.

For now, temporarily comment out the required calls in src/env.ts (or set dummy values) and verify the basics:

npm run dev

Visit http://localhost:3000. You should see the “Log in with GitHub” link. The link goes to /auth/github, which will 404 for now. That is the route we build in the next section.

File layout

oauth-course/
  .env
  .gitignore
  package.json
  tsconfig.json
  src/
    app.ts          # routes, hooks
    server.ts       # starts the server
    env.ts          # environment variable access
    db.ts           # User type, in-memory store
    sessions.ts     # session store
    cookies.ts      # cookie helpers

Why do we use a .env file instead of hardcoding secrets in the source code?

Why does the User type have nullable githubId and googleId fields instead of a single 'provider' field?

← The Authorization Code Flow, Step by Step Register a GitHub OAuth App →

© 2026 hectoday. All rights reserved.