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

Combining OAuth with Password Auth

Why offer both

Some users prefer social login for convenience. Others prefer passwords because they do not want to depend on a third party, or their employer restricts which OAuth providers they can use.

Offering both gives users the choice. The same local user account can be accessed via password, GitHub, Google, or any combination.

The merged User type

Our User type needs a passwordHash field alongside the provider IDs. Update the User interface in src/db.ts:

export interface User {
  id: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
  passwordHash: string | null; // null if the user only uses OAuth
  githubId: number | null;
  googleId: string | null;
}

You will also need to update findOrCreateFromGithub and findOrCreateFromGoogle to include passwordHash: null when creating new users, so the new field is always present.

All three auth fields (passwordHash, githubId, googleId) are nullable. A user might have:

  • Only a password (signed up with email/password)
  • Only GitHub (signed up with GitHub)
  • Password and Google (signed up with password, later linked Google)
  • All three

Signup with password

Add bcrypt and create a signup route:

npm install bcryptjs
npm install -D @types/bcryptjs
// In src/routes/auth.ts
import bcrypt from "bcryptjs";
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import { users, findByEmail } from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie, getSessionId, clearSessionCookie } from "../cookies.js";
import { deleteSession } from "../sessions.js";

const SignupBody = z.object({
  email: z.email(),
  password: z.string().min(8, "Password must be at least 8 characters"),
  name: z.string().min(1).optional(),
});

const LoginBody = z.object({
  email: z.email(),
  password: z.string().min(1),
});

export const authRoutes = group([
  route.post("/signup", {
    request: { body: SignupBody },
    resolve: async (c) => {
      if (!c.input.ok) {
        return Response.json({ error: c.input.issues }, { status: 400 });
      }

      const { email, password, name } = c.input.body;

      if (findByEmail(email)) {
        return Response.json({ error: "Email already registered" }, { status: 409 });
      }

      const passwordHash = await bcrypt.hash(password, 10);

      const user = {
        id: crypto.randomUUID(),
        email,
        name: name ?? null,
        avatarUrl: null,
        passwordHash,
        githubId: null,
        googleId: null,
      };

      users.set(user.id, user);

      const sessionId = createSession(user.id);

      return Response.json(
        { id: user.id, email: user.email },
        {
          status: 201,
          headers: { "set-cookie": sessionCookie(sessionId) },
        },
      );
    },
  }),

  route.post("/login", {
    request: { body: LoginBody },
    resolve: async (c) => {
      if (!c.input.ok) {
        return Response.json({ error: c.input.issues }, { status: 400 });
      }

      const { email, password } = c.input.body;

      const user = findByEmail(email);
      if (!user || !user.passwordHash) {
        return Response.json({ error: "Invalid email or password" }, { status: 401 });
      }

      const valid = await bcrypt.compare(password, user.passwordHash);
      if (!valid) {
        return Response.json({ error: "Invalid email or password" }, { status: 401 });
      }

      const sessionId = createSession(user.id);

      return Response.json(
        { id: user.id, email: user.email },
        {
          status: 200,
          headers: { "set-cookie": sessionCookie(sessionId) },
        },
      );
    },
  }),

  route.post("/logout", {
    resolve: (c) => {
      const sessionId = getSessionId(c.request);
      if (sessionId) deleteSession(sessionId);

      return Response.json(
        { message: "Logged out" },
        { headers: { "set-cookie": clearSessionCookie() } },
      );
    },
  }),
]);

Notice the login route checks !user.passwordHash. If a user signed up with GitHub and has no password, they cannot use password login. The error message is the same (“Invalid email or password”) to prevent enumeration.

Account linking with passwords

The OAuth findOrCreateFromGithub and findOrCreateFromGoogle functions already link by email. If a user signs up with password and email [email protected], then logs in with GitHub (which has the same email), the GitHub ID is added to the existing account.

This works in the other direction too. If a user signs up with GitHub (no password), they can later add a password through a “set password” route:

route.post("/me/password", {
  request: {
    body: z.object({
      password: z.string().min(8),
    }),
  },
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    user.passwordHash = await bcrypt.hash(c.input.body.password, 10);

    return Response.json({ message: "Password set" });
  },
});

Now the user can log in with either their provider or their password.

The home page, updated

route.get("/", {
  resolve: () =>
    new Response(
      `<h1>OAuth Course</h1>
       <p><a href="/auth/github">Log in with GitHub</a></p>
       <p><a href="/auth/google">Log in with Google</a></p>
       <p>Or use <code>POST /signup</code> and <code>POST /login</code> for email/password.</p>`,
      { headers: { "content-type": "text/html" } },
    ),
}),

Exercises

Exercise 1: Sign up with email/password. Then log in with GitHub using the same email. Visit /me and verify both passwordHash (not the value, just that it exists) and githubId are on the same user.

Exercise 2: Sign up with GitHub (no password). Try POST /login with your email and a random password. Verify you get 401 because the user has no passwordHash. Then add a password via POST /me/password and try again.

A user signed up with GitHub and has no password. What happens when they try POST /login with their email?

← Multiple Providers, One User Error Handling →

© 2026 hectoday. All rights reserved.