KeyloomKeyloom
Keyloom Auth is currently in beta. Feedback and contributions are welcome!
Core

RBAC

Role-based access control - configuration, permission mapping, and guards.

RBAC

Keyloom supports optional RBAC for multi-organization apps. Enable it in config and use helpers to protect routes and actions.

Config

keyloom.config.ts (RBAC)
export default defineKeyloom({
  // ...
  rbac: {
    enabled: true,
    roles: {
      admin: { permissions: ["manage:org", "manage:users", "read"] },
      member: { permissions: ["read"] },
    },
  },
});
  • enabled: when false, role/org checks are skipped
  • roles: structured mapping of roles to permissions

Types (from @keyloom/core):

  • RbacConfig, RbacRolesMapping, RbacRolePermissions

Permission mapping

import { toPermissionMap } from "@keyloom/core/rbac/policy";

const permMap = toPermissionMap(config.rbac); // { 'manage:org': ['admin'], 'read': ['admin','member'] }

Core guard (generic)

import { withRole } from "@keyloom/core/rbac/with-role";

const handler = withRole(
  async () => {
    // protected logic
    return "ok";
  },
  {
    requiredRoles: ["admin", "member"],
    requiredPermission: "manage:org",
    getRole: async () => ({ role: "admin" }),
    permMap,
  }
);
  • If requiredRoles is provided, the current role must be included
  • If requiredPermission is provided, role must be allowed in the map

Next.js helpers

For app router ergonomics use @keyloom/nextjs/rbac:

import {
  withRole,
  getActiveOrgId,
  setActiveOrgCookie,
} from "@keyloom/nextjs/rbac";
  • getActiveOrgId() - reads current org from cookie
  • setActiveOrgCookie(orgId) - returns a Set-Cookie string
  • withRole(action, { getUser, adapter, requiredRoles, requiredPermission, config }) - server action wrapper enforcing RBAC

Adapter requirements

When RBAC is enabled, your adapter should implement membership queries:

interface RbacAdapter {
  getMembership(
    userId: string,
    orgId: string
  ): Promise<{ role: string } | null>;
}

Use provided memory adapter implementation as a reference.

Patterns

  • Redirect users without an active org to an org selection page
  • Use middleware for coarse gating and withRole for server-side enforcement
  • Keep permissions small and composable (e.g., read, write, manage:org)

Prerequisites

  • Define roles and permissions in keyloom.config.ts
  • Ensure your adapter can read memberships: getMembership(userId, orgId)
  • Choose an organization selection strategy (cookie, query param, page)

Organization selection workflow (example)

app/orgs/actions.ts
"use server";
import { setActiveOrgCookie } from "@keyloom/nextjs/rbac";

export async function selectOrg(orgId: string) {
  // validate orgId belongs to user before setting
  return new Response(null, {
    status: 204,
    headers: { "set-cookie": setActiveOrgCookie(orgId) },
  });
}
app/orgs/page.tsx
import { getActiveOrgId } from "@keyloom/nextjs/rbac";
import { selectOrg } from "./actions";

export default async function OrgsPage() {
  const current = getActiveOrgId();
  const orgs = [
    { id: "org_1", name: "Acme" },
    { id: "org_2", name: "Globex" },
  ];
  return (
    <form action={(fd) => selectOrg(String(fd.get("orgId")))}>
      <select name="orgId" defaultValue={current ?? undefined}>
        {orgs.map((o) => (
          <option key={o.id} value={o.id}>
            {o.name}
          </option>
        ))}
      </select>
      <button type="submit">Use organization</button>
    </form>
  );
}

Server action enforcement

app/actions/admin.ts
import { withRole } from "@keyloom/nextjs/rbac";
import cfg from "@/keyloom.config";

export async function adminOnly(action: () => Promise<Response>) {
  return withRole(action, {
    requiredRoles: ["owner", "admin"],
    getUser: async () => ({ id: "usr_42" }),
    adapter: (cfg as any).adapter,
    rbacEnabled: cfg.rbac?.enabled !== false,
    config: cfg, // derive permission map when provided
  });
}

API reference

Prop

Type

Types (core): RbacConfig, RbacRolesMapping, Role, Permission, Membership.

Realistic permission mapping

const rbac = {
  enabled: true,
  roles: {
    owner: {
      permissions: ["manage:org", "manage:users", "billing", "read", "write"],
    },
    admin: { permissions: ["manage:users", "read", "write"] },
    member: { permissions: ["read"] },
    viewer: { permissions: ["read"] },
  },
} as const;

Business rules examples:

  • Only owner can access billing and transfer ownership
  • admin can invite/remove members and manage resources
  • member can read and write project resources
  • viewer can only read

Adapter contract

interface RbacAdapter {
  getMembership(
    userId: string,
    orgId: string
  ): Promise<{ role: string } | null>;
}
  • Implement getMembership efficiently (index on userId+orgId)
  • Return null for non-members to enforce 403

Troubleshooting

  • No active org: set cookie via setActiveOrgCookie after user selection
  • Access denied: role missing required permission; check toPermissionMap result
  • RBAC ignored in middleware: ensure config.rbac.enabled !== false
  • Edge checks failing: rely on server withRole for final enforcement

Performance and security

  • Derive permission map once per request; avoid recomputing repeatedly
  • Keep the org cookie HttpOnly and SameSite=Lax
  • Do not trust client-provided orgId without server validation

See also

Next steps

  • Add an org selection page and persist selection
  • Wrap sensitive server actions with withRole

How is this guide?