← Back to all posts
15 min readCentrali Team

Add Fine-Grained Authorization to Your Next.js App with Centrali

Learn how to add policy-based authorization to your Next.js application using Centrali. Works with any identity provider—this tutorial uses Clerk as an example.

TutorialIntegrationGuides

Authentication tells you who a user is. Authorization tells you what they can do. Most applications need both, but they're often conflated into a single system—leading to rigid, hard-to-maintain permission logic scattered throughout your codebase.

Centrali solves the authorization problem. It lets you define policies that determine who can do what, then makes those decisions at runtime—keeping your codebase clean and your permissions auditable.

In this tutorial, we'll add Centrali authorization to a Next.js application. We'll use Clerk* as our identity provider, but Centrali works with any OIDC-compliant IdP (Auth0, Okta, Keycloak, etc.).

By the end, you'll have:

  • Policy-based authorization powered by Centrali
  • Clean separation between authentication and authorization
  • No permission logic scattered in your code—just policy checks

What We're Building

A simple order approval system where:

  • Managers can approve orders of any amount
  • Regular users can only approve orders under $1,000
  • Authorization decisions are made by Centrali using claims from Clerk's JWT

Prerequisites

  • Node.js 18+
  • A Clerk account (free tier works)
  • A Centrali workspace

Step 1: Create a Next.js App with Clerk

Let's start with a fresh Next.js app and add Clerk authentication.

bash
npx create-next-app@latest clerk-centrali-demo cd clerk-centrali-demo npm install @clerk/nextjs @centrali-io/centrali-sdk

Configure Clerk

Create a .env.local file with your Clerk keys (get these from the Clerk Dashboard):

bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

Set Up Clerk Provider

Update app/layout.tsx:

tsx
import { ClerkProvider, SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/nextjs'; import './globals.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <ClerkProvider> <html lang="en"> <body> <header className="flex justify-between items-center p-4 border-b"> <h1 className="font-bold">Order Approval System</h1> <div className="flex gap-4"> <SignedOut> <SignInButton /> </SignedOut> <SignedIn> <UserButton /> </SignedIn> </div> </header> {children} </body> </html> </ClerkProvider> ); }

Add Clerk Middleware

Create middleware.ts in your project root:

typescript
import { clerkMiddleware } from '@clerk/nextjs/server'; export default clerkMiddleware(); export const config = { matcher: [ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', '/(api|trpc)(.*)', ], };

At this point, you have Clerk authentication working. Users can sign in and sign up.

Step 2: Create a JWT Template in Clerk

Here's where it gets interesting. Clerk lets you create JWT templates that include custom claims. These claims will be used by Centrali for authorization decisions.

In Clerk Dashboard:

  1. Go to Configure → Sessions → JWT Templates
  2. Click Add new templateBlank
  3. Name it centrali

Configure the Claims:

json
{ "aud": "centrali", "name": "{{user.full_name}}", "email": "{{user.primary_email_address}}", "role": "{{user.public_metadata.role}}", "department": "{{user.public_metadata.department}}", "approval_limit": "{{user.public_metadata.approval_limit}}" }

This template:

  • Sets the audience to centrali (for validation)
  • Includes standard user info (name, email)
  • Pulls custom metadata: role, department, and approval_limit

Set User Metadata

In Clerk Dashboard, go to Users → select a user → Edit metadata and add:

json
{ "role": "manager", "department": "sales", "approval_limit": 10000 }

For a regular user, set:

json
{ "role": "employee", "department": "sales", "approval_limit": 1000 }

Step 3: Configure Centrali

Now let's set up Centrali to accept Clerk tokens and make authorization decisions.

Add Centrali Environment Variables

Add to .env.local:

bash
CENTRALI_API_URL=https://api.centrali.io CENTRALI_WORKSPACE=your-workspace-slug

Register Clerk as External Auth Provider

In Centrali Console → Settings → External Authentication ProvidersAdd Provider:

FieldValue
Provider NameClerk
Provider Typeclerk
Issuer URLhttps://your-clerk-domain.clerk.accounts.dev
Allowed Audiencescentrali

Configure Claim Mappings

json
[ { "jwtPath": "role", "attribute": "role", "required": false, "defaultValue": "employee" }, { "jwtPath": "department", "attribute": "department", "required": false }, { "jwtPath": "approval_limit", "attribute": "approval_limit", "required": false, "defaultValue": 0 } ]

Create Authorization Policy

Create a policy called order_approval:

json
{ "name": "order_approval", "specification": { "rules": [ { "rule_id": "manager-unlimited", "effect": "Allow", "conditions": [ { "function": "string_equal", "attribute": "ext_role", "value": "manager" } ] }, { "rule_id": "within-limit", "effect": "Allow", "conditions": [ { "function": "integer_less_equal", "attribute": "request_metadata", "metadata_key": "amount", "value_from_attribute": "ext_approval_limit" } ] } ], "default": { "rule_id": "deny", "effect": "Deny" } } }

This policy:

  1. Allows managers to approve any order
  2. Allows others to approve orders within their limit
  3. Denies everything else

Step 4: Build the Authorization Check

Now let's wire it all together. Create an API route that checks authorization before approving orders.

Create the API Route

Create app/api/orders/[id]/approve/route.ts:

typescript
import { auth } from '@clerk/nextjs/server'; import { CentraliSDK } from '@centrali-io/centrali-sdk'; import { NextRequest, NextResponse } from 'next/server'; const centrali = new CentraliSDK({ baseUrl: process.env.CENTRALI_API_URL!, workspaceId: process.env.CENTRALI_WORKSPACE!, }); // Mock database const orders: Record<string, { id: string; amount: number; status: string }> = { 'order-1': { id: 'order-1', amount: 500, status: 'pending' }, 'order-2': { id: 'order-2', amount: 5000, status: 'pending' }, 'order-3': { id: 'order-3', amount: 15000, status: 'pending' }, }; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { // 1. Check authentication with Clerk const { userId, getToken } = await auth(); if (!userId) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } // 2. Get the order const order = orders[params.id]; if (!order) { return NextResponse.json({ error: 'Order not found' }, { status: 404 }); } // 3. Get Clerk JWT with custom template const token = await getToken({ template: 'centrali' }); if (!token) { return NextResponse.json({ error: 'Failed to get token' }, { status: 500 }); } // 4. Check authorization with Centrali const authResult = await centrali.checkAuthorization({ token, resource: 'orders', action: 'approve', context: { orderId: params.id, amount: order.amount, }, }); if (!authResult.data.allowed) { return NextResponse.json( { error: 'Not authorized to approve this order', reason: `Order amount (${order.amount}) exceeds your approval limit` }, { status: 403 } ); } // 5. Approve the order order.status = 'approved'; return NextResponse.json({ success: true, order, message: `Order ${params.id} approved successfully`, }); }

Create the Orders Page

Create app/page.tsx:

tsx
import { auth } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import { OrderList } from './components/OrderList'; export default async function Home() { const { userId } = await auth(); if (!userId) { redirect('/sign-in'); } return ( <main className="max-w-4xl mx-auto p-8"> <h1 className="text-2xl font-bold mb-8">Order Approval Dashboard</h1> <OrderList /> </main> ); }

Create the OrderList Component

Create app/components/OrderList.tsx:

tsx
'use client'; import { useState } from 'react'; const mockOrders = [ { id: 'order-1', amount: 500, status: 'pending', customer: 'Acme Corp' }, { id: 'order-2', amount: 5000, status: 'pending', customer: 'Globex Inc' }, { id: 'order-3', amount: 15000, status: 'pending', customer: 'Initech' }, ]; export function OrderList() { const [orders, setOrders] = useState(mockOrders); const [loading, setLoading] = useState<string | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); async function handleApprove(orderId: string) { setLoading(orderId); setMessage(null); try { const response = await fetch(`/api/orders/${orderId}/approve`, { method: 'POST', }); const data = await response.json(); if (response.ok) { setOrders(orders.map(o => o.id === orderId ? { ...o, status: 'approved' } : o )); setMessage({ type: 'success', text: data.message }); } else { setMessage({ type: 'error', text: data.reason || data.error }); } } catch (error) { setMessage({ type: 'error', text: 'Failed to approve order' }); } finally { setLoading(null); } } return ( <div className="space-y-4"> {message && ( <div className={`p-4 rounded ${message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}> {message.text} </div> )} <div className="border rounded-lg overflow-hidden"> <table className="w-full"> <thead className="bg-gray-50"> <tr> <th className="px-4 py-3 text-left">Order ID</th> <th className="px-4 py-3 text-left">Customer</th> <th className="px-4 py-3 text-right">Amount</th> <th className="px-4 py-3 text-left">Status</th> <th className="px-4 py-3 text-right">Action</th> </tr> </thead> <tbody> {orders.map((order) => ( <tr key={order.id} className="border-t"> <td className="px-4 py-3 font-mono text-sm">{order.id}</td> <td className="px-4 py-3">{order.customer}</td> <td className="px-4 py-3 text-right">${order.amount.toLocaleString()}</td> <td className="px-4 py-3"> <span className={`px-2 py-1 rounded text-sm ${ order.status === 'approved' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' }`}> {order.status} </span> </td> <td className="px-4 py-3 text-right"> {order.status === 'pending' && ( <button onClick={() => handleApprove(order.id)} disabled={loading === order.id} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" > {loading === order.id ? 'Approving...' : 'Approve'} </button> )} </td> </tr> ))} </tbody> </table> </div> </div> ); }

Step 5: Test It

  1. Start the app: npm run dev
  2. Sign in with a user who has role: "employee" and approval_limit: 1000
  3. Try to approve the $500 order → ✅ Works
  4. Try to approve the $5,000 order → ❌ Denied (exceeds limit)
  5. Sign in as a manager (role: "manager")
  6. Approve any order → ✅ Works

What Just Happened?

Let's trace the flow:

  1. User clicks "Approve" → Frontend calls /api/orders/[id]/approve
  2. API route checks authentication → Clerk's auth() verifies the session
  3. API route gets JWTgetToken({ template: 'centrali' }) returns a JWT with custom claims
  4. API route checks authorization → Centrali validates the JWT and evaluates the policy
  5. Centrali returns decision → Based on ext_role and ext_approval_limit vs the order amount
  6. API route acts on decision → Approves or denies the order

The key insight: authorization logic lives in Centrali policies, not in your code. To change who can approve what, you update the policy—no code deployment needed.

Going Further

Dynamic Policies

Want to add a rule that finance department can approve orders over $10,000? Update the policy:

json
{ "rule_id": "finance-high-value", "effect": "Allow", "conditions": [ { "function": "string_equal", "attribute": "ext_department", "value": "finance" }, { "function": "integer_greater_than", "attribute": "request_metadata", "metadata_key": "amount", "value": 10000 } ] }

Time-Based Access

Restrict approvals to business hours:

json
{ "rule_id": "business-hours-only", "effect": "Allow", "conditions": [ { "function": "boolean_equal", "attribute": "is_weekend", "value": false }, { "function": "time_in_range", "attribute": "current_time", "value": { "start": "09:00:00", "end": "17:00:00" } } ] }

Audit Everything

Centrali logs every authorization decision. You can see who tried to approve what, when, and whether it was allowed—without adding logging code to your app.

Why This Architecture?

Separation of concerns: Clerk handles the complex world of authentication (passwords, MFA, social login, session management). Centrali handles the complex world of authorization (policies, attributes, decisions). Your app focuses on business logic.

Flexibility: Change authorization rules without deploying code. Add new roles, adjust limits, restrict by time—all through policy updates.

Auditability: Every decision is logged. When the auditor asks "who approved this order?", you have the answer.

Scalability: As your app grows, your authorization logic stays manageable. No more sprawling if/else statements checking permissions.

Conclusion

Centrali gives you powerful, policy-based authorization without the complexity:

  • Keep your existing IdP — No need to migrate users or change your auth flow
  • Policies, not code — Define rules declaratively, change them without deployments
  • Full audit trail — Every authorization decision is logged and queryable
  • Works with any IdP — Clerk, Auth0, Okta, Keycloak, or any OIDC provider

The pattern is simple: your IdP issues JWTs with claims, Centrali validates them and makes authorization decisions based on your policies. Your application code stays clean.

Ready to add policy-based authorization to your app? Sign up for Centrali and check out our External Authentication Guide for setup instructions with any identity provider.


*Clerk is a trademark of Clerk, Inc. This tutorial demonstrates Centrali's integration capabilities and is not affiliated with or endorsed by Clerk, Inc. Centrali works with any OIDC-compliant identity provider.

Building something with Centrali and want to share feedback about this feature?

Email feedback@centrali.io