Tekko

Language

Get in Touch →

Usually respond within 24 hours

Back to BlogArchitecture

Scaling SaaS Permissions: ReBAC with OpenFGA and Next.js

7 min read
OpenFGANext.jsReBACSaaSSecurity
Scaling SaaS Permissions: ReBAC with OpenFGA and Next.js

As a SaaS application grows, authorization logic often becomes the most tangled part of the codebase. What starts as a simple isAdmin flag eventually evolves into a complex web of roles, permissions, and resource-specific overrides. If you have ever tried to implement a GitHub-style permission system—where a user is a 'Reader' of a repository because they are a 'Member' of an 'Organization' that owns a 'Team'—you know that traditional Role-Based Access Control (RBAC) quickly hits a wall.

This is where Relationship-Based Access Control (ReBAC) comes in. Inspired by Google’s Zanzibar whitepaper, ReBAC shifts the focus from roles to the relationships between users and objects. In this guide, we will explore how to implement ReBAC using OpenFGA (Fine-Grained Authorization) and Next.js to build a scalable, multi-tenant permission system.

Why RBAC Fails Modern SaaS

Traditional RBAC is identity-centric. You assign a user a role (e.g., Editor), and your code checks if (user.role === 'Editor'). This works for flat hierarchies, but modern SaaS requirements are rarely flat. Consider these common scenarios:

  1. Nested Resources: A user should have access to a document because they have access to the folder containing it.
  2. Resource-Specific Roles: A user is an Admin for Project A but only a Viewer for Project B.
  3. Transitive Permissions: If Team A is granted access to a resource, all members of Team A should inherit that access.

In a standard relational database, solving these requires complex recursive JOINs or pre-calculating permission flat-tables—both of which are nightmare scenarios for performance and maintainability. ReBAC solves this by treating authorization as a graph problem rather than a set of boolean flags.

Understanding the ReBAC Model: Tuples and Relationships

At the heart of ReBAC (and OpenFGA) is the concept of a Relationship Tuple. A tuple is a simple statement that defines a relationship between a subject and an object:

document:proposal#viewer@user:anne

This reads as: "User Anne has a 'viewer' relationship with 'document:proposal'."

In OpenFGA, we define a Configuration Model (or schema) that dictates the rules for these relationships. Instead of hardcoding logic like "Admins can edit everything," we define relationships like "An editor of a workspace is also an editor of every document in that workspace."

Designing the Schema for Multi-Tenant SaaS

Let’s build a schema for a typical B2B SaaS application where we have Organizations, Projects, and Documents.

The OpenFGA DSL

model schema 1.1 type user type organization relations define admin: [user] define member: [user] or admin type project relations define parent_org: [organization] define owner: [user] or admin from parent_org define contributor: [user] or owner type document relations define parent_project: [project] define viewer: [user] or contributor from parent_project define editor: [user] or owner from parent_project

In this model:

  • Inheritance: An admin of an organization automatically becomes an owner of a project if that project is linked to the organization.
  • Hierarchy: A viewer of a document can be a specific user or anyone who is a contributor to the parent project.
  • Decoupling: The application logic doesn't need to know why a user has access; it only needs to ask OpenFGA: "Can User X view Document Y?"

Integrating OpenFGA with Next.js

To implement this in a Next.js environment, we need to handle two sides of the coin: Writing Tuples (when things change) and Checking Permissions (when a user performs an action).

1. Setting up the OpenFGA Client

First, install the SDK:

npm install @openfga/sdk

Create a utility to initialize the client. In a Next.js environment, you'll want to ensure this is handled securely on the server side.

import { OpenFgaClient } from '@openfga/sdk'; export const fgaClient = new OpenFgaClient({ apiUrl: process.env.FGA_API_URL, // e.g., http://localhost:8080 storeId: process.env.FGA_STORE_ID, authorizationModelId: process.env.FGA_MODEL_ID, });

2. Writing Tuples on Resource Creation

When a new project is created in your Next.js API route or Server Action, you must inform OpenFGA of the relationship.

// app/api/projects/route.ts export async function POST(req: Request) { const { userId, orgId, projectName } = await req.json(); // 1. Create project in your primary DB (Prisma, Drizzle, etc.) const project = await db.project.create({ data: { name: projectName, orgId } }); // 2. Create relationship tuples in OpenFGA await fgaClient.write({ writes: [ { user: `user:${userId}`, relation: 'owner', object: `project:${project.id}`, }, { user: `organization:${orgId}`, relation: 'parent_org', object: `project:${project.id}`, } ], }); return Response.json(project); }

3. Enforcing Permissions in Middleware and Server Components

For a seamless user experience, you want to check permissions as early as possible. However, for security, the ultimate check must happen on the server.

Server-Side Permission Check

In Next.js 14+ Server Components, you can perform a check before rendering sensitive data:

// app/projects/[id]/page.tsx export default async function ProjectPage({ params }: { params: { id: string } }) { const session = await getSession(); const { allowed } = await fgaClient.check({ user: `user:${session.user.id}`, relation: 'viewer', object: `project:${params.id}`, }); if (!allowed) { return <div>Access Denied</div>; } const projectData = await getProject(params.id); return <ProjectDashboard data={projectData} />; }

Handling Multi-Tenancy at Scale

In a multi-tenant environment, isolation is paramount. OpenFGA handles this naturally by using storeId. You can either use one store per tenant (for hard isolation) or a single store with the tenant ID embedded in the object names (e.g., tenant_1:project_abc).

For most SaaS applications, a shared store with prefixed object IDs is the most manageable approach. This allows you to write global policies while maintaining logical separation.

Performance and Caching

One common concern with externalizing authorization is latency. Every permission check is a network call. To mitigate this:

  1. Consistency vs. Speed: OpenFGA allows for "check" requests with different consistency levels. For UI elements (like showing/hiding a button), a slightly stale cache is often acceptable.
  2. Batch Checks: If you are rendering a list of 50 documents, don't call check 50 times. Use the ListObjects or BatchCheck APIs to find all resources a user has access to in a single call.
  3. Middleware Caching: Use Next.js Middleware to check high-level access (like "is this user in this org?") and cache the result in a JWT or a session cookie to avoid redundant FGA calls on every page transition.

The "Source of Truth" Dilemma

When implementing ReBAC, you now have two databases: your primary application database (Postgres/MySQL) and your authorization store (OpenFGA).

It is vital to treat OpenFGA as the authoritative source for access, but not for resource metadata. Your Postgres DB should still store the project's name, description, and timestamps. OpenFGA only cares about the relationship graph.

To keep them in sync:

  • Use database transactions where possible (though cross-system transactions are difficult).
  • Implement an idempotent "sync" worker that periodically ensures the tuples in OpenFGA reflect the state of your primary database.
  • Use Outbox patterns to emit events (e.g., ProjectCreated) that a background worker picks up to update OpenFGA.

Actionable Conclusion

Implementing ReBAC with OpenFGA transforms authorization from a chore into a robust architectural component. It allows your product team to define complex sharing and collaboration features without requiring developers to rewrite the core permission logic every time.

Next Steps for Implementation:

  1. Audit your current permissions: Identify where simple RBAC is failing or where you have "spaghetti" if statements.
  2. Define your Model: Use the OpenFGA Playground to draft your schema DSL and test it against sample tuples.
  3. Start Small: Don't migrate everything at once. Start by moving one complex resource (like a shared folder or a project) to OpenFGA while keeping basic site-wide roles in your existing system.
  4. Decouple Early: Even if you aren't ready for OpenFGA yet, start wrapping your permission checks in a dedicated service or helper function. This makes swapping the underlying engine much easier later on.

By adopting a Zanzibar-inspired model today, you are building an authorization system that can scale from your first ten users to millions, without ever needing a fundamental redesign.