hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authorization with @hectoday/http

Beyond Authentication

  • Authentication vs. Authorization
  • Project Setup

Role-Based Access Control (RBAC)

  • Roles and What They Mean
  • Checking Roles in Route Handlers
  • Role Hierarchy

Permission-Based Access Control

  • From Roles to Permissions
  • Checking Permissions
  • Custom Permissions

Organization Scoping

  • Multi-Tenancy
  • Switching Organizations
  • Inviting Members

API Keys and Scoping

  • API Keys
  • Scoped API Keys

Putting It All Together

  • Policy Functions
  • Audit Logging
  • Authorization Checklist
  • Capstone: Multi-Tenant Notes API

Checking Roles in Route Handlers

The authorization check pattern

Every protected route in this course follows the same pattern:

  1. Authenticate the user (who are you?)
  2. Resolve the organization (which org is this request about?)
  3. Check membership (are you a member of this org?)
  4. Check role (does your role allow this action?)

If any step fails, the request is rejected. Only if all four pass does the handler proceed.

The requireRole function

Create src/authorize.ts:

// src/authorize.ts
import { getMembership } from "./membership.js";
import type { AuthUser } from "./auth.js";

export function requireRole(user: AuthUser, orgId: string, requiredRole: string): true | Response {
  const membership = getMembership(user.id, orgId);

  if (!membership) {
    // Not a member — return 404, not 403, to avoid revealing the org exists
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  if (membership.role !== requiredRole) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  return true;
}

Same true | Response pattern as authenticate from the auth courses: the function returns true if authorized, or a Response if not. The caller checks with instanceof Response.

Notice the 404 vs 403 distinction (same as the IDOR lesson in the web security course): if the user is not a member at all, we return 404 to avoid confirming the organization exists. If they are a member but with the wrong role, we return 403 because they already know the org exists.

Adding notes routes

Create src/routes/notes.ts:

// src/routes/notes.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { authenticate } from "../auth.js";
import { requireRole } from "../authorize.js";

const CreateNoteBody = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

export const notesRoutes = group([
  // List notes in an organization — any member can view
  route.get("/orgs/:orgId/notes", {
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const role = requireRole(user, c.params.orgId, "viewer");
      if (role instanceof Response) return role;

      const notes = db
        .prepare(
          "SELECT id, org_id, created_by, title, body, created_at FROM notes WHERE org_id = ?",
        )
        .all(c.params.orgId);

      return Response.json(notes);
    },
  }),

  // Create a note — only editors and owners
  route.post("/orgs/:orgId/notes", {
    request: { body: CreateNoteBody },
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const role = requireRole(user, c.params.orgId, "editor");
      if (role instanceof Response) return role;

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

      const { title, body: noteBody } = c.input.body;
      const id = crypto.randomUUID();

      db.prepare(
        "INSERT INTO notes (id, org_id, created_by, title, body) VALUES (?, ?, ?, ?, ?)",
      ).run(id, c.params.orgId, user.id, title, noteBody);

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

  // Delete a note — only owners
  route.delete("/orgs/:orgId/notes/:noteId", {
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const role = requireRole(user, c.params.orgId, "owner");
      if (role instanceof Response) return role;

      const result = db
        .prepare("DELETE FROM notes WHERE id = ? AND org_id = ?")
        .run(c.params.noteId, c.params.orgId);

      if (result.changes === 0) {
        return Response.json({ error: "Not found" }, { status: 404 });
      }

      return Response.json({ message: "Deleted" });
    },
  }),
]);

Wire it into src/app.ts:

import { notesRoutes } from "./routes/notes.js";
// Add to routes array: ...notesRoutes,

The problem

There is a bug. Look at the view route:

const role = requireRole(user, c.params.orgId, "viewer");

This checks if the user has exactly the viewer role. But Alice is an owner, not a viewer. The check fails for Alice because "owner" !== "viewer".

Owners should be able to do everything viewers can do. Editors should be able to do everything viewers can do. The current requireRole checks for an exact match, but we need a hierarchy check.

The next lesson fixes this.

Try it

# Log in as Bob (editor at Acme)
curl -c cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password123"}'

# List Acme notes — should work (but fails because requireRole checks exact match)
curl -b cookies.txt http://localhost:3000/orgs/org-acme/notes
# Returns 403 because Bob is "editor", not "viewer"

This demonstrates the problem. We fix it in the next lesson.

Exercises

Exercise 1: Log in as Carol (viewer at Acme). Try listing notes. Does it work? (It should — Carol has exactly the “viewer” role.)

Exercise 2: Log in as Alice (owner at Acme). Try listing notes. Does it fail? (Yes — Alice has “owner”, not “viewer”. The exact match is wrong.)

Exercise 3: Log in as Bob. Try accessing Globex notes (/orgs/org-globex/notes). You should get 404 because Bob is not a member of Globex.

Why does the role check return 404 (not 403) when the user is not a member of the organization?

← Roles and What They Mean Role Hierarchy →

© 2026 hectoday. All rights reserved.