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
Step 1: Create a Next.js App with Clerk
Let's start with a fresh Next.js app and add Clerk authentication.
npx create-next-app@latest clerk-centrali-demo
cd clerk-centrali-demo
npm install @clerk/nextjs @centrali-io/centrali-sdkConfigure Clerk
Create a .env.local file with your Clerk keys (get these from the Clerk Dashboard):
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-upSet Up Clerk Provider
Update app/layout.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:
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:
- Go to Configure → Sessions → JWT Templates
- Click Add new template → Blank
- Name it
centrali
Configure the Claims:
{
"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, andapproval_limit
Set User Metadata
In Clerk Dashboard, go to Users → select a user → Edit metadata and add:
{
"role": "manager",
"department": "sales",
"approval_limit": 10000
}For a regular user, set:
{
"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:
CENTRALI_API_URL=https://api.centrali.io
CENTRALI_WORKSPACE=your-workspace-slugRegister Clerk as External Auth Provider
In Centrali Console → Settings → External Authentication Providers → Add Provider:
| Field | Value |
|---|---|
| Provider Name | Clerk |
| Provider Type | clerk |
| Issuer URL | https://your-clerk-domain.clerk.accounts.dev |
| Allowed Audiences | centrali |
Configure Claim Mappings
[
{ "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:
{
"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:
- Allows managers to approve any order
- Allows others to approve orders within their limit
- 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:
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:
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:
'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
- Start the app:
npm run dev - Sign in with a user who has
role: "employee"andapproval_limit: 1000 - Try to approve the $500 order → ✅ Works
- Try to approve the $5,000 order → ❌ Denied (exceeds limit)
- Sign in as a manager (
role: "manager") - Approve any order → ✅ Works
What Just Happened?
Let's trace the flow:
- User clicks "Approve" → Frontend calls
/api/orders/[id]/approve - API route checks authentication → Clerk's
auth()verifies the session - API route gets JWT →
getToken({ template: 'centrali' })returns a JWT with custom claims - API route checks authorization → Centrali validates the JWT and evaluates the policy
- Centrali returns decision → Based on
ext_roleandext_approval_limitvs the order amount - 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:
{
"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:
{
"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.