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.

MC
Martin Coll

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                   200

7. 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.