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 withoutawaitat 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 becausebcrypt.hashfinishes 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:
| Concept | Where it appears |
|---|---|
| Password hashing (bcrypt) | Signup route, login route |
| Session management | Login creates session, logout deletes it |
| Cookies | Session ID stored in HttpOnly cookie |
| Authentication | authenticate() reads cookie, looks up session |
| Authorization | requireAdmin() checks role, returns 403 |
| Composed checks | authenticatedAdmin() combines both |
| Enumeration prevention | Login returns same error for missing user and wrong password |
| Token auth | /token/login and /token/me routes |
| Hooks | onRequest 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?