Skip to main content

Add organization-scoped authorization to a Next.js App Router app

Short answer: To scope a Next.js App Router app by organization, map each organization to a Permit.io tenant and call permit.check() in route handlers with the user and organization from the verified session. Use it when one user holds different roles in different organizations. Don't use it when every user has one global role, or when you only need show and hide rules in the UI.

FactValue
Question this page answersHow do I make sure users can only edit resources that belong to their organization in a Next.js App Router app?
Organization modelOne Permit tenant for each organization. A user can have a different role in each tenant.
Models usedRBAC for roles in a tenant and ReBAC for roles on a single post.
PDPA container PDP at http://localhost:7766 in this tutorial, or the Cloud PDP.
SDKpermitio on npm. The code in this tutorial is written against version 2.7.6, with Next.js 15.5.
AuthenticationAny provider that issues a JWT with a user ID in sub and an organization ID claim. The example reads a claim named org_id.
Free tierCommunity plan, labeled "Free Forever" on the Permit.io pricing page: MAU 1000, Tenants 20, Authorization Queries No Limit, Environments 3, PDP Instances No Limit.
AuditEach check appears on the Audit Log screen of the Permit dashboard.
Open sourceThe Edge PDP (permitio/PDP), OPAL, cedar-agent, the Permit SDKs, and the Permit CLI are open source. See Open-source fallback.

Build Next.js API routes for a blogging platform that register users in Permit.io and allow only users with the Author role through a protected route. This tutorial is for Next.js developers who want to enforce Permit.io policies from App Router route handlers and middleware.

When you finish, your Next.js app has two API routes:

RouteWhat it does
POST /api/registerSyncs a user to Permit.io and assigns the user the Reader role in the default tenant
GET /api/protected/postsRuns middleware that calls permit.check() and returns 403 unless the user in the user header has permission to create a Post

Prerequisites​

  • A Permit.io account. See Create a Permit.io account.
  • Node.js and npm, to run the app and install the Permit CLI.
  • A Next.js 15.5 or later project that uses the App Router and a src directory, with the @/ import alias pointing to src/. The middleware in this tutorial runs in the Node.js runtime, which Next.js middleware supports from 15.5.
  • Docker, to run the policy decision point (PDP) container.

1. Configure the policy in Permit​

Create the blogging platform policy with the Permit CLI. If your environment already has a policy with a Post resource and an Author role that can create posts, skip to 2. Get your API key.

1

Install the Permit CLI​

The Permit CLI creates policies and runs the PDP from your terminal. Install the CLI with npm:

npm install -g @permitio/cli

Run permit to confirm that the CLI is installed.

2

Sign in with the Permit CLI​

Authenticate the CLI with your Permit.io account:

permit login

The command opens a browser window where you sign in. After you sign in, the CLI uses your default environment. To use a different environment, run permit env select and choose the environment.

3

Apply the blogging platform template​

Permit CLI templates create a policy with predefined resources, roles, and rules. To see the available templates, run permit env template list. The template source files are in the Permit CLI repository.

Terminal output of the Permit CLI template list command showing the available policy templates

Apply the blogging-platform template to your environment:

permit env template apply --template blogging-platform

The CLI prints a success message when the template is applied.

4

Review the policy in the Policy Editor​

In the Permit dashboard, select your project and open the Policy screen.

Permit Policy Editor showing the Post and Comment resources with permissions for the Admin, Reader, Author, and Premium Reader roles

The blogging-platform template creates:

Policy elementWhat the template defines
ResourcesPost (with a premium boolean attribute) and Comment, each with create, read, update, and delete actions
RolesAdmin (all actions), Author (create and read posts, read comments), Reader (create and read comments), and Premium Reader (read posts and comments)
RelationshipA Post is the parent of its Comment instances. An Author of a post instance becomes a Moderator of the comments on that post. This rule is relationship-based access control (ReBAC).
Resource setFree Post contains posts where premium is false. Readers can read free posts. This rule is attribute-based access control (ABAC).

This tutorial uses one rule from the policy: the Author role can create a Post, and the Reader role cannot. To change which role can perform an action, check or clear the box in the Policy Editor.

2. Get your API key​

Your Next.js app and the PDP authenticate with Permit.io with your environment API key. Copy the API key of the environment where you applied the template. See Get your API key.

Keep the API key out of your code

Anyone with the environment API key can change that environment's policy through the Permit API. Load the key from an environment variable, and don't commit it.

3. Run the PDP​

The PDP evaluates each permission check against your policy. Start a PDP container with the Permit CLI:

permit pdp run

The command starts the PDP in Docker and prints the container ID and name. The PDP listens on port 7766, so your app connects to it at http://localhost:7766.

Terminal output of permit pdp run showing the PDP container details

The Free Post resource set is an ABAC rule, and the Cloud PDP doesn't evaluate ABAC rules, so run the container PDP for this policy. To run the container with docker run instead, or to check that the PDP is healthy, see Run the PDP.

4. Build the Next.js app​

1

Install the Node.js SDK​

In your Next.js project directory, install the Permit Node.js SDK:

npm install permitio

For all SDK options, see Check permissions with the Node.js SDK.

2

Create a shared Permit client​

Create src/lib/permit.ts with the following code:

// src/lib/permit.ts
import { Permit } from "permitio";

export const permit = new Permit({
token: process.env.PERMIT_API_KEY!,
pdp: process.env.PDP_URL!,
});

The file exports one Permit client that route handlers import with @/lib/permit. The client reads two environment variables:

VariableValue
PERMIT_API_KEYYour environment API key from 2. Get your API key
PDP_URLThe PDP address from 3. Run the PDP: http://localhost:7766
3

Add the /api/register route​

Create src/app/api/register/route.ts with the following code. The POST handler syncs the user to Permit.io with permit.api.users.sync(), then assigns the user the Reader role in the default tenant.

// src/app/api/register/route.ts
import { permit } from '@/lib/permit';
import { NextRequest } from 'next/server';

export async function POST(req: NextRequest) {
const { email, first_name, last_name } = await req.json();

if (!email || !first_name || !last_name) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400 });
}

try {
// Sync user with Permit
const user = await permit.api.users.sync({
key: email,
email,
first_name,
last_name,
});

// Assign role as part of registration
const assignedRole = {
user: email,
role: 'Reader',
tenant: 'default'
};
const response = await permit.api.users.assignRole(assignedRole);

// Continue with your app's registration logic
return new Response(JSON.stringify({ message: 'User registered and role assigned', user, response }), { status: 201 });
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to sync user' }), { status: 500 });
}
}

The user's email address is the user key in Permit.io. The middleware passes the same key to permit.check().

4

Check permissions in middleware​

Next.js runs middleware from a middleware.ts file in the project root, or in src/ when the project uses a src directory. Create src/middleware.ts with the following code:

// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { permit } from '@/lib/permit';

export async function middleware(req: NextRequest) {
// Only run for /api/protected/* routes
if (!req.nextUrl.pathname.startsWith('/api/protected/')) {
return NextResponse.next();
}

const user = req.headers.get('user');
const action = "create"
const resource = "Post"

if (!user) {
return new NextResponse(JSON.stringify({ message: 'missing the required headers' }), { status: 403 });
}

try {
const permitted = await permit.check(user, action, resource);
if (!permitted) {
return new NextResponse(JSON.stringify({ message: 'You are not authorized to access this resource' }), { status: 403 });
}
return NextResponse.next();
} catch (err) {
return new NextResponse(JSON.stringify({ error: 'Permission check failed' }), { status: 500 });
}
}

export const config = {
runtime: 'nodejs',
matcher: ['/api/protected/:path*'],
};

runtime: 'nodejs' is required. The Permit.io Node.js SDK depends on Node.js modules such as pino, which needs module, os, and path, so the SDK cannot run in the Edge runtime that Next.js 15 middleware uses by default. Without that line, Next.js 15 builds this middleware for the Edge runtime and the permission check fails. Node.js middleware is stable from Next.js 15.5. On Next.js 16 and later, middleware is renamed to proxy, a proxy.ts file runs in the Node.js runtime already, and setting runtime in its config throws an error. See the Next.js proxy reference.

The middleware runs for every request that matches /api/protected/:path*. It reads the user key from the user header and asks the PDP whether that user can create a Post:

ConditionResponse
The user header is missing403 with "missing the required headers"
The PDP call fails500 with "Permission check failed"
The PDP denies the request403 with "You are not authorized to access this resource"
The PDP allows the requestNext.js passes the request to the route handler

To protect other routes, such as commenting or editing, change the action and resource values and the matcher.

note

In a production app, take the user key from your authenticated session. This example reads the user key from a request header so that you can test the route with curl.

5

Add the protected /api/protected/posts route​

In the App Router, a route handler lives in a route.ts file inside a folder named after the URL segment. Create src/app/api/protected/posts/route.ts with the following code:

// src/app/api/protected/posts/route.ts
import { NextRequest } from 'next/server';

export async function GET(req: NextRequest) {
return new Response(
JSON.stringify({ message: "You have passed the auth check" }),
{ headers: { "Content-Type": "application/json" } }
);
}

The GET handler returns "You have passed the auth check". The handler runs only when the middleware allows the request.

6

Start the Next.js app​

Create a .env.local file in the project root, replacing <YOUR_API_KEY> with your API key:

PERMIT_API_KEY=<YOUR_API_KEY>
PDP_URL=http://localhost:7766

Start the development server:

npm run dev

The app listens on http://localhost:3000.

5. Test the permission check​

Register two users, give one of them the Author role, and confirm that the PDP allows only that user through the protected route.

1

Register two users​

In a second terminal, register John and Emma:

curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"email": "john@example.com", "first_name": "John", "last_name": "Doe"}'

curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"email": "emma@example.com", "first_name": "Emma", "last_name": "Den"}'

Each request returns HTTP 201 with "message": "User registered and role assigned", the synced user under user, and the role assignment under response, with "role": "Reader" and "tenant": "default".

Terminal showing curl requests to /api/register for Emma and John, each returning the synced user and a Reader role assignment

2

Assign John the Author role​

Both users have the Reader role, which can't create posts. Give John the Author role in the Permit dashboard:

  1. Open the Directory screen and select john@example.com to open the Edit User panel.
  2. Under Permissions Per Tenant, select the Default Tenant.
  3. In Top Level Access, add the Author role.
  4. Click Save.

Edit User panel in the Permit Directory with Reader and Author roles under Top Level Access for john@example.com

For other ways to assign roles, including the API and SDK, see Sync users.

3

Check that John can access the route and Emma can't​

Send a request to the protected route as John:

curl http://localhost:3000/api/protected/posts \
-H "user: john@example.com"

The PDP allows the request because John has the Author role. The route returns {"message":"You have passed the auth check"}.

Send the same request as Emma:

curl http://localhost:3000/api/protected/posts \
-H "user: emma@example.com"

The PDP denies the request because Emma has only the Reader role. The middleware returns HTTP 403 with {"message":"You are not authorized to access this resource"}.

Terminal showing a curl request to /api/protected/posts for emma@example.com returning You are not authorized to access this resource

Each check also appears in the Audit Log screen of the Permit dashboard, with the user, action, resource, and decision.

6. Scope permissions to the user's organization​

The middleware in step 4 reads the user from a request header, which is useful for a local test. In a real app, read the user and the active organization from a verified session. This section shows route handlers that use the organization as the Permit tenant, so a user who is an admin in one organization and a reader in another gets the right answer in each. For the model behind this, see Multi-tenant authorization.

1

Read the user and organization from the session​

Install jose, a JWT library:

npm install jose

Create src/lib/session.ts. The example verifies a JWT from the Authorization header and reads the user from sub and the organization from an org_id claim. Change the claim name to match your identity provider, or read the session from your auth library:

// src/lib/session.ts
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

// Returns the verified user ID and organization ID, or null
export async function getSession(req: Request) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
if (!payload.sub || typeof payload.org_id !== "string") return null;
return { userId: payload.sub, orgId: payload.org_id };
} catch {
return null;
}
}
2

Create a tenant for each organization​

Create src/lib/orgs.ts. Call createOrg() when your app creates an organization, and addMember() when a user joins it. The role is a top-level role such as Admin, Author, or Reader, assigned in that organization's tenant only:

// src/lib/orgs.ts
import { permit } from "./permit";

// Call when your app creates an organization
export async function createOrg(orgId: string, orgName: string) {
await permit.api.tenants.create({ key: orgId, name: orgName });
}

// Call when a user joins an organization
export async function addMember(userId: string, email: string, orgId: string, role: string) {
await permit.api.users.sync({ key: userId, email });
await permit.api.roleAssignments.assign({ user: userId, role, tenant: orgId });
}

Use the same value for userId as the sub claim in the user's token. For a user who belongs to two organizations, call addMember() once for each organization with a different role.

3

Check the tenant when a post is created​

Create src/app/api/posts/route.ts. The handler checks create in the user's organization, registers the post as a resource instance in the same tenant, and makes the creator the Author of that post:

// src/app/api/posts/route.ts
import { randomUUID } from "node:crypto";
import { getSession } from "@/lib/session";
import { permit } from "@/lib/permit";

export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });

const allowed = await permit.check(session.userId, "create", {
type: "Post",
tenant: session.orgId,
});
if (!allowed) return Response.json({ error: "Forbidden" }, { status: 403 });

const id = randomUUID();
await permit.api.resourceInstances.create({ key: id, resource: "Post", tenant: session.orgId });
await permit.api.roleAssignments.assign({
user: session.userId,
role: "Author",
resource_instance: `Post:${id}`,
tenant: session.orgId,
});
return Response.json({ id }, { status: 201 });
}
4

Check the tenant and the post when it is edited​

Create src/app/api/posts/[id]/route.ts. In Next.js 15, params is a promise, so the handler awaits it. The check names the post and the organization, so a user from another organization is denied even if the post ID is known:

// src/app/api/posts/[id]/route.ts
import { getSession } from "@/lib/session";
import { permit } from "@/lib/permit";

export async function PUT(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession(req);
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });

const { id } = await params;
const permitted = await permit.check(session.userId, "update", {
type: "Post",
key: id,
tenant: session.orgId,
});
if (!permitted) return Response.json({ error: "Forbidden" }, { status: 403 });

// ... update the post in your database
return Response.json({ message: `Post ${id} updated` });
}

The post must exist as a resource instance in the same tenant as the check. Create the instance when your app creates the post, as the POST handler does.

5

Test with a signed token​

Set JWT_SECRET in the environment of the Next.js app, restart it, and sign a token for a user in an organization. Run this command in the project directory:

export JWT_SECRET=$(openssl rand -hex 32)
node --input-type=module -e "import { SignJWT } from 'jose'; console.log(await new SignJWT({ org_id: 'acme' }).setProtectedHeader({ alg: 'HS256' }).setSubject('john@example.com').setExpirationTime('1h').sign(new TextEncoder().encode(process.env.JWT_SECRET)))"

Before you call the routes, create the acme tenant with createOrg('acme', 'Acme') and assign john@example.com a role with addMember(). Send the token as Authorization: Bearer <TOKEN> to POST /api/posts. A token with a different org_id that has no role in that tenant gets 403.

Server Actions run on the server, and a client can call them directly. Call getSession-style code and permit.check() inside each action, as you do in a route handler, and don't rely on the page that renders the button.

Frequently asked questions​

How do I make sure users can only edit resources that belong to their organization in Next.js?​

Map each organization to a Permit tenant, put the organization ID in the verified session, and pass it as the tenant in every permit.check() call. For a single post, also pass the post ID as the resource key. Section 6 shows the route handlers.

Which authentication providers work with this setup?​

Any provider that gives your route handlers a verified user ID and the active organization ID, such as Clerk, Auth.js, or Supabase Auth. Permit doesn't issue tokens or manage passwords. Read the session with your provider's helper instead of getSession(), and pass the same user ID to Permit.

Can a user have different roles in different organizations?​

Yes. A role assignment belongs to one tenant, so a user can be an admin in one organization and a viewer in another. A check with tenant: orgId uses only the roles in that tenant.

Should I check permissions in middleware or in route handlers?​

Check in the route handler or Server Action that touches the data, because that code knows the resource. Middleware, as in step 4, fits a coarse gate on a URL prefix. Middleware that calls permit.check() must run in the Node.js runtime, as the prerequisites describe.

Do I need to redeploy when a role changes?​

No. Change roles and permissions in the Policy Editor or the Directory. The next permit.check() call uses the updated policy.

What does the free tier include?​

The Community plan on the Permit.io pricing page lists MAU 1000, Tenants 20, Authorization Queries No Limit, and PDP Instances No Limit.

Next steps​