Protecting routes
We have sessions. After a successful login, the user’s browser holds a cookie with a session ID, and the server has a matching record tucked away in its session store. Now the question becomes: how do we actually use that? When a request arrives at /dashboard, how do we check the cookie, look up the session, and decide “yes, this is a logged-in user” or “no, kick them out”? That is what we are building this lesson. And in the process, you are going to see one of the nicest patterns in Hectoday HTTP for keeping auth code readable.
The authenticate function
The workflow is the same for every protected route: read the cookie, look up the session, decide if it is valid. Writing that out inline in every handler would be repetitive and easy to get wrong. So we extract it into a single function called authenticate. It takes a request and returns either a verified user or an error response.
Create src/auth.ts:
// src/auth.ts
import type { User } from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";
export function authenticate(request: Request): Omit<User, "passwordHash"> | 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,
};
} Focus on the return type for a moment: Omit<User, "passwordHash"> | Response. This function returns either user data (without the password hash) or a Response. That union type is the core pattern of Hectoday auth, and it is worth pausing on.
If you have never seen Omit before, it is a built-in TypeScript utility. Omit<User, "passwordHash"> means “the User type with the passwordHash field removed.” So the actual return type is { id: string; email: string; role: "user" | "admin" }. We use it to make absolutely sure the password hash never leaks back to handler code that just wants to know “who is the caller?”
There are two things that can go wrong inside authenticate: there is no session ID in the cookie (user never logged in, or their cookie was cleared, or the cookie expired) or the session ID does not match any record on the server (session was deleted, or the ID was made up, or the server was restarted). Both produce a 401 response.
Using it in a handler
Here is where the pattern shines. Create src/routes/users.ts:
// src/routes/users.ts
import { route, group } from "@hectoday/http";
import { authenticate } from "../auth.js";
export const userRoutes = group([
route.get("/me", {
resolve: (c) => {
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
// caller is { id, email, role }, TypeScript narrowed it
return Response.json({ user: caller });
},
}),
]); Two lines. authenticate(c.request) gives us back either a user or a Response. Then if (caller instanceof Response) return caller short-circuits the handler if auth failed.
Take a second to appreciate what just happened. In a lot of other frameworks, auth lives in “middleware” that runs invisibly before the handler. You write app.get("/me", ...) and somewhere, far away, some auth middleware was registered, and a magical req.user shows up. The current handler gives you no clue that any of this happened.
In Hectoday, the auth check is right there, in the handler. You see the call. You see the check. You see the early return. You can read the handler top-to-bottom and know exactly what happens.
What happens after the instanceof check?
Notice what happens with the TypeScript type of caller after that if check. Before the check, it is User | Response. After the check, if the handler did not return, TypeScript narrows the type. It knows that if we got to this line, caller must be the user object, because if it had been a Response we would have already returned. So below the check, caller.id, caller.email, and caller.role all work with full type safety. No casting, no any, no manual type guards. The language just figures it out.
Wire the new routes into the app
Update src/app.ts to include the new user routes:
// src/app.ts
import { setup, route } from "@hectoday/http";
import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
export const app = setup({
routes: [
route.get("/health", {
resolve: () => Response.json({ status: "ok" }),
}),
...authRoutes,
...userRoutes,
],
}); Try it out
First, sign up and log in to get a session cookie saved to a file:
curl -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' The -c cookies.txt flag tells curl to save cookies it receives to a file called cookies.txt. Now use those cookies:
curl -b cookies.txt http://localhost:3000/me The -b cookies.txt flag tells curl to send those cookies back. You should see:
{
"user": {
"id": "a1b2c3d4-...",
"email": "[email protected]",
"role": "user"
}
} It worked. The cookie carried the session ID, the server looked it up, and we got the user back.
Now try without the cookie:
curl http://localhost:3000/me { "error": "Unauthorized" } The route is protected. Without a valid session cookie, the request gets 401’d. This is exactly what we built authenticate to do.
Why this pattern actually matters
It is easy to breeze past “call a function, check the result” as unimpressive. Let me put it side by side with the alternative so you can see the difference.
The middleware way (what you have probably seen in other frameworks)
// Somewhere, in some file, maybe registered globally
app.use(authMiddleware);
// In the actual handler, no visible auth check
app.get("/me", (req, res) => {
res.json({ user: req.user }); // where did req.user come from?
}); Reading this handler, you cannot tell whether auth ran. You cannot tell what it checked. You cannot tell what happens when it fails. To answer any of those questions, you have to find the middleware file, check its registration order, and trace the chain.
The Hectoday way
route.get("/me", {
resolve: (c) => {
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
return Response.json({ user: caller });
},
}); The auth check is right there in front of you. Two lines. You see the call, you see the check, you see the early return. You do not need to hunt through other files.
The cost is those two extra lines per protected handler. That is the tradeoff: a tiny amount of repetition in exchange for auth logic you can actually read and audit.
A protected route with input validation
Real handlers usually combine auth and validation. Here is what that looks like:
route.post("/posts", {
request: {
body: z.object({
title: z.string().min(1),
content: z.string().min(1),
}),
},
resolve: async (c) => {
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const post = {
id: crypto.randomUUID(),
title: c.input.body.title,
content: c.input.body.content,
authorId: caller.id,
};
return Response.json(post, { status: 201 });
},
}); Auth check first, then validation, then business logic. Each step is a potential early return. Read it top to bottom and every exit point is visible.
Order matters here. We do auth first because there is no point validating input for a request we are about to reject. We validate input before business logic because the business logic assumes valid data. Each guard filters out a different kind of bad request before the real work starts.
In the next lesson, we add one more piece: a logout route. We have built “get in” and “stay in.” Now we need “get out.”
Exercises
Exercise 1: Create a GET /whoami route that returns different information depending on whether the user is authenticated. If they have a valid session, return their email and role. If not, return { "authenticated": false }. Do not use authenticate() for this, write the cookie/session lookup inline so you can return different responses instead of a 401.
Exercise 2: Add a POST /posts route (like the example above) to your app. Log in, create a post, and verify the response includes the authorId matching your user ID. Then try creating a post without logging in and verify you get a 401.
Exercise 3: What happens if you manually send a Cookie: session=fake-session-id header with a session ID that does not exist in the store? Try it with curl. The authenticate function should return 401 because getSession("fake-session-id") returns undefined. Think about why this is important: it means an attacker cannot just guess session IDs and bluff their way in.
What does `if (caller instanceof Response) return caller;` do?
Why does the authenticate function return a union type (User | Response) instead of throwing an error?