DEV Community

Cover image for How to Scope Permissions Per Tenant in a Multi-Tenant App
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Scope Permissions Per Tenant in a Multi-Tenant App

The same user, Sarah, is an admin at Acme Corp and a read-only viewer at Startup Inc. She should see completely different things in the same product depending on which tenant she is operating in. Here is how to build that correctly.

In this article, you will learn:

  • What "per-tenant permissions" actually means and why it is different from global RBAC
  • How Kinde models multi-tenancy with organizations, and how tenant context flows into every token
  • How to define roles and permissions in Kinde that scope per organization
  • How to read tenant-scoped permissions from the Kinde token in a Next.js API route
  • How to build a reusable permission guard that you can drop into any route
  • How to gate UI components in React based on the current tenant's permissions
  • How to isolate data at the database query level using the org code from the token
  • How to handle the user-switching-tenants scenario correctly
  • The two most common multi-tenant permission bugs and how to avoid them

Let's dive in!

The Problem With Global Permissions

Most developers reach for a simple RBAC model first: define some roles globally, assign users to roles, check roles in your code. Admin can do everything, member can do some things, viewer can only read.

This works fine for single-tenant apps. It falls apart the moment one user belongs to more than one tenant.

Consider Sarah. She joined Acme Corp as an admin — she manages billing, invites colleagues, and deletes stale projects. She was also invited to Startup Inc as an outside consultant — read-only access, she can view reports but touch nothing.

In a global RBAC model, you cannot express this. Sarah has one set of roles in your system. If she is an admin, she is an admin everywhere. If you restrict her globally to viewer, she loses the ability to manage Acme Corp.

The correct model is tenant-scoped permissions: Sarah's role and permissions are defined per organization. When she logs into Acme Corp, her token says admin with full permissions. When she logs into Startup Inc, the same user's token says viewer with read-only permissions. Same user identity, completely different permission set, determined by which tenant context she authenticated into.

This is exactly how Kinde handles multi-tenancy, and it is the right mental model for any B2B SaaS product.

User

How Kinde Models This

In Kinde, every customer of your SaaS product is an organization. Acme Corp is one org. Startup Inc is another. Each has its own isolated context — its own members, its own roles assigned to those members, its own feature flags, and optionally its own auth settings.

When a user logs in, they authenticate within an organization context. Kinde issues a JWT access token that includes the org_code — the unique identifier for the tenant they are currently operating in. Every permission, every role, every feature flag in that token is scoped to that specific organization.

The token for Sarah logged into Acme Corp looks like this:

{
  "sub": "kp_123abc",
  "email": "sarah@example.com",
  "org_code": "org_acme_corp",
  "roles": [
    { "id": "rol_001", "key": "admin", "name": "Admin" }
  ],
  "permissions": [
    "projects:create",
    "projects:read",
    "projects:delete",
    "billing:manage",
    "members:invite"
  ],
  "iss": "https://yourapp.kinde.com",
  "exp": 1740000000
}
Enter fullscreen mode Exit fullscreen mode

The same Sarah logged into Startup Inc gets a new token:

{
  "sub": "kp_123abc",
  "email": "sarah@example.com",
  "org_code": "org_startup_inc",
  "roles": [
    { "id": "rol_003", "key": "viewer", "name": "Viewer" }
  ],
  "permissions": [
    "projects:read",
    "reports:read"
  ],
  "iss": "https://yourapp.kinde.com",
  "exp": 1740000000
}
Enter fullscreen mode Exit fullscreen mode

Same sub. Different org_code, roles, and permissions. Your application reads these claims and enforces them. Kinde's job is to put the right claims into each token based on what role the user holds in each specific org.

Two JWTs side by side, both showing

Step #1: Define Roles and Permissions in Kinde

Before writing a single line of application code, configure the roles and permissions that express your access model in the Kinde dashboard.

Defining permissions

Navigate to Roles & PermissionsPermissionsAdd permission.

Kinde dashboard Roles & Permissions > Permissions page showing a list of defined permissions with columns for key and description.

Create one permission per discrete action in your product. The resource:action naming pattern keeps permissions readable as your product grows:

Permission key Description
projects:create Create new projects
projects:read View projects and details
projects:delete Permanently delete projects
members:invite Invite new members to the organization
members:remove Remove members from the organization
billing:manage Manage subscription and billing
reports:read View analytics and reports
reports:export Export report data

Note: Permission keys must be defined centrally in Kinde before they can appear in any token. A permission that is not defined will never show up in the permissions array, regardless of what roles you assign.

Defining roles

Navigate to Roles & PermissionsRolesAdd role.

Kinde dashboard Roles page showing three roles: Admin (description:

For the project management example, three roles cover most B2B SaaS access models:

Admin: projects:create, projects:read, projects:delete, members:invite, members:remove, billing:manage, reports:read, reports:export

Member: projects:create, projects:read, reports:read

Viewer: projects:read, reports:read

These role-to-permission mappings are what Kinde resolves when building the permissions array in the user's token. Assign a user the admin role within a specific org, and every permission attached to that role flows into their token for that org.

Assigning roles per organization

When a user joins an organization, they are assigned a role for that organization specifically. Navigate to an organization in Kinde → Members → select a member → assign their role.

Kinde dashboard showing an organization's member list with three members: Alice (Admin badge), Bob (Member badge), Sarah (Viewer badge).

This is the per-tenant scoping in action. Sarah's role in this specific organization is Viewer. Navigate to a different organization, find Sarah there, and she might have a completely different role assignment.

Terrific! Kinde is configured. Time to use these permissions in the application.

Step #2: Read Tenant-Scoped Permissions in a Next.js API Route

With the Kinde Next.js SDK installed and configured, reading tenant-scoped permissions server-side requires one import.

// app/api/projects/route.ts
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { NextResponse } from "next/server";

export async function GET() {
  const { isAuthenticated, getOrganization, getPermissions } =
    getKindeServerSession();

  // Step 1: Confirm the user is authenticated
  if (!(await isAuthenticated())) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Step 2: Get the current tenant context from the token
  // org_code is the tenant identifier — comes from the signed JWT, not the request
  const org = await getOrganization();
  if (!org?.orgCode) {
    return NextResponse.json(
      { error: "No organization context" },
      { status: 403 }
    );
  }

  // Step 3: Get this user's permissions for this specific org
  // getPermissions() returns { permissions: string[], orgCode: string }
  const { permissions, orgCode } = await getPermissions();

  // Step 4: Check the required permission
  if (!permissions.includes("projects:read")) {
    return NextResponse.json(
      { error: "You do not have permission to view projects" },
      { status: 403 }
    );
  }

  // Step 5: Fetch data scoped to this tenant
  // ALWAYS use org.orgCode from the token — never a client-supplied tenant ID
  const projects = await db.projects.findMany({
    where: { orgCode: org.orgCode },
  });

  return NextResponse.json({ projects });
}
Enter fullscreen mode Exit fullscreen mode

Two things to pay attention to here.

getPermissions() returns both the permissions array and the orgCode — confirming which org these permissions apply to. This is useful for logging and debugging: if a permission check fails unexpectedly, you can verify the org context is what you expected.

The database query filters by org.orgCode from the token, not a tenant ID from the request body or query string. The org code in the JWT is cryptographically signed — a client-supplied value is not. This is the fundamental data isolation guarantee of the multi-tenant model.

Step #3: Build a Reusable Permission Guard

Repeating the same auth and permission check logic in every route is tedious and error-prone. Extract it into a single reusable guard function.

// lib/permissions.ts
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { NextResponse } from "next/server";

// Enumerate all permissions in one place — catches typos at compile time
type Permission =
  | "projects:create"
  | "projects:read"
  | "projects:delete"
  | "members:invite"
  | "members:remove"
  | "billing:manage"
  | "reports:read"
  | "reports:export";

// What a successful auth context looks like
interface AuthContext {
  userId: string;
  orgCode: string;
  permissions: Permission[];
}

/**
 * Checks authentication, org context, and required permissions.
 * Returns AuthContext on success, NextResponse error on failure.
 *
 * Usage:
 *   const auth = await requirePermission("projects:read");
 *   if (auth instanceof NextResponse) return auth;
 *   // auth.orgCode and auth.permissions are now available
 */
export async function requirePermission(
  ...required: Permission[]
): Promise<AuthContext | NextResponse> {
  const { isAuthenticated, getOrganization, getPermissions, getUser } =
    getKindeServerSession();

  if (!(await isAuthenticated())) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const [user, org, { permissions }] = await Promise.all([
    getUser(),
    getOrganization(),
    getPermissions(),
  ]);

  if (!org?.orgCode) {
    return NextResponse.json(
      { error: "No organization context — authenticate within an organization" },
      { status: 403 }
    );
  }

  const hasPermission = required.some((perm) =>
    (permissions as string[]).includes(perm)
  );

  if (!hasPermission) {
    return NextResponse.json(
      {
        error: "Insufficient permissions",
        required,
        granted: permissions,
      },
      { status: 403 }
    );
  }

  return {
    userId: user?.id ?? "",
    orgCode: org.orgCode,
    permissions: permissions as Permission[],
  };
}
Enter fullscreen mode Exit fullscreen mode

Every protected API route now becomes a clean two-step: check permissions, run logic.

// app/api/projects/route.ts
import { requirePermission } from "@/lib/permissions";
import { NextResponse } from "next/server";

// GET — requires projects:read
export async function GET() {
  const auth = await requirePermission("projects:read");
  if (auth instanceof NextResponse) return auth;

  const projects = await db.projects.findMany({
    where: { orgCode: auth.orgCode }, // tenant-scoped
  });

  return NextResponse.json({ projects });
}

// POST — requires projects:create
export async function POST(request: Request) {
  const auth = await requirePermission("projects:create");
  if (auth instanceof NextResponse) return auth;

  const body = await request.json();

  const project = await db.projects.create({
    data: {
      ...body,
      orgCode: auth.orgCode, // stamp the tenant from the token
      createdBy: auth.userId,
    },
  });

  return NextResponse.json({ project }, { status: 201 });
}

// DELETE — requires projects:delete
export async function DELETE(
  _request: Request,
  { params }: { params: { id: string } }
) {
  const auth = await requirePermission("projects:delete");
  if (auth instanceof NextResponse) return auth;

  // orgCode in the where clause prevents cross-tenant deletion
  // Even if a wrong ID is passed, this query will not find it
  // unless it belongs to the current user's tenant
  await db.projects.delete({
    where: {
      id: params.id,
      orgCode: auth.orgCode,
    },
  });

  return NextResponse.json({ deleted: true });
}
Enter fullscreen mode Exit fullscreen mode

The orgCode filter on every write and delete is the data isolation layer. It is the database-level defense that ensures even a logic bug cannot cause cross-tenant data leakage.

Amazing!

Step #4: Gate UI Components by Permission

The API layer is the security boundary — it rejects unauthorized requests. The UI layer is the experience boundary — it hides controls the user cannot use, preventing confusion without exposing a wall of 403 errors.

Create a client-side permission hook:

// hooks/usePermissions.ts
"use client";

import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";

type Permission =
  | "projects:create"
  | "projects:read"
  | "projects:delete"
  | "members:invite"
  | "members:remove"
  | "billing:manage"
  | "reports:read"
  | "reports:export";

export function usePermissions() {
  const { getPermission, getOrganization } = useKindeBrowserClient();

  // getPermission returns { isGranted: boolean, orgCode: string }
  // isGranted is org-scoped — it reflects permissions for the current org context
  const can = (permission: Permission): boolean =>
    getPermission(permission)?.isGranted ?? false;

  const org = getOrganization();

  return {
    can,
    orgCode: org?.orgCode ?? null,
    orgName: org?.name ?? null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Use the hook in your components for conditional rendering:

// components/ProjectCard.tsx
"use client";

import { usePermissions } from "@/hooks/usePermissions";

export function ProjectCard({ project }: { project: Project }) {
  const { can } = usePermissions();

  return (
    <div className="project-card">
      <h3>{project.name}</h3>

      <div className="project-actions">
        {/* Members and admins can edit */}
        {can("projects:create") && (
          <button onClick={() => editProject(project.id)}>Edit</button>
        )}

        {/* Only admins can delete — not rendered at all for members/viewers */}
        {can("projects:delete") && (
          <button
            onClick={() => deleteProject(project.id)}
            className="destructive"
          >
            Delete
          </button>
        )}
      </div>
    </div>
  );
}

// components/Sidebar.tsx
"use client";

import { usePermissions } from "@/hooks/usePermissions";
import Link from "next/link";

export function Sidebar() {
  const { can, orgName } = usePermissions();

  return (
    <nav className="sidebar">
      {/* Show current org context at the top */}
      {orgName && <div className="workspace-name">{orgName}</div>}

      {/* Always visible to authenticated org members */}
      <Link href="/projects">Projects</Link>

      {/* Only visible if user has reports:read */}
      {can("reports:read") && <Link href="/reports">Reports</Link>}

      {/* Only visible if user can invite members (admin) */}
      {can("members:invite") && <Link href="/members">Team Members</Link>}

      {/* Only visible if user can manage billing (admin) */}
      {can("billing:manage") && (
        <Link href="/settings/billing">Billing</Link>
      )}
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

The same component file produces three completely different outputs
depending on who is logged in:

Admin view — full sidebar with Projects, Reports, Team Members,<br>
and Billing. Cards show both Edit and Delete buttons.<br>
Badge shows Admin · Sarah Chen

Member view — sidebar shows Projects and Reports only.<br>
Cards show Edit button only, no Delete.<br>
Badge shows Member · Sarah Chen

Viewer view — sidebar shows Projects only.<br>
Cards show No actions available — no buttons at all.<br>
Badge shows Viewer · Sarah Chen

Note: Conditional rendering hides controls but does not enforce access. A user can still call API routes directly with tools like curl. The requirePermission guard in the API layer is what enforces authorization — the UI gate is for experience, not security.

Step #5: Handle Tenant Switching Correctly

When Sarah switches from Acme Corp to Startup Inc, she needs a new token scoped to the new organization. The existing token's org_code is org_acme_corp — you cannot change a signed JWT's claims without issuing a new one.

The correct approach is to redirect the user through Kinde's authentication with the target org specified:

// components/OrgSwitcher.tsx
"use client";

import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";
import { LoginLink } from "@kinde-oss/kinde-auth-nextjs/components";

interface Org {
  code: string;
  name: string;
}

export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
  const { getOrganization } = useKindeBrowserClient();
  const currentOrg = getOrganization();

  return (
    <div className="org-switcher">
      {orgs.map((org) =>
        org.code === currentOrg?.orgCode ? (
          // Already in this org — highlight it as current
          <div key={org.code} className="org-item current">
            {org.name} <span className="badge">Current</span>
          </div>
        ) : (
          // Switch to this org — triggers a new Kinde token for org.code
          <LoginLink
            key={org.code}
            authUrlParams={{ org_code: org.code }}
            className="org-item"
          >
            {org.name}
          </LoginLink>
        )
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The org_code parameter in authUrlParams tells Kinde which organization to issue the token for. After the brief authentication redirect, the user's token contains the org_code, roles, and permissions for the switched-to org. Every permission check in both the API and the UI immediately reflects the new tenant context.

Org switching flow —

Putting It All Together

Here is the complete per-tenant permission system mapped across all layers:

Layer What it does Where it lives
Kinde (config) Defines permissions globally, bundles them into roles, assigns roles per org Kinde dashboard
JWT token Carries org_code + permissions for the current org context Issued by Kinde, verified by SDK
requirePermission() (API) Rejects requests missing auth, org context, or required permissions lib/permissions.ts
DB queries Filter every read/write/delete by auth.orgCode from the token All API route handlers
usePermissions() hook (UI) Conditionally renders controls based on getPermission(key).isGranted React components
OrgSwitcher (UI) Issues a new token for the selected org via LoginLink + org_code OrgSwitcher component

Full architecture — Kinde (left box: permissions → roles → orgs with role assignments) issues a signed JWT → the JWT flows to both the API Layer (center box: requirePermission guard → DB queries filtered by orgCode) and the React Layer (right box: usePermissions hook → conditional rendering).

The Two Most Common Multi-Tenant Permission Bugs

Bug #1: Trusting the client-supplied tenant ID

// ❌ Wrong — org code from the request body is attacker-controlled
export async function GET(request: Request) {
  const { orgCode } = await request.json();
  const projects = await db.projects.findMany({
    where: { orgCode }, // a user can pass any org's code here
  });
}

// ✅ Correct — org code from the signed JWT cannot be forged
export async function GET() {
  const auth = await requirePermission("projects:read");
  if (auth instanceof NextResponse) return auth;
  const projects = await db.projects.findMany({
    where: { orgCode: auth.orgCode },
  });
}
Enter fullscreen mode Exit fullscreen mode

A user controls what goes in a request body. They cannot forge a claim in a signed JWT. Always read the tenant context from the token.

Bug #2: Checking role names instead of permissions

// ❌ Wrong — brittle, breaks when roles are renamed or restructured
const roles = await getRoles();
const isAdmin = roles?.some(r => r.key === "admin");
if (!isAdmin) return forbidden();

// ✅ Correct — checks the specific action, decoupled from role names
const auth = await requirePermission("projects:delete");
if (auth instanceof NextResponse) return auth;
Enter fullscreen mode Exit fullscreen mode

Role names can change. New roles can be created. A "super_admin" or "owner" role might need delete access too. When you check role names, you have to update every check site when your role structure evolves. When you check permissions, the mapping from roles to permissions is managed in Kinde's configuration and your code stays unchanged.

Conclusion

In this article, you built a complete per-tenant permission system for a multi-tenant Next.js app using Kinde organizations. The same user now has completely different permissions depending on which org they are operating in — because those permissions come from the org-scoped token claims, not from a global role attached to the user.

The model is clear: Kinde puts the right claims in the token for each org context (trust layer). Your API guard enforces permissions before any logic runs (security layer). Your UI hook hides controls the user cannot use (experience layer). Your database queries filter by the org code from the token (isolation layer).

Four layers, one source of truth: the token.

Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account at kinde.com and have multi-tenant permissions running today.

Top comments (0)