Checking Roles in Route Handlers
The authorization check pattern
Every protected route in this course follows the same pattern:
- Authenticate the user (who are you?)
- Resolve the organization (which org is this request about?)
- Check membership (are you a member of this org?)
- 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?