RBAC in Express with TypeScript: per-organization permissions
Build role-based access control for a REST API in Express and TypeScript: a typed permission catalog, per-org roles and a requirePermission middleware.
To add RBAC to an Express API in TypeScript, define a closed catalog of
resource:action permissions, store a list of them on each role per
organization, load the caller's role into req with one middleware, and gate
every route with a requirePermission('resource:action') middleware. The step
people forget is a second check that the organization in the URL is the
organization the permissions were loaded for. This guide builds the whole chain
using the shape of the code in useDeploy, and the snippets run on Express
4.22, the version it uses. The mechanics follow Express's own middleware
guide.
1. A typed permission catalog
A permission is a string like roles:update. Make the set closed so a typo is a
compile error. This is the catalog useDeploy ships, in
packages/shared/src/permissions/index.ts:
export const RESOURCES = [
'users', 'roles', 'settings', 'reports', 'organizations',
'billing', 'invitations', 'webhooks', 'api-keys', 'queues',
] as const;
export const ACTIONS = ['create', 'read', 'update', 'delete', 'manage'] as const;
// Coarse scopes that don't fit the resource:action grid.
export const EXTRA_PERMISSIONS = ['api-keys:manage', 'audit:read'] as const;
export type Resource = (typeof RESOURCES)[number];
export type Action = (typeof ACTIONS)[number];
export type ExtraPermission = (typeof EXTRA_PERMISSIONS)[number];
export type Permission = `${Resource}:${Action}` | ExtraPermission | '*:*';
export const SUPER_ADMIN: Permission = '*:*';
export const hasPermission = (
granted: ReadonlyArray<Permission>,
required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);requirePermission('roles:upadte') does not compile. *:* is the owner's
wildcard.
2. Roles are rows, scoped to an organization
Hard-coding if (role === 'admin') stops working the first time a customer asks
for "an admin who can't touch billing". Store roles as data. In useDeploy a role
belongs to one organization and holds its permissions as a string array:
model Role {
id String @id @default(cuid())
organizationId String
name String
isSystem Boolean @default(false)
permissions String[] @default([])
@@unique([organizationId, name])
}
model OrganizationMember {
organizationId String
userId String
roleId String
@@unique([organizationId, userId])
}Every new organization gets three system roles that cannot be edited or deleted:
owner (*:*), admin (users, roles read, settings, reports, audit and
invitations) and member (users:read, reports:read). Custom roles are
whatever the organization builds from the catalog.
3. Load the caller's grants
Authorization needs three facts: who the user is, which organization the request is about, and what their role there allows. Put the middleware chain in this order:
import type { RequestHandler, Response } from 'express';
import type { Permission } from './permissions';
declare global {
namespace Express {
interface Request {
session?: { userId: string };
organization?: { id: string; permissions: ReadonlyArray<Permission> };
grants?: ReadonlyArray<Permission>;
}
}
}
const deny = (res: Response, status: number, message: string) =>
res.status(status).json({ error: message });
export const authMiddleware: RequestHandler = async (req, res, next) => {
const session = await getSession(req); // your auth library: BetterAuth in useDeploy
if (!session) return void deny(res, 401, 'Authentication required');
req.session = { userId: session.userId };
next();
};
export const organizationContext: RequestHandler = async (req, res, next) => {
const orgId = req.header('x-organization-id');
if (!orgId) return void deny(res, 403, 'Missing x-organization-id header');
const membership = await findMembership(orgId, req.session!.userId);
if (!membership) return void deny(res, 403, 'You are not a member of this organization');
const role = await findRoleForOrg(membership.roleId, orgId); // filtered by orgId
if (!role) return void deny(res, 403, 'Membership role missing');
req.organization = { id: orgId, permissions: role.permissions };
next();
};
export const hydratePermissions: RequestHandler = (req, _res, next) => {
if (req.organization) req.grants = req.organization.permissions;
next();
};useDeploy's real version is attachOrganizationContext in
modules/tenancy/interfaces/http/organization-context.middleware.ts. It adds a
suspension check that rejects members of a suspended or deleted organization on
every request, and it handles API-key sessions separately because a key carries
its own scoped permissions and must not inherit its creator's.
Two details worth copying:
- The header is a claim, not a fact. The client says "I am acting in org X"; the middleware proves it by looking up the membership. Never put permissions in the header or in a token the user can edit.
- Query roles with the organization id.
findRoleForOrg(roleId, orgId)takes both. A lookup by role id alone is the kind of query that leaks across tenants; see Multi-tenancy in Prisma without RLS.
useDeploy caches single-role lookups for 300 seconds and drops the cached entries for an organization on every role save or delete, so editing a role takes effect on the next request.
4. The gate
export const requirePermission =
(permission: Permission): RequestHandler =>
(req, res, next) => {
if (!req.session) return void deny(res, 401, 'Authentication required');
if (!hasPermission(req.grants ?? [], permission)) {
return void deny(res, 403, `Missing permission: ${permission}`);
}
next();
};Return 401 when there is no identity and 403 when there is one that is not
allowed. req.grants ?? [] means a route that forgot the loading middleware
denies everything instead of allowing everything.
5. The check everyone forgets
Permissions come from the header's organization. The route acts on the URL's.
Without a check that they match, a member of org A holding roles:update can call
PATCH /organizations/B/roles/... and the permission check passes, because it was
evaluated against A:
export const requireOrgMatchesPath: RequestHandler = (req, res, next) => {
if (!req.organization || req.organization.id !== req.params.organizationId) {
return void deny(res, 403, 'Organization context does not match URL');
}
next();
};In useDeploy this is requireOrgMatchesPath() in
infrastructure/http/require-org-matches-path.ts, mounted on the tenancy and API-key
routers; the webhooks router has an equivalent inline check. It exists because that cross-tenant hole is easy to
write and easy to miss in review.
6. Wire it
const router = Router();
router.use(authMiddleware, organizationContext, hydratePermissions);
router.use('/:organizationId', requireOrgMatchesPath);
router.get('/:organizationId/roles', requirePermission('roles:read'), listRoles);
router.patch('/:organizationId/roles/:roleId', requirePermission('roles:update'), updateRole);
app.use('/api/v1/organizations', router);We ran this chain against six requests, with two organizations and three users. The output:
anonymous 401 Authentication required
owner reads roles in its own org 200
member without roles:read 403 Missing permission: roles:read
not a member of the org in the header 403 You are not a member of this organization
org B admin, header B, URL says org A 403 Organization context does not match URL
org B owner updating org B 2007. Editing your own profile
Some routes should always allow "me" and gate only "someone else", like
PATCH /users/:id. useDeploy has requirePermissionOrSelf(permission, getTargetUserId). It skips the permission check when a cookie session targets its
own user id. API-key sessions never get that shortcut: a key carries its creator's
id for audit only, so it must hold the permission.
8. Don't let roles escalate
A gate on POST /members is not enough if an admin can assign the owner role to
themselves. The rules useDeploy applies in the use cases:
- A caller can only hand out a role whose permissions are a subset of their own
(
ensureRoleGrantable). A*:*caller can grant anything, so only owners create owners. - A caller cannot modify a member whose current role exceeds their grants, so an admin cannot demote an owner.
- The last owner cannot be demoted or removed. The count and the write run in one serializable transaction so two concurrent requests cannot both succeed.
Frequently asked questions
Should permissions live in the JWT or session? Not if roles can change. Reading them from the database (or a short cache) on each request means a role edit applies immediately and a removed member loses access on the next call.
RBAC or ABAC?
RBAC covers "what can this role do". Ownership rules, like "only edit your own
invoice", sit on top of it as a check in the use case, or as the OrSelf variant
for the common case.
Where do I put the check, middleware or use case?
Coarse gates (roles:update) in middleware, so every route declares what it
needs in one visible line. Rules that depend on the data (can this caller grant
this role) in the use case, where the data is loaded.
Does this work with an auth library other than Better Auth?
Yes. The only thing it needs from auth is a user id on req.session.
useDeploy ships this whole chain, the role editor endpoints and the permission catalog, plus the invariants above. See the RBAC feature, the RBAC docs, and how the tenancy model fits together in Better Auth organizations: multi-tenant setup and Reusing one multi-tenant base across every client.