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

Authorization

We have spent four sections on authentication. Login works. Sessions work. Tokens work. We can verify identity five different ways. But here is the thing we have been quietly sidestepping the whole time: just because the server knows who you are does not mean you get to do whatever you want. A regular user should not be able to delete other users’ accounts. A viewer should not be able to edit somebody else’s post. That is the gap we close in this lesson. Authentication answered “who are you?” Authorization answers “are you allowed to do this?”

Roles

The simplest authorization model is role-based access control, or RBAC. Each user has a role, and each role grants some set of permissions. You will see this pattern in almost every web app you ever build.

Our User type has had a role field from the very beginning:

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

With only two roles, the rules are straightforward:

  • "user" can read and modify their own data.
  • "admin" can do everything a user can, plus manage other users.

That is usually a fine place to start. You can add more roles later ("moderator", "editor", "billing", whatever) without fundamentally changing the pattern.

The requireAdmin pattern

Authorization in Hectoday HTTP uses the same pattern as authentication: write a function that returns true or a Response. The handler checks the result and either continues or returns the error immediately.

Add this to src/auth.ts:

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

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

That is it. If the user is not an admin, we return a 403 (Forbidden) response. Otherwise we return true.

One detail worth stopping on: we use 403 here, not 401. Remember from earlier in the course:

  • 401 Unauthorized: “I don’t know who you are.” Use this when authentication fails.
  • 403 Forbidden: “I know who you are, but you are not allowed to do this.” Use this when authorization fails.

In this function, the user has already been authenticated (that is why we can read user.role). We know exactly who they are. We are just denying the specific action. So 403 is the right code.

Using it in a handler

Here is what a handler with auth + authz + validation looks like:

route.delete("/users/:id", {
  request: {
    params: z.object({ id: z.string().uuid() }),
  },
  resolve: async (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

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

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

    // Only admins reach this point
    // Delete the user...
    return new Response(null, { status: 204 });
  },
});

Three checks, each two lines:

  1. Is the user authenticated? If not, return 401.
  2. Is the user an admin? If not, return 403.
  3. Is the input valid? If not, return 400.

Read top to bottom. Every decision boundary is visible. Every return shows you exactly where the request might end. This is the Hectoday style: nothing is hidden, nothing is invisible.

Composing checks

When you find yourself writing the same combination of checks over and over (“authenticate, then check admin”), just combine them into one function:

export function authenticatedAdmin(request: Request): Omit<User, "passwordHash"> | Response {
  const user = authenticate(request);
  if (user instanceof Response) return user;

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

  return user;
}

Now any handler that needs admin auth becomes two lines:

resolve: async (c) => {
  const caller = authenticatedAdmin(c.request);
  if (caller instanceof Response) return caller;

  // caller is an admin
  // ...
};

This is the kind of lightweight abstraction you build as you go. You notice a repeated pattern, extract it into a function, and use it everywhere. No framework magic required. They are just regular JavaScript functions.

Ownership checks

Not all authorization is about roles. Sometimes you need to check whether the user owns a specific resource. Like, “can I edit this post? Only if I wrote it.” Here is what that looks like:

export function requireOwner(user: AuthenticatedUser, resourceOwnerId: string): true | Response {
  if (user.id !== resourceOwnerId) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
  return true;
}

Same pattern. If the user’s ID does not match the resource owner’s ID, return a 403. Use it in a handler:

route.put("/posts/:id", {
  request: {
    params: z.object({ id: z.string().uuid() }),
    body: z.object({ title: z.string(), content: z.string() }),
  },
  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 = await db.posts.get(c.input.params.id);
    if (!post) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }

    const owner = requireOwner(caller, post.authorId);
    if (owner instanceof Response) return owner;

    // Only the post's author reaches here
    const updated = await db.posts.update(post.id, c.input.body);
    return Response.json(updated);
  },
});

Notice the order of checks: auth, input, lookup, ownership. Each one is its own little guard. If any one fails, we bail out with an appropriate status code. The actual update happens at the bottom, only for requests that passed every gate.

Owner or admin

A common pattern is “only the owner can edit this, unless the caller is an admin.” Admins often need to be able to override ownership restrictions. You just compose:

export function requireOwnerOrAdmin(
  user: AuthenticatedUser,
  resourceOwnerId: string,
): true | Response {
  if (user.role === "admin") return true;
  if (user.id === resourceOwnerId) return true;

  return Response.json({ error: "Forbidden" }, { status: 403 });
}

Two early “yes” paths, one fallthrough “no.” Easy to read. Easy to change later if the rule shifts.

The point: these are just functions

Take a step back and look at everything we did in this lesson. Every authorization check is a plain function. No decorators. No middleware registration. No framework-provided abstraction. You wrote the logic, returned either true or a Response, and checked the result with instanceof.

This means you can:

  • Put them in one file, or spread them across modules, however makes sense for your project.
  • Test them independently by calling them with mock user objects. No HTTP required.
  • Compose them however your app needs. New roles? New check functions. New ownership rules? New function.

The cost is a few lines per handler. The benefit is that every permission check is visible right where it happens. You will thank yourself later when you are doing a security audit and every auth decision can be read directly in the handler it affects.

Exercises

Exercise 1: Write a requireRole function that accepts any role string instead of being hardcoded to "admin". Use it to create a check for a "moderator" role. The function signature should be requireRole(user: AuthenticatedUser, role: string): true | Response.

Exercise 2: Add a PUT /users/:id/role route that lets an admin change another user’s role. It should accept { role: "user" | "admin" } in the body. Use authenticatedAdmin for the auth check. Test it by promoting a regular user to admin, then verifying they can access admin-only routes.

Exercise 3: Write a unit test for requireAdmin without using the app or any HTTP routes. Create a mock user object with role: "admin" and verify the function returns true. Then create one with role: "user" and verify it returns a Response with status 403. This drives home that these are plain functions, testable in isolation. You do not need any HTTP setup to test them.

In the next lesson, we zoom out and look at common mistakes in authentication code. Things that look right, pass review, pass tests, and still leave the door wide open. If you are going to ship auth code to production, you want to know what the landmines look like.

What is the difference between a 401 and a 403 response?

Why does requireAdmin return `true | Response` instead of `boolean`?

← Sessions vs. Tokens Common Mistakes →

© 2026 hectoday. All rights reserved.