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

Building a signup route

We have a project. We know how to hash a password. Time to put those pieces together into an actual HTTP endpoint. This lesson is where we finally start writing the app, for real. We are going to build a POST /signup route that accepts an email and password, validates them, hashes the password, and stores the user. Nothing exotic, but it is the first real authentication endpoint you will ship, and it introduces the full pattern we will reuse for every route in this course.

What signup actually needs to do

Before we write a single line of code, let’s be precise about what the endpoint is responsible for. A signup endpoint has four jobs:

  1. Receive an email and password from the client.
  2. Validate them. Is the email actually an email? Is the password long enough to be worth using?
  3. Hash the password so we never store it in plain text.
  4. Store the user somewhere we can look them up later.

We will build each step one at a time. But we need a place to store users first.

Our fake database

In a real app, you would use a real database, PostgreSQL or SQLite or whatever. For this course, we are using an in-memory JavaScript Map. This is a deliberate choice: it keeps the focus on authentication instead of on database setup. Everything we build works exactly the same way with a real database. You just replace the Map operations with database queries later.

Create src/db.ts:

// src/db.ts
export interface User {
  id: string;
  email: string;
  passwordHash: string;
  role: "user" | "admin";
}

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

A few things to notice:

  • We store passwordHash, not password. The plaintext password should never, ever exist in our data layer. It arrives in a request, we hash it immediately, and we forget it.
  • The role field will matter later, when we add authorization. Every new user gets "user" and admins get "admin". We will use this in Section 5.
  • The Map is keyed by email. That makes looking someone up by email (which is what login will need to do) a fast, single operation.

[!WARNING] An in-memory Map means all data is lost whenever the server restarts. That is fine for learning, but it is definitely not fine for production. Use a real database in real apps.

The Zod schema

Now we need to validate the data coming in. For that, we use Zod.

Zod is a validation library. The idea is simple: you describe the shape of the data you expect, and Zod checks incoming data against that shape. If it matches, you get a nicely typed object. If it does not, you get a list of what went wrong.

Hectoday HTTP integrates directly with Zod. You attach a schema to a route, the framework runs the validation for you before your handler code ever runs, and the result lands on c.input. So you never write any if (typeof body.email !== 'string') nonsense yourself.

Create 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"),
});

Let’s walk through this. z.object({...}) says “the body should be an object with these fields.” z.email() says “this field should be a valid-looking email address.” z.string().min(8) says “this field should be a string with at least 8 characters.” The second argument to .min(8) is a custom error message that gets returned when the check fails.

[!NOTE] We import from "zod/v4", which is the Zod v4 API. If you search the web for Zod docs, you will likely run into older v3 examples that say z.string().email() instead of z.email(). Both actually work in v4, but z.email() is the newer, shorter form. Use it.

Why 8 characters for the minimum? It is a common baseline. Shorter passwords are easier to brute-force. In production you might require more, or check the password against a list of commonly breached passwords, or both. For this course, 8 is fine.

The signup route

Now we write the actual handler. We will put all of our auth-related routes in one file.

Create 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 } from "../schemas.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;

      // Check if a user with this email already exists
      if (users.has(email)) {
        return Response.json({ error: "Email already registered" }, { status: 409 });
      }

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

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

      users.set(email, user);

      // Return the user without the password hash
      return Response.json({ id: user.id, email: user.email, role: user.role }, { status: 201 });
    },
  }),
]);

This is a lot at once, so let’s go through it piece by piece.

The outer structure

group([...]) wraps an array of routes. It is just a way to bundle related routes together so we can spread them into the app in one go. Think of it as a folder for routes.

route.post("/signup", { ... }) declares that we handle POST requests to /signup. Inside, we pass two things: request describes what the incoming request should look like, and resolve is the function that actually runs when the request arrives.

request: { body: SignupBody } is where we plug in our Zod schema. Hectoday HTTP will parse the incoming request body as JSON and run the schema check before our resolve function is ever called. The result lands on c.input.

The validation check

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

c.input.ok is a boolean. If the body matched the schema, it is true, and we can read the validated data from c.input.body. If it did not match (bad email, missing password, wrong types) it is false, and c.input.issues contains the details of what went wrong.

We check !c.input.ok right at the top and return a 400 (Bad Request) with the issues. Everything after this line can assume the input is good.

The duplicate check

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

Before we do anything else, we check whether a user with this email already exists. If so, we return a 409 (Conflict). This prevents two accounts from sharing an email, which would make login ambiguous: which account do you log in?

Notice we do this check before hashing the password. Hashing is deliberately slow (remember, 2^10 = 1024 rounds), so there is no point burning those CPU cycles for a signup we already know will fail.

Hashing the password

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

Straight out of the last lesson. This is the one and only place in the entire codebase where we even touch the plaintext password. It arrives in the request, it gets hashed right here, and the plaintext version never gets stored anywhere.

Creating the user

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

users.set(email, user);

We give the user a random UUID as their ID (this is a built-in Web API, no library needed). We store the email, the hashed password, and a default role of "user". The as const bit is a little TypeScript hint that tells the compiler “treat this string as the literal type 'user', not the generic type string.” Without it, TypeScript would widen the type and our User type would get unhappy.

Then we store the user in the Map, keyed by email.

The response

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

We return a safe subset of the user data. Look carefully: passwordHash is not in there. The hash should never, ever leave the server. Even though it is one-way, exposing it gives attackers material to brute-force offline, and the client has zero reason to ever see it.

The status 201 means “Created.” It is the conventional way to say “this request successfully created a new resource.” 200 would also work but 201 is more precise.

Wire it into the app

We have our routes, but the app does not know about them yet. Update src/app.ts:

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

export const app = setup({
  routes: [
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
    ...authRoutes,
  ],
});

The ...authRoutes spread operator takes all the routes out of the group and splats them into the app’s routes array. That is it.

Let’s try it

With the server running (npm run dev), hit the signup endpoint from another terminal:

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

You should get back a 201 with the new user:

{
  "id": "a1b2c3d4-...",
  "email": "[email protected]",
  "role": "user"
}

Now, what do you think happens if we send invalid data?

curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "not-an-email", "password": "password123"}'

You get a 400 with the Zod validation error, telling you “not-an-email” is not a valid email. The schema did its job.

And if you try to sign up the same email twice?

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

409 Conflict. Exactly what we wanted.

The file layout so far

src/
  app.ts           # setup() with routes
  server.ts        # runs the app
  db.ts            # User type, in-memory store
  schemas.ts       # Zod schemas
  routes/
    auth.ts        # signup route (login coming next)

Exercises

Exercise 1: Try signing up with a password shorter than 8 characters. What does the validation error look like? What status code do you get?

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

You should get a 400 with an issue mentioning the minimum length.

Exercise 2: Add a name field to the User interface and the SignupBody schema. Make it a required string with at least 1 character. Update the signup route to store and return it.

Exercise 3: Try sending a request with no body at all (curl -X POST http://localhost:3000/signup). What happens? Try sending invalid JSON (curl -X POST http://localhost:3000/signup -H "Content-Type: application/json" -d 'not json'). What does c.input.issues contain in each case? This is how you develop a feel for how the framework reports different kinds of bad input.

Next up: login. We have a way to create accounts, but without login, users cannot actually do anything with them. Plus, login is where the statelessness problem from Section 1 comes back to haunt us.

Why do we check if the email already exists before hashing the password?

Why does the response not include the passwordHash field?

← Hashing with bcrypt Building a Login Route →

© 2026 hectoday. All rights reserved.