Building session management
In the last two lessons we talked about cookies (how the browser carries data back to the server) and sessions (a random ID pointing to data the server holds on to). Now we actually build it. This lesson is where our login route starts doing what everyone expects “login” to do: the user hits submit, and for the rest of the day, the server remembers who they are. We are going to write a tiny session store, a couple of cookie helpers, and wire it all into the login endpoint we wrote in Section 2.
The session store
First, the server side. We need a place to keep session data. Create 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);
} Three functions. Let’s look at each.
createSession takes session data (everything except createdAt, which we set automatically), generates a random UUID as the session ID, stores the session in our Map, and returns the ID. The caller uses that ID to put into a cookie.
getSession takes a session ID and returns the session data, or undefined if no such session exists. That undefined is important. Our authentication logic later will use it to detect invalid or expired cookies.
deleteSession removes a session. This is what logout will call.
About crypto.randomUUID(): it is a Web Standard API available in Node. It generates a version 4 UUID, which is 122 bits of randomness. That is more than enough to prevent an attacker from guessing valid session IDs. With that much entropy, you would need to try an astronomical number of guesses before stumbling onto a real one.
The Omit<Session, "createdAt"> type in the function signature is a little TypeScript utility. It means “the Session type but without the createdAt field.” We use it because the caller provides the user info, but the timestamp is ours to set.
[!WARNING] Like the user store in
db.ts, this session store is an in-memoryMap. It gets cleared every time the server restarts. Sincetsx watchrestarts the server on every file save, you will lose all sessions whenever you edit code. Your saved curl cookies will stop working, and you will have to log in again. This is normal during development. In production, you would use Redis or a database for the session store.
Cookie helpers
We also need a few small utilities for dealing with cookies. Parsing the cookie header, building the Set-Cookie value, and so on. Let’s put those in one place. 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`;
} Four little functions. Let’s go through them.
parseCookies takes the raw Cookie header string and turns it into a plain object. It handles the “multiple cookies separated by semicolons” shape we saw in the previous lesson. If the header is missing, it returns an empty object. Notice the rest.join("=") bit: cookie values can sometimes contain = (like Base64-encoded values), so we split on the first = and rejoin the rest to preserve them.
getSessionId is a convenience function. It reads the cookie header off the request, parses it, and pulls out just the session cookie. We will call this from every route that needs to check auth.
sessionCookie builds the Set-Cookie header value for a new session. Notice all the attributes tacked onto the end. We included them right from the start: HttpOnly, SameSite=Lax, Path=/, Max-Age=86400. We will explain each of these in detail in the Cookie security lesson. For now, the two you want to know about are HttpOnly (JavaScript on the page cannot read the cookie) and SameSite=Lax (the cookie is not sent with cross-site form submissions). Both protect our session from common attacks.
clearSessionCookie is the opposite. It produces a Set-Cookie string that tells the browser to delete the cookie, by setting Max-Age=0. That is how we will log users out.
Updating the login route
Now we finally close the loop. Open src/routes/auth.ts and update the login route to create a session and set the cookie:
// 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 } from "../sessions.js";
import { sessionCookie } 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 });
}
// Create a session and set the cookie
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),
},
},
);
},
}),
]); The only real change is at the end of the login handler. Everything up to the bcrypt compare is exactly the same as before. After we confirm the password is correct, we do three new things:
- Call
createSession()with the user’s info. This stores a fresh session record on the server and gives us back a random session ID. - Call
sessionCookie(sessionId)to build theSet-Cookieheader string. - Return the response with that
Set-Cookieheader.
That third step uses Response.json()’s second argument, which lets you pass status and headers. It is the same object shape you would pass to new Response(body, { ... }). Here we use it to tack the set-cookie header onto the JSON response.
Try it
Sign up and log in:
curl -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
curl -v -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' The -v flag on the login request makes curl print the full response, including the headers. Scroll through and look for:
< set-cookie: session=f47ac10b-58cc-4372-a567-0e02b2c3d479; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400 That is the session cookie. In a real browser, the browser would silently store that and send it back on every future request. In curl, you have to opt in with -b and -c flags (which we will use more heavily in the next few lessons).
The file layout
src/
app.ts
server.ts
db.ts
schemas.ts
sessions.ts # session store (create, get, delete)
cookies.ts # cookie parsing and formatting
routes/
auth.ts # signup and login (now creates sessions) This is starting to feel like a real app.
In the next lesson, we actually use these sessions. We will write an authenticate() function that checks the session cookie and decides whether a request is legitimate, then wire that into our first protected route.
Exercises
Exercise 1: Log in with curl using -v to see the response headers. Find the set-cookie header. Copy the session ID value and use it in a manual Cookie header on a different request:
curl http://localhost:3000/health -H "Cookie: session=YOUR_SESSION_ID_HERE" This is exactly what the browser does automatically, but you are doing it by hand so you can see the parts.
Exercise 2: Log in twice with the same user. You will get two different session IDs (two different cookies). Both are valid. Verify that both work by making requests with each cookie. This demonstrates that a user can have multiple active sessions at once. This is how “logged in on my phone and my laptop” works in real apps.
Exercise 3: Add a console.log to the createSession function that prints the session ID and user email when a session is created. Log in a few times and watch the server output. Then add a log to getSession that prints when a session is looked up. This shows you the full session lifecycle and is a good way to convince yourself that everything you wrote is real.
What does createSession return?