hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authentication with @hectoday/http

What Is Authentication?

  • Who Are You?
  • HTTP Is Stateless
  • Project Setup

Passwords

  • Why Not Store Passwords Directly
  • Hashing with bcrypt
  • Building a Signup Route
  • Building a Login Route

Sessions and Cookies

  • What Is a Cookie?
  • What Is a Session?
  • Building Session Management
  • Protecting Routes
  • Logout
  • Cookie Security

Tokens

  • What Is a Token?
  • Anatomy of a JWT
  • Creating JWTs
  • Verifying JWTs
  • Sessions vs. Tokens

Putting It Together

  • Authorization
  • Common Mistakes
  • Capstone: User Management API

Capstone: user management API

We made it. Over the last four sections, you built up every piece of a working authentication system: hashed passwords, sessions, cookies, JWTs, protected routes, role-based checks, and a whole catalog of things not to do. In this final lesson we pull it all together into one complete API. Nothing genuinely new happens here. Instead, you will see every concept from the course working side by side in one project. Think of this as the “final form” of everything you have been building.

What we are building

A user management API that does:

  • Signup, create an account with email and password
  • Login, authenticate and receive a session cookie
  • Logout, destroy the session
  • GET /me, view your own profile (authenticated)
  • GET /users, list all users (admin only)
  • DELETE /users/:id, delete a user (admin only)
  • A seeded admin account so you can test admin routes right away
  • Token-based routes too, so you can see both auth flavors

It is a complete, production-shaped set of endpoints. Not bad for a course that started with “HTTP does not remember you.”

Project structure

src/
  app.ts              # setup(), routes and hooks
  server.ts           # starts the server
  db.ts               # User type, in-memory store, seed data
  schemas.ts          # Zod schemas
  sessions.ts         # session store
  cookies.ts          # cookie helpers
  jwt.ts              # JWT helpers (from Section 4)
  auth.ts             # authenticate, requireAdmin, compositions
  routes/
    auth.ts           # POST /signup, POST /login, POST /logout
    users.ts          # GET /me, GET /users, DELETE /users/:id
    token-auth.ts     # POST /token/login, GET /token/me

You have written most of these already. The next few sections are the final versions of each file.

Step 0: server and supporting modules

These modules were built throughout the course. Here is the complete form of each.

src/server.ts

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

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

src/schemas.ts

// src/schemas.ts
import * as z from "zod/v4";

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

export const LoginBody = z.object({
  email: z.email(),
  password: z.string().min(1, "Password is required"),
});

src/sessions.ts

// src/sessions.ts
export interface Session {
  userId: string;
  email: string;
  role: "user" | "admin";
  createdAt: number;
}

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

export function createSession(data: Omit<Session, "createdAt">): string {
  const id = crypto.randomUUID();
  store.set(id, { ...data, createdAt: Date.now() });
  return id;
}

export function getSession(id: string): Session | undefined {
  return store.get(id);
}

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

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`;
}

src/jwt.ts

// src/jwt.ts
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET ?? "development-secret-change-in-production-32chars!",
);

export async function createToken(payload: {
  userId: string;
  email: string;
  role: string;
}): Promise<string> {
  const token = await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("24h")
    .sign(secret);

  return token;
}

export async function verifyToken(
  token: string,
): Promise<{ userId: string; email: string; role: "admin" | "user" } | null> {
  try {
    const { payload } = await jwtVerify(token, secret);

    if (
      typeof payload.userId !== "string" ||
      typeof payload.email !== "string" ||
      (payload.role !== "admin" && payload.role !== "user")
    ) {
      return null;
    }

    return {
      userId: payload.userId,
      email: payload.email,
      role: payload.role,
    };
  } catch {
    return null;
  }
}

Step 1: seed an admin user

We want an admin account to exist right when the server starts so you can test the admin-only routes immediately without having to manually promote someone. Update src/db.ts:

// src/db.ts
import bcrypt from "bcryptjs";

export interface User {
  id: string;
  email: string;
  passwordHash: string;
  role: "user" | "admin";
}

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

// Seed an admin user
async function seed() {
  const passwordHash = await bcrypt.hash("admin123", 10);
  users.set("[email protected]", {
    id: crypto.randomUUID(),
    email: "[email protected]",
    passwordHash,
    role: "admin",
  });
}

seed();

Now, when the server starts, there is already an admin account you can log in with: [email protected] / admin123.

[!NOTE] In a real app, you would seed admin accounts through a database migration or a CLI command, not in application code. This is just a shortcut for our course so we can demo the admin routes without extra setup.

[!TIP] The seed() function is async, but we call it without await at the top level of the module. That means there is a tiny window where the server is running but the admin user has not been created yet. In practice this is harmless because bcrypt.hash finishes in milliseconds, well before you send your first request. But if you ever see “Invalid email or password” when trying to log in as admin immediately after a server restart, just wait a moment and try again.

Step 2: the auth module

Here is the final src/auth.ts with all the functions we built throughout the course:

// src/auth.ts
import type { User } from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";
import { verifyToken } from "./jwt.js";

type AuthenticatedUser = Omit<User, "passwordHash">;

// Session-based authentication
export function authenticate(request: Request): AuthenticatedUser | Response {
  const sessionId = getSessionId(request);
  if (!sessionId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const session = getSession(sessionId);
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return {
    id: session.userId,
    email: session.email,
    role: session.role,
  };
}

// Token-based authentication
export async function authenticateToken(request: Request): Promise<AuthenticatedUser | Response> {
  const header = request.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const token = header.slice(7);
  const payload = await verifyToken(token);
  if (!payload) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return {
    id: payload.userId,
    email: payload.email,
    role: payload.role,
  };
}

// Authorization: require admin role
export function requireAdmin(user: AuthenticatedUser): true | Response {
  if (user.role !== "admin") {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
  return true;
}

// Composed: session auth + admin check
export function authenticatedAdmin(request: Request): AuthenticatedUser | Response {
  const user = authenticate(request);
  if (user instanceof Response) return user;

  const admin = requireAdmin(user);
  if (admin instanceof Response) return admin;

  return user;
}

Four functions, each doing one thing. Each is testable on its own without spinning up any HTTP.

Step 3: the routes

Here is the full src/routes/users.ts:

// src/routes/users.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import { users } from "../db.js";
import { authenticate, authenticatedAdmin } from "../auth.js";

export const userRoutes = group([
  // View your own profile
  route.get("/me", {
    resolve: (c) => {
      const caller = authenticate(c.request);
      if (caller instanceof Response) return caller;

      return Response.json({ user: caller });
    },
  }),

  // List all users (admin only)
  route.get("/users", {
    resolve: (c) => {
      const caller = authenticatedAdmin(c.request);
      if (caller instanceof Response) return caller;

      const allUsers = Array.from(users.values()).map((u) => ({
        id: u.id,
        email: u.email,
        role: u.role,
      }));

      return Response.json({ users: allUsers });
    },
  }),

  // Delete a user (admin only)
  route.delete("/users/:id", {
    request: {
      params: z.object({ id: z.string().uuid() }),
    },
    resolve: (c) => {
      const caller = authenticatedAdmin(c.request);
      if (caller instanceof Response) return caller;

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

      const { id } = c.input.params;

      // Find the user by ID
      const target = Array.from(users.entries()).find(([, u]) => u.id === id);

      if (!target) {
        return Response.json({ error: "User not found" }, { status: 404 });
      }

      // Prevent deleting yourself
      if (target[1].id === caller.id) {
        return Response.json({ error: "Cannot delete your own account" }, { status: 400 });
      }

      users.delete(target[0]); // delete by email (Map key)

      return new Response(null, { status: 204 });
    },
  }),
]);

Notice the pattern in every handler: auth first, input validation next, then the business logic. Each step can short-circuit the handler with an early return.

The “cannot delete yourself” check is a small but important safety detail. If the only admin deletes their own account, there is no admin left to manage the system. It is the kind of thing you do not think about until you lock yourself out once.

Step 3b: the auth routes

Complete src/routes/auth.ts:

// src/routes/auth.ts
import bcrypt from "bcryptjs";
import { route, group } from "@hectoday/http";
import { users } from "../db.js";
import { SignupBody, LoginBody } from "../schemas.js";
import { createSession, deleteSession } from "../sessions.js";
import { sessionCookie, getSessionId, clearSessionCookie } from "../cookies.js";

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 } = c.input.body;

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

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

      const user = {
        id: crypto.randomUUID(),
        email,
        passwordHash,
        role: "user" as const,
      };

      users.set(email, user);

      return Response.json({ id: user.id, email: user.email, role: user.role }, { status: 201 });
    },
  }),

  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 = users.get(email);
      if (!user) {
        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({
        userId: user.id,
        email: user.email,
        role: user.role,
      });

      return Response.json(
        { user: { id: user.id, email: user.email, role: user.role } },
        {
          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" },
        {
          status: 200,
          headers: {
            "set-cookie": clearSessionCookie(),
          },
        },
      );
    },
  }),
]);

Step 3c: token-based auth routes

Complete src/routes/token-auth.ts:

// src/routes/token-auth.ts
import bcrypt from "bcryptjs";
import { route, group } from "@hectoday/http";
import { users } from "../db.js";
import { LoginBody } from "../schemas.js";
import { createToken } from "../jwt.js";
import { authenticateToken } from "../auth.js";

export const tokenAuthRoutes = group([
  route.post("/token/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 = users.get(email);
      if (!user) {
        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 token = await createToken({
        userId: user.id,
        email: user.email,
        role: user.role,
      });

      return Response.json({ token });
    },
  }),

  route.get("/token/me", {
    resolve: async (c) => {
      const caller = await authenticateToken(c.request);
      if (caller instanceof Response) return caller;

      return Response.json({ user: caller });
    },
  }),
]);

Step 4: the app

Finally, src/app.ts brings everything together. We will also add a couple of nice hooks to log requests and catch unexpected errors:

// src/app.ts
import { setup, route } from "@hectoday/http";
import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { tokenAuthRoutes } from "./routes/token-auth.js";

export const app = setup({
  onRequest: ({ request }) => ({
    startTime: Date.now(),
  }),

  routes: [
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
    ...authRoutes,
    ...userRoutes,
    ...tokenAuthRoutes,
  ],

  onResponse: ({ request, response, locals }) => {
    const duration = Date.now() - locals.startTime;
    const url = new URL(request.url);
    console.log(`${request.method} ${url.pathname} ${response.status} ${duration}ms`);
    return response;
  },

  onError: ({ error }) => {
    console.error(error);
    return Response.json({ error: "Internal error" }, { status: 500 });
  },
});

onRequest runs before every handler and records the start time. onResponse runs after every handler and logs the method, path, status, and duration. onError catches any unexpected error so an unhandled exception does not leak stack traces to the client. These are standard ops hooks you will want in any real app.

Step 5: try the full flow

Start the server:

npm run dev

Sign up a regular user

curl -s -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}' | jq

Log in as the regular user

curl -s -c cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}' | jq

View your profile

curl -s -b cookies.txt http://localhost:3000/me | jq

Try to list all users (should fail with 403)

curl -s -b cookies.txt http://localhost:3000/users | jq
# {"error":"Forbidden"}

Log in as admin

curl -s -c admin-cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "admin123"}' | jq

List all users as admin

curl -s -b admin-cookies.txt http://localhost:3000/users | jq

Delete Alice’s account as admin

Copy Alice’s user ID from the output above, then:

curl -s -b admin-cookies.txt -X DELETE \
  http://localhost:3000/users/ALICE_USER_ID_HERE | head -1
# (empty body, 204 No Content)

Log out

curl -s -b admin-cookies.txt -c admin-cookies.txt \
  -X POST http://localhost:3000/logout | jq

Verify logout worked

curl -s -b admin-cookies.txt http://localhost:3000/me | jq
# {"error":"Unauthorized"}

Every one of these requests exercises multiple concepts from the course at once.

What you built

Every piece of this API uses concepts from the course:

ConceptWhere it appears
Password hashing (bcrypt)Signup route, login route
Session managementLogin creates session, logout deletes it
CookiesSession ID stored in HttpOnly cookie
Authenticationauthenticate() reads cookie, looks up session
AuthorizationrequireAdmin() checks role, returns 403
Composed checksauthenticatedAdmin() combines both
Enumeration preventionLogin returns same error for missing user and wrong password
Token auth/token/login and /token/me routes
HooksonRequest for timing, onResponse for logging, onError for safety

All plain functions. No middleware magic. No decorators. No hidden registration. Every auth check is visible right in the handler that uses it. If you had to audit this code for a security review, you could read each route top to bottom and see exactly what happens.

Where to go from here

The course covered fundamentals. Here are some natural directions to explore next:

Database storage. Replace the in-memory Map with SQLite, PostgreSQL, or another real database. The auth functions stay exactly the same. Only the data access layer changes. That is the beauty of how we structured things.

Refresh tokens. For token-based auth, implement the refresh token flow: short-lived access tokens (like 15 minutes) with long-lived refresh tokens that you can revoke. This is the piece we skipped over in Section 4.

OAuth. Let users sign in with Google, GitHub, or Apple instead of (or in addition to) a password. Same auth pipeline on your end, different way of getting the initial identity.

Multi-factor authentication (MFA). Add a second factor after the password check: TOTP codes, SMS, or email-based one-time codes.

Rate limiting. Prevent brute-force login attempts by limiting how many login requests a single IP or email can make per minute.

Password reset. Let users reset their password via a time-limited email link. This is its own little mini-system involving tokens, email, and expiry handling.

Each of these builds on the foundation you have now. The patterns are always the same: write a function, return data or a Response, check the result at the handler level.

Challenges

If you want to test your understanding, try extending the capstone with these features. Each one uses patterns from the course without introducing new concepts.

Challenge 1: Password change route. Add POST /me/password that accepts { currentPassword, newPassword }. The user must be authenticated. Verify the current password with bcrypt.compare, hash the new password, and update the user record. Bonus: delete all of the user’s sessions after a password change so they are forced to log in again on every device.

Challenge 2: Session expiry. Modify getSession to check createdAt and reject sessions older than 24 hours (see the Common mistakes lesson for the exact pattern). Verify that a session older than 24 hours returns 401 on a protected route.

Challenge 3: Admin user creation. Add POST /users (admin only) that creates a new user with a specified role. The admin should be able to create either regular users or other admins. Use authenticatedAdmin for the auth check.

Thanks for sticking with the whole course. You now understand authentication from the wire all the way up to the handler code, and you have written every piece yourself. That is a surprisingly rare skill in 2026, even among experienced developers. Most people install a library and trust it. You built one, and you know why every piece is there.

Go build something.

In the capstone, why does the DELETE /users/:id route check if the target user ID matches the caller's ID?

← Common Mistakes Back to course →

© 2026 hectoday. All rights reserved.