Security · Nov 6, 2026 · 10 min read

Policy-Based Authorization in Next.js with OPA and Rego

Learn to implement scalable, auditable authorization in Next.js using Open Policy Agent and Rego. Includes concrete policies for multi-tenancy, middleware integration patterns, WASM deployment, testin

Policy-Based Authorization in Next.js with OPA and Rego

Why Hardcoded Authorization Logic Becomes Untenable

As Next.js applications grow beyond basic role checks, developers encounter repeated problems. A simple if (user.role === "admin") in route handlers quickly multiplies across dozens of files. Requirements change: a new "billing" team needs read access to invoices but not refunds. Auditors request proof of who could access customer data last month. Compliance teams ask for a single source of truth instead of scattered if-statements.

Policy-based authorization solves these issues by moving decisions outside application code. Open Policy Agent (OPA) evaluates policies written in Rego, a declarative language designed for this purpose. The application supplies structured JSON context (user, resource, action, environment) and receives a yes/no decision or arbitrary computed data. This separation makes policies versionable, testable, and auditable independently of the Next.js codebase.

OPA is a CNCF graduated project used by Netflix, Capital One, and Pinterest. For indie developers and small teams, it removes the need to build custom permission engines while providing enterprise-grade capabilities without licensing costs.

Running OPA in Development and Production

Start by running OPA locally as a sidecar. The command below launches OPA in server mode with the latest stable release at the time of writing (0.68.0):

docker run -d \
  -p 8181:8181 \
  -v $(pwd)/policies:/policies \
  openpolicyagent/opa:0.68.0 \
  run --server --addr :8181 /policies

This mounts a local policies directory containing Rego files. OPA automatically reloads bundles or individual files when they change if you add the --watch flag during development.

For production Docker-based deployments, use a multi-stage build or run OPA as a separate container in your orchestration setup (Docker Compose, Kubernetes, or Fly.io machines). When using Next.js on Vercel, the recommended approach shifts to WebAssembly evaluation to stay within edge constraints.

Health checks are important. Query the /health endpoint to confirm OPA is ready before your Next.js application starts accepting traffic. Production configurations should enable authentication on the OPA API using bearer tokens or mutual TLS.

Writing Effective Rego Policies

Rego resembles a logic programming language. Here is a complete policy file that implements basic multi-tenant authorization for a SaaS application:

package authz

default allow = false

# Admin can do anything
allow if {
  input.user.role == "admin"
}

# Users can access their own tenant resources
allow if {
  input.user.tenant_id == input.resource.tenant_id
  permitted_action
}

permitted_action if {
  input.action == "read"
  input.resource.type == "invoice"
}

permitted_action if {
  input.action == "write"
  input.user.role == "manager"
  input.resource.type == "invoice"
}

# Deny access to other tenants even for admins unless explicitly allowed
deny_other_tenant if {
  input.user.tenant_id != input.resource.tenant_id
  not input.user.is_super_admin
}

Notice the use of default allow = false. This follows the principle of least privilege. Every decision must explicitly evaluate to true. The input object is supplied entirely by your Next.js application, giving you complete control over the context passed to the policy engine.

More advanced policies can compute dynamic values. For example, you can calculate maximum allowed file upload size based on the user's subscription tier directly in Rego instead of maintaining that logic in TypeScript.

Integrating OPA with Next.js Route Handlers

Create a dedicated authorization utility that queries OPA from server-side code. Install the required dependency:

npm install ky

Then implement a reusable function:

import ky from 'ky';

const OPA_URL = process.env.OPA_URL || 'http://localhost:8181';

export async function checkAuthz(input: Record): Promise {
  const response = await ky
    .post(`${OPA_URL}/v1/data/authz/allow`, {
      json: { input },
      timeout: 150,
    })
    .json<{ result: boolean }>();

  return response.result === true;
}

Use this helper inside App Router route handlers:

import { checkAuthz } from '@/lib/authz';
import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const user = { 
    id: 'usr_123', 
    role: 'manager', 
    tenant_id: 'tnt_abc' 
  };
  
  const resource = {
    type: 'invoice',
    id: 'inv_456',
    tenant_id: 'tnt_abc'
  };

  const allowed = await checkAuthz({
    user,
    action: 'read',
    resource,
    environment: { region: 'eu-1' }
  });

  if (!allowed) {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

  // proceed with business logic
  return Response.json({ data: 'sensitive invoice details' });
}

This pattern keeps route handlers clean while centralizing policy decisions. The environment key allows policies to consider time of day, IP ranges, or current threat level.

Using OPA Inside Next.js Middleware

Middleware runs on the edge, making HTTP calls to a central OPA instance undesirable. Two solutions exist: deploy OPA instances close to your edge functions (e.g., regional Fly.io machines) or use WebAssembly.

For the WASM approach, first compile your policies into a bundle:

opa build -t wasm -o policy.wasm policies/

Copy the resulting policy.wasm file into your Next.js project. Then load and evaluate it in middleware:

import { NextResponse } from 'next/server';
import { readFileSync } from 'fs';
import { instantiate } from './opa-wasm';

let opaInstance: any;

async function initOpa() {
  if (!opaInstance) {
    const wasmBuffer = readFileSync('./policy.wasm');
    opaInstance = await instantiate(wasmBuffer);
  }
  return opaInstance;
}

export async function middleware(request: NextRequest) {
  const instance = await initOpa();
  
  const input = {
    user: request.headers.get('x-user') 
      ? JSON.parse(request.headers.get('x-user')!) 
      : null,
    path: request.nextUrl.pathname,
    method: request.method,
  };

  const result = instance.evaluate(input);
  
  if (!result.allow) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  return NextResponse.next();
}

The WASM module must be initialized once and reused. Cold starts on Vercel Edge can add 20-40ms of latency on first request, so use edge runtime carefully and consider warming strategies.

Practical Policy Examples for Common SaaS Scenarios

Multi-tenancy is the most frequent use case. The policy below enforces that users can only list records belonging to their tenant and that support staff can only access tenants with an active support contract:

package authz

allow if {
  input.resource.type == "customer_record"
  input.user.tenant_id == input.resource.tenant_id
}

allow if {
  input.user.role == "support"
  input.resource.type == "customer_record"
  input.environment.support_contract_active == true
  input.action == "read"
}

Feature flags can be expressed as policies rather than configuration files. This allows gradual rollouts based on user attributes, geography, or even random sampling expressed in Rego.

Rate limiting decisions can also live in OPA. While Redis-based sliding windows remain faster for high-volume counting, OPA can decide which rate limit bucket applies to a user (free vs pro) and what the limit value should be.

Another powerful pattern is "just-in-time" access. A policy can check whether a temporary access token was issued within the last 15 minutes for a specific resource, enabling break-glass scenarios without code changes.

Testing Rego Policies Before Deployment

OPA includes a built-in test runner. Create a authz_test.rego file alongside your policies:

package authz

test_admin_can_access_anything {
  allow with input as {
    "user": {"role": "admin"},
    "action": "delete",
    "resource": {"type": "invoice", "tenant_id": "different"}
  }
}

test_regular_user_cannot_access_other_tenant {
  not allow with input as {
    "user": {"role": "user", "tenant_id": "t1"},
    "action": "read",
    "resource": {"type": "invoice", "tenant_id": "t2"}
  }
}

Run tests with:

opa test -v policies/

Integrate this command into your CI pipeline. Aim for 100% policy coverage on critical paths. OPA's test framework supports table-driven tests and mocking of external data sources via with statements.

Performance, Latency, and Caching Considerations

Each OPA evaluation over HTTP typically adds single-digit millisecond latency when the server is co-located. WASM evaluations are faster after initial load, often under 1ms for simple policies. Complex policies with many rules or external data lookups can exceed 10ms.

Caching decisions is effective when the same input context repeats. Implement a simple in-memory cache in your Next.js server with a short TTL (30 seconds) for authorization results. Be careful to include tenant_id and user_id in the cache key.

For high-traffic APIs, batch authorization decisions. Instead of checking each resource individually, send an array of resources to OPA and receive an array of decisions in one call. This reduces overhead significantly when rendering lists or dashboards.

Monitor decision latency using OpenTelemetry instrumentation on the OPA side and within your Next.js services. Set alerts when p95 decision time exceeds 15ms.

Combining OPA with NextAuth.js v5

NextAuth handles identity and session management. OPA handles authorization. The integration point is the session callback or a custom middleware that enriches the user object with roles and tenant information before passing it to OPA.

After successful NextAuth authentication, fetch additional attributes (tenant membership, subscription tier) from your database or Supabase and attach them to the JWT or session. These enriched claims become the input.user object sent to OPA.

Use separate policies for authentication (NextAuth) and authorization (OPA). This separation prevents mixing concerns and makes it easier to swap one without affecting the other. For example, you can migrate from NextAuth to Clerk while keeping all authorization logic intact.

Deployment Strategies for Different Hosting Platforms

On Vercel, the WASM approach is preferred because outbound connections from Edge Functions have cold-start penalties and cost implications. Bundle the compiled WebAssembly module with your application and load it at runtime.

On self-hosted infrastructure or Fly.io, run OPA as a sidecar container sharing the same virtual private cloud. This keeps latency under 2ms while allowing centralized policy management. Use OPA's bundle feature to distribute updated policies without restarting services.

Version policies by including a policy_version field in every decision log. This enables forensic analysis if a policy change causes unintended access. Store decision logs in a separate immutable store for compliance.

Alternatives and When OPA Might Be Overkill

Simple applications with fewer than five distinct roles can manage with a well-designed RBAC library such as next-auth combined with a permission matrix stored in a database. Casbin is another lightweight option with similar goals but a different policy language.

OPA shines when you have complex boolean logic, need to support multiple teams authoring policies, require auditability, or expect authorization rules to evolve independently of code releases. The learning curve for Rego is real—expect 2-3 days before writing fluent policies.

If your product is a consumer-facing mobile or web app with straightforward access rules, start with database-level row security (RLS in Supabase or PostgreSQL) before introducing OPA. Use OPA when business logic and compliance requirements exceed what database policies can express cleanly.

Observability and Decision Logging

Configure OPA to emit decision logs in JSON format. Each log entry contains the full input, resulting decision, and execution time. Forward these logs to your existing observability stack (Datadog, Loki, or Vercel Logs).

A useful pattern is to log every authorization decision during the first 30 days after launching a new policy, then reduce to sampling. This helps catch missing edge cases quickly.

Tools like OPA's opa inspect command help visualize policy dependencies. For larger policy sets, maintain a documentation site that explains each rule in plain English alongside the Rego implementation.

Regular policy reviews with both engineering and compliance stakeholders prevent policy bloat. Aim to keep the total lines of Rego under 800 for most startups. Split large policy files by domain (authz_tenant.rego, authz_billing.rego, authz_admin.rego).

Scaling Authorization as Your Startup Grows

The beauty of the OPA approach is that the same policies used in development work at 10 users or 10,000 users. As traffic increases, add more OPA instances behind a load balancer. Because policies are stateless and evaluation is CPU-bound rather than I/O bound, horizontal scaling is straightforward.

Consider partial evaluation for advanced use cases. OPA can return partially evaluated policies that your application can then apply to large datasets locally, reducing the need to send every record to the authorization service.

Finally, treat policies as code. Store them in the same Git repository as your Next.js application or in a dedicated policies repository with its own CI. Automated tests, semantic versioning of bundles, and canary releases of new policies are all possible once you adopt this architecture.

By moving authorization logic into Rego, Next.js teams gain clarity, auditability, and agility that traditional approaches cannot match. The initial investment in learning OPA pays dividends as product complexity grows.

Keep reading

Related Articles