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
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:
# .env.local
RBAC_API_KEY=ac_live_...
RBAC_API_URL=https://api.wardenauthz.comStep 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:
// 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):
// 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:
// 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):
// 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:
// 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:
// 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:
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
})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.