Advanced (Updated: 6/1/2026)

Role-Based Access Control with Claude Code: RBAC, Tenants, Tests, and Audit Logs

Build safer RBAC with Claude Code: auth vs authorization, permissions, tenant boundaries, Express middleware, tests, and audit logs.

Role-Based Access Control with Claude Code: RBAC, Tenants, Tests, and Audit Logs

RBAC is not secure just because an app has roles named admin, editor, and viewer. Most authorization bugs start in smaller places: treating a logged-in user as trusted, forgetting the tenant filter in one query, checking the button in the UI but not the API, or allowing an editor to update any object with a guessed ID.

This guide turns RBAC into a reviewable Claude Code task. We will separate authentication and authorization, model role / permission / resource / action, enforce deny by default, protect tenant boundaries, add object-level authorization, build Express middleware, design a database schema, write denial-heavy tests, record audit logs, and define when RBAC should move toward ABAC. For the login layer, pair this with the Claude Code JWT authentication guide. For broader operational safeguards, read the Claude Code security best practices.

The security baseline here follows trusted references: OWASP Authorization Cheat Sheet, OWASP Access Control, Casbin RBAC, and Auth0 Core RBAC. OWASP is especially clear on least privilege, deny by default, validating permissions on every request, logging, and authorization tests.

Authentication Is Not Authorization

Authentication answers “who is this actor?” Authorization answers “may this actor perform this action on this resource?” A password, SSO session, JWT, or MFA challenge can prove identity, but it does not prove that the user may delete an invoice, invite an administrator, or read another customer’s data.

For RBAC, keep four words explicit:

ElementExampleReview question
roleviewer, editor, billing_admin, ownerIs this based on job responsibility, not status?
permissionarticle:update, invoice:readIs the string defined in one place?
resourcearticle, invoice, userWhat object is being protected?
actionread, create, update, deleteIs the action more precise than the HTTP method?

The common mistake is starting with a powerful admin role and adding exceptions later. That makes reviews vague because nobody can tell whether an exception is a product requirement or a temporary workaround. A better shape is to define permissions first and treat roles as named bundles of permissions.

flowchart TD
  A["Authenticated actor"] --> B["Tenant boundary"]
  B --> C["Role to permission map"]
  C --> D["Resource and action check"]
  D --> E["Object-level rule"]
  E --> F["Allow"]
  B --> X["Deny and audit"]
  C --> X
  D --> X
  E --> X

Start With Deny by Default

Deny by default means every operation is rejected unless the code explicitly allows it. For RBAC, that rule should be the center of the implementation, not an afterthought. When you ask Claude Code to work on authorization, state the denial rules first: unknown permission, empty role list, tenant mismatch, missing resource, and missing object ownership should all fail closed.

The following Express example is intentionally small enough to copy into a new project. The demo authentication reads headers so the behavior is visible in tests. In production, replace that layer with verified JWT or session data.

{
  "name": "rbac-express-demo",
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "express": "^5.1.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.3",
    "@types/node": "^22.15.0",
    "@types/supertest": "^6.0.3",
    "supertest": "^7.1.1",
    "tsx": "^4.19.4",
    "typescript": "^5.8.3",
    "vitest": "^3.1.4"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

Implement the RBAC Core in TypeScript

Do not scatter role checks across controllers. Put the decision in one authorize function and let routes pass permission names. That keeps the review surface small and gives Claude Code a clear place to update when a new resource appears.

// src/rbac.ts
export const permissions = [
  "project:read",
  "article:read",
  "article:create",
  "article:update",
  "article:delete",
  "invoice:read",
  "invoice:refund",
  "user:manage"
] as const;

export type Permission = (typeof permissions)[number];
export type Role = "viewer" | "editor" | "billing_admin" | "owner";

export type Actor = {
  id: string;
  tenantId: string;
  roles: Role[];
};

export type ResourceRecord = {
  id: string;
  tenantId: string;
  ownerId?: string;
};

export type AuthorizationDecision = {
  allow: boolean;
  reason: string;
};

export const rolePermissions = {
  viewer: ["project:read", "article:read", "invoice:read"],
  editor: ["project:read", "article:read", "article:create", "article:update"],
  billing_admin: ["project:read", "invoice:read", "invoice:refund"],
  owner: [...permissions]
} as const satisfies Record<Role, readonly Permission[]>;

const knownPermissions = new Set<Permission>(permissions);

export function authorize(
  actor: Actor,
  permission: Permission,
  record?: ResourceRecord
): AuthorizationDecision {
  if (!knownPermissions.has(permission)) {
    return { allow: false, reason: "unknown_permission" };
  }

  if (actor.roles.length === 0) {
    return { allow: false, reason: "no_role" };
  }

  if (record && record.tenantId !== actor.tenantId) {
    return { allow: false, reason: "tenant_mismatch" };
  }

  const roleAllows = actor.roles.some((role) =>
    rolePermissions[role].includes(permission)
  );

  if (!roleAllows) {
    return { allow: false, reason: "role_missing_permission" };
  }

  if (
    permission === "article:update" &&
    !actor.roles.includes("owner") &&
    record?.ownerId !== actor.id
  ) {
    return { allow: false, reason: "not_resource_owner" };
  }

  return { allow: true, reason: "allowed" };
}

export function auditAuthorization(input: {
  actor?: Actor;
  permission: Permission;
  resourceId?: string;
  decision: AuthorizationDecision;
}) {
  console.info(
    JSON.stringify({
      type: "authorization",
      actorId: input.actor?.id ?? "anonymous",
      tenantId: input.actor?.tenantId ?? "unknown",
      permission: input.permission,
      resourceId: input.resourceId ?? null,
      allow: input.decision.allow,
      reason: input.decision.reason,
      at: new Date().toISOString()
    })
  );
}

The important detail is that routes ask for article:update, not “is this user an editor?” The role is only one input. The tenant boundary and object owner check still run after the role-to-permission mapping.

Enforce It With Express Middleware

Authorization should run before the handler mutates data. OWASP’s guidance is not “check permissions on important screens”; it is to validate permissions on every protected request. Middleware makes that easy to review.

// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import {
  type Actor,
  type Permission,
  type ResourceRecord,
  type Role,
  auditAuthorization,
  authorize,
  rolePermissions
} from "./rbac.js";

declare global {
  namespace Express {
    interface Request {
      actor?: Actor;
    }
  }
}

type Article = ResourceRecord & {
  title: string;
  body: string;
};

const articles: Article[] = [
  { id: "a1", tenantId: "tenant-a", ownerId: "user-1", title: "Roadmap", body: "Draft" },
  { id: "a2", tenantId: "tenant-a", ownerId: "user-2", title: "Release", body: "Ready" },
  { id: "b1", tenantId: "tenant-b", ownerId: "user-9", title: "Private", body: "Secret" }
];

function parseRoles(value: string | undefined): Role[] {
  return (value ?? "")
    .split(",")
    .map((role) => role.trim())
    .filter((role): role is Role => role in rolePermissions);
}

function authenticateForDemo(req: Request, _res: Response, next: NextFunction) {
  const userId = req.header("x-user-id");
  const tenantId = req.header("x-tenant-id");

  if (userId && tenantId) {
    req.actor = {
      id: userId,
      tenantId,
      roles: parseRoles(req.header("x-roles"))
    };
  }

  next();
}

function findArticle(req: Request): Article | undefined {
  return articles.find((article) => article.id === req.params.articleId);
}

function requirePermission(
  permission: Permission,
  loadResource?: (req: Request) => ResourceRecord | undefined
) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.actor) {
      return res.status(401).json({ error: "unauthenticated" });
    }

    const record = loadResource?.(req);

    if (loadResource && !record) {
      return res.status(404).json({ error: "not_found" });
    }

    const decision = authorize(req.actor, permission, record);

    auditAuthorization({
      actor: req.actor,
      permission,
      resourceId: record?.id,
      decision
    });

    if (!decision.allow) {
      return res.status(403).json({ error: "forbidden", reason: decision.reason });
    }

    return next();
  };
}

export const app = express();

app.use(express.json());
app.use(authenticateForDemo);

app.get("/health", (_req, res) => {
  res.json({ ok: true });
});

app.get(
  "/articles/:articleId",
  requirePermission("article:read", findArticle),
  (req, res) => {
    res.json(findArticle(req));
  }
);

app.patch(
  "/articles/:articleId",
  requirePermission("article:update", findArticle),
  (req, res) => {
    const article = findArticle(req);
    if (!article) return res.status(404).json({ error: "not_found" });

    article.title = String(req.body.title ?? article.title);
    article.body = String(req.body.body ?? article.body);

    return res.json(article);
  }
);

app.delete(
  "/articles/:articleId",
  requirePermission("article:delete", findArticle),
  (req, res) => {
    const index = articles.findIndex((article) => article.id === req.params.articleId);
    if (index >= 0) articles.splice(index, 1);
    return res.status(204).send();
  }
);

app.get("/admin/users", requirePermission("user:manage"), (_req, res) => {
  res.json([{ id: "user-1" }, { id: "user-2" }]);
});

if (process.env.NODE_ENV !== "test") {
  app.listen(3000, () => {
    console.log("RBAC demo listening on http://localhost:3000");
  });
}

In production, the authentication middleware should be replaced by a verified identity source. Even if an identity provider sends role or permission claims, the API should still re-check tenant boundaries and object-level rules on the server.

Design the Database Around Tenant Boundaries

Authorization bugs often happen before middleware runs: a query fetches the wrong row, then the handler assumes it is safe. Make tenant boundaries visible in the schema and add audit logs as first-class data.

CREATE TABLE tenants (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL REFERENCES tenants(id),
  email TEXT NOT NULL,
  UNIQUE (tenant_id, email)
);

CREATE TABLE roles (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  UNIQUE (tenant_id, name)
);

CREATE TABLE permissions (
  id TEXT PRIMARY KEY,
  resource TEXT NOT NULL,
  action TEXT NOT NULL,
  UNIQUE (resource, action)
);

CREATE TABLE user_roles (
  user_id TEXT NOT NULL REFERENCES users(id),
  role_id TEXT NOT NULL REFERENCES roles(id),
  tenant_id TEXT NOT NULL REFERENCES tenants(id),
  PRIMARY KEY (user_id, role_id)
);

CREATE TABLE role_permissions (
  role_id TEXT NOT NULL REFERENCES roles(id),
  permission_id TEXT NOT NULL REFERENCES permissions(id),
  PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE articles (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL REFERENCES tenants(id),
  owner_id TEXT NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  body TEXT NOT NULL
);

CREATE TABLE authorization_audit_logs (
  id TEXT PRIMARY KEY,
  tenant_id TEXT,
  actor_id TEXT,
  permission TEXT NOT NULL,
  resource_id TEXT,
  allowed BOOLEAN NOT NULL,
  reason TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_articles_tenant_owner ON articles(tenant_id, owner_id);
CREATE INDEX idx_audit_tenant_created ON authorization_audit_logs(tenant_id, created_at);

For SaaS products, custom tenant-level roles usually mean tenant_id + name should be unique. In regulated environments, global role definitions and tenant-local assignments can be easier to audit. The right choice depends on who is allowed to change roles and how those changes are reviewed.

Test the Denials, Not Only the Happy Path

RBAC tests should prove that expected actions work, but they should spend more energy on denial cases: same role in a different tenant, same tenant but different owner, logged-in user without permission, unknown resource, and unauthenticated requests.

// test/rbac.test.ts
import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/server.js";

const editorUser1 = {
  "x-user-id": "user-1",
  "x-tenant-id": "tenant-a",
  "x-roles": "editor"
};

const editorTenantB = {
  "x-user-id": "user-9",
  "x-tenant-id": "tenant-b",
  "x-roles": "editor"
};

const owner = {
  "x-user-id": "owner-1",
  "x-tenant-id": "tenant-a",
  "x-roles": "owner"
};

describe("RBAC middleware", () => {
  it("rejects unauthenticated requests", async () => {
    const res = await request(app).get("/articles/a1");
    expect(res.status).toBe(401);
  });

  it("allows an editor to update an owned article in the same tenant", async () => {
    const res = await request(app)
      .patch("/articles/a1")
      .set(editorUser1)
      .send({ title: "Updated roadmap" });

    expect(res.status).toBe(200);
    expect(res.body.title).toBe("Updated roadmap");
  });

  it("blocks cross-tenant access even when the role matches", async () => {
    const res = await request(app).get("/articles/a1").set(editorTenantB);

    expect(res.status).toBe(403);
    expect(res.body.reason).toBe("tenant_mismatch");
  });

  it("blocks editors from managing users", async () => {
    const res = await request(app).get("/admin/users").set(editorUser1);

    expect(res.status).toBe(403);
    expect(res.body.reason).toBe("role_missing_permission");
  });

  it("allows owners to delete tenant resources", async () => {
    const res = await request(app).delete("/articles/a2").set(owner);

    expect(res.status).toBe(204);
  });
});

Run it locally:

npm install
npm test
npm run dev

When Claude Code adds tests, ask it to cover tenant_mismatch, not_resource_owner, role_missing_permission, and unauthenticated explicitly. The reason string returned to the client should match the reason written to audit logs, otherwise debugging production incidents becomes guesswork.

Four Real Use Cases

The first use case is B2B project management. Owners can invite members and update billing settings, while editors can only update project content. The tenant boundary matters more than the role name. A guessed projectId must fail both in the database query and in authorization middleware.

The second use case is an internal CMS. Editors can update their own drafts, but deleting a published article or editing another author’s article requires a stronger role. That is object-level authorization layered on top of RBAC, because article:update still depends on ownerId and status.

The third use case is billing and refunds. A billing_admin may read invoices, but refunds may require amount limits, approval status, or a second reviewer. RBAC opens the gate; business attributes decide whether a specific refund is safe.

The fourth use case is support impersonation. Support staff may view a customer workspace to debug a ticket, but should not change email addresses, payment methods, or owner roles. Every impersonated action should be logged separately from the customer’s own actions.

Failure Cases and Fixes

FailureRiskFix
Returning all records for any logged-in userHorizontal privilege escalationFilter by tenant_id and resource ID every time
Hiding buttons only in the UIDirect API calls still workEnforce authorization on the server
Growing one giant admin roleReviews become meaninglessSplit permissions and rebuild roles
Logging only allowed actionsDenied probing disappearsLog allowed and denied decisions with reasons
Testing only success casesRegressions open quiet holesAutomate denial cases as a matrix

The UI is not a security boundary. Hiding a delete button improves usability, but it does not protect the endpoint. If the API does not check article:delete, the operation is still exposed.

What to Delegate to Claude Code

Claude Code is useful for turning a clear authorization policy into code, tests, and reviewable diffs. It should not invent the security policy on its own. Give it the files, denial rules, and review criteria.

Help implement RBAC.
Only change src/rbac.ts, src/server.ts, and test/rbac.test.ts.

Requirements:
- An authenticated actor has userId, tenantId, and roles
- Permissions use the resource:action format
- Unknown permission, empty role list, and tenant mismatch deny by default
- article:update is allowed only for owner role or the article owner
- 403 responses include a reason, and audit logs use the same reason
- Add more denial tests than success tests

Do not:
- Add an admin bypass
- Treat UI visibility as authorization
- Modify the existing authentication flow

Review the result by checking whether authorize is the only decision point, every protected route uses middleware, every object query has a tenant boundary, audit logs avoid secrets and tokens, and tests cover negative cases. If Claude Code produces a large diff, split authorization changes from unrelated refactors before review.

When RBAC Should Become ABAC

RBAC is good for job responsibilities. It becomes awkward when conditions depend on attributes: refunds above a threshold need two approvals, EU personal data must be viewed from an EU region, exports are allowed only for Enterprise plans, or a permission is valid only during business hours. At that point, role names start multiplying.

ABAC means Attribute-Based Access Control. It evaluates attributes of the actor, resource, environment, and request. A practical path is to keep RBAC as the coarse gate, then add ABAC-style checks where business rules require them. Policy engines and libraries can help, but they should not replace clear permission names, tenant boundaries, audit logs, and tests.

Summary

Good RBAC is defined by clear denial behavior. Separate authentication from authorization, model permissions as resource:action, enforce deny by default, check tenant boundaries, add object-level rules, and verify the whole path with tests and audit logs. That structure gives Claude Code a safe scope: implement the policy, add denial tests, and keep the authorization surface reviewable.

For a full API flow, combine this article with the Claude Code API development guide, the JWT authentication guide, and the security best practices. If you need consulting, training, or implementation support, bring your current role table, protected API list, and the operations that would be most damaging if exposed. That is enough to start a focused RBAC review.

When you try this article’s approach, verify four things: denial tests pass, cross-tenant IDs return 403, audit logs contain both allowed and denied decisions, and Claude Code did not introduce a shortcut that bypasses authorize. Start with one dangerous operation, write down role, permission, resource, action, tenantId, and ownerId, then move that matrix into code.

#Claude Code #RBAC #authorization #security #design patterns
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.