WardenAuthAgent Security
PricingDocsCompareBlogLearnChangelog
Sign inGet started free
All posts
Tutorial
June 18, 2026
15 min read

Implementing Multi-Tenant RBAC in Next.js with AWS Cognito

A step-by-step guide to adding fine-grained access control to a Next.js application backed by AWS Cognito — from creating scopes per customer workspace to checking permissions in API routes and React Server Components.


This guide walks through adding fine-grained, multi-tenant role-based access control to a Next.js application that uses AWS Cognito for authentication. By the end, you'll be able to:

  • Create per-customer workspaces (scopes) in EC-RBAC
  • Define roles and permissions for each workspace
  • Assign roles to users via access policies
  • Check permissions in Next.js API routes and React Server Components
All code in this guide assumes Next.js 14 with the App Router. Cognito is used for auth; EC-RBAC is used for authorization only. You can replace Cognito with any JWT-based auth provider.

Step 1: Set Up Your EC-RBAC Account

Create an account at wardenauthz.com/register. After registration, you'll have:

  • An organization (your company/product)
  • An API key for your backend services
  • A default workspace scope

Store your API key in your Next.js environment:

bash
# .env.local
RBAC_API_KEY=ac_live_...
RBAC_API_URL=https://api.wardenauthz.com

Step 2: Create Scopes per Customer

In EC-RBAC, a scope is a tenant boundary. Each of your customers gets their own scope. When you onboard a new customer, create a scope for them:

typescript
// app/api/customers/route.ts
import { NextResponse } from 'next/server'

async function createCustomerScope(customerId: string, customerName: string) {
  const res = await fetch(`${process.env.RBAC_API_URL}/v1/scopes`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.RBAC_API_KEY!,
    },
    body: JSON.stringify({
      id: customerId,
      name: customerName,
    }),
  })

  if (!res.ok) {
    const error = await res.json()
    throw new Error(`Failed to create scope: ${error.message}`)
  }

  return res.json()
}

export async function POST(request: Request) {
  const { customerId, customerName } = await request.json()
  await createCustomerScope(customerId, customerName)
  return NextResponse.json({ success: true })
}

Step 3: Define Permissions

Permissions define what actions can be performed on what resources. Create them once per scope (or use templates when creating new customer scopes):

typescript
// lib/rbac.ts
const RBAC_HEADERS = {
  'Content-Type': 'application/json',
  'x-api-key': process.env.RBAC_API_KEY!,
}

async function createPermission(scopeId: string, permission: {
  id: string
  resource: string
  action: string
  effect: 'allow' | 'deny'
}) {
  await fetch(`${process.env.RBAC_API_URL}/v1/scopes/${scopeId}/permissions`, {
    method: 'POST',
    headers: RBAC_HEADERS,
    body: JSON.stringify(permission),
  })
}

// Seed default permissions when creating a new customer scope
export async function seedPermissions(scopeId: string) {
  const permissions = [
    { id: 'invoice-read',    resource: 'invoice',  action: 'read',   effect: 'allow' },
    { id: 'invoice-create',  resource: 'invoice',  action: 'create', effect: 'allow' },
    { id: 'invoice-delete',  resource: 'invoice',  action: 'delete', effect: 'allow' },
    { id: 'user-read',       resource: 'user',     action: 'read',   effect: 'allow' },
    { id: 'user-invite',     resource: 'user',     action: 'invite', effect: 'allow' },
    { id: 'billing-read',    resource: 'billing',  action: 'read',   effect: 'allow' },
    { id: 'billing-manage',  resource: 'billing',  action: 'manage', effect: 'allow' },
  ]

  await Promise.all(permissions.map(p => createPermission(scopeId, p)))
}

Step 4: Create Roles

Roles bundle permissions together. Create a few default roles per customer scope:

typescript
// lib/rbac.ts (continued)
async function createRole(scopeId: string, role: {
  id: string
  name: string
  permissions: string[]  // permission IDs from Step 3
}) {
  await fetch(`${process.env.RBAC_API_URL}/v1/scopes/${scopeId}/roles`, {
    method: 'POST',
    headers: RBAC_HEADERS,
    body: JSON.stringify(role),
  })
}

export async function seedRoles(scopeId: string) {
  await createRole(scopeId, {
    id: 'admin',
    name: 'Admin',
    permissions: ['invoice-read', 'invoice-create', 'invoice-delete',
                  'user-read', 'user-invite', 'billing-read', 'billing-manage'],
  })

  await createRole(scopeId, {
    id: 'member',
    name: 'Member',
    permissions: ['invoice-read', 'invoice-create', 'user-read'],
  })

  await createRole(scopeId, {
    id: 'viewer',
    name: 'Viewer',
    permissions: ['invoice-read', 'user-read'],
  })
}

Step 5: Assign Roles to Users

When a user is invited to a customer workspace, assign them a role via an access policy. The subjectId is the user's Cognito sub (UUID):

typescript
// lib/rbac.ts (continued)
export async function assignRole(scopeId: string, userId: string, roles: string[]) {
  await fetch(`${process.env.RBAC_API_URL}/v1/scopes/${scopeId}/access-policies`, {
    method: 'POST',
    headers: RBAC_HEADERS,
    body: JSON.stringify({
      subjectId: userId,
      roles,
    }),
  })
}

// When a user accepts an invitation:
await assignRole('customer-acme', cognitoUser.sub, ['member'])

Step 6: Check Permissions in API Routes

The access check API takes a subject, resource, action, and scope. Use it in your Next.js API routes before performing sensitive operations:

typescript
// lib/auth.ts
import { getServerSession } from 'next-auth'  // or your Cognito session helper

export async function requirePermission(
  userId: string,
  scopeId: string,
  resource: string,
  action: string
) {
  const res = await fetch(`${process.env.RBAC_API_URL}/v1/has-access`, {
    method: 'POST',
    headers: RBAC_HEADERS,
    body: JSON.stringify({ subjectId: userId, scopeId, resource, action }),
  })

  const { allowed } = await res.json()
  if (!allowed) {
    throw new Error('Forbidden')
  }
}

// app/api/invoices/[id]/route.ts
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
  const session = await getServerSession()
  const scopeId = request.headers.get('x-workspace-id')!

  try {
    await requirePermission(session.user.id, scopeId, 'invoice', 'delete')
  } catch {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  }

  await deleteInvoice(params.id)
  return NextResponse.json({ success: true })
}

Step 7: Check Permissions in React Server Components

In Next.js 14 App Router, you can check permissions directly in Server Components without client-side fetching:

typescript
// app/(app)/invoices/page.tsx
import { getServerSession } from 'next-auth'
import { checkPermission } from '@ac/web-shared/lib/api'

export default async function InvoicesPage() {
  const session = await getServerSession()
  const scopeId = cookies().get('active-workspace')?.value

  const [canCreate, canDelete] = await Promise.all([
    checkPermission(session.user.id, scopeId, 'invoice', 'create'),
    checkPermission(session.user.id, scopeId, 'invoice', 'delete'),
  ])

  return (
    <div>
      <div className="flex justify-between">
        <h1>Invoices</h1>
        {canCreate && <button>New Invoice</button>}
      </div>
      <InvoiceList showDelete={canDelete} />
    </div>
  )
}

Caching Considerations

Access checks are fast (3–8ms), but if you're making many per render, consider caching with React's built-in cache() function for Server Components:

typescript
import { cache } from 'react'

// Deduplicated within a single request lifecycle
export const checkPermission = cache(async (
  userId: string,
  scopeId: string,
  resource: string,
  action: string
): Promise<boolean> => {
  const res = await fetch(`${process.env.RBAC_API_URL}/v1/has-access`, {
    method: 'POST',
    headers: RBAC_HEADERS,
    body: JSON.stringify({ subjectId: userId, scopeId, resource, action }),
    // Next.js fetch caching — cache for 30 seconds
    next: { revalidate: 30 },
  })
  const { allowed } = await res.json()
  return allowed
})
Using next: { revalidate: 30 } on authorization checks is safe — if a user's role changes, worst case they retain or lose access for up to 30 seconds. Adjust to your SLA requirements. For security-critical operations (payments, data deletion), use cache: 'no-store' to always check live.

Summary

You now have multi-tenant RBAC in your Next.js + Cognito app. Each customer has isolated permissions, roles, and user assignments. Access decisions are a single HTTP call, with optional caching via Next.js fetch. The RBAC configuration is fully dynamic — roles and permissions can be changed by your customers via the EC-RBAC dashboard or your own UI.


Back to blogTry WardenAuth free →
© 2026 ecarrizo. All rights reserved.
PricingDocsCompareBlogLearnChangelogStatusGlossaryContactTermsPrivacy