Updated
9 min read

Permissions-Based Security in Next.js Apps: A Practical Guide with Arcjet and Permit.io

Learn to enhance web app security using Arcjet and Permit.io. Implement dynamic permissions-based security with RBAC, ABAC, and ReBAC models

Permissions-Based Security in Next.js Apps: A Practical Guide with Arcjet and Permit.io

If you’re already using Arcjet for your web application security, you know how easy it is to protect your application against common attacks, implement rate-limiting, and detect and block bots. But did you know you can enhance these features by configuring them based on user permissions?

Perhaps you want your top tier customers to have a higher rate-limit, or enable enhanced bot detection for anonymous users. By integrating a permissions service such as Permit.io, you can create dynamic, permissions-based security rules that take your application's security to the next level.

In this article, we'll explore how to leverage Permit.io to implement advanced access control models like RBAC, ABAC, and ReBAC. Then, we'll dive into configuring Arcjet to apply rate-limiting and bot protection rules based on these permissions, giving you a flexible and robust security setup.

  • Role-Based Access Control (RBAC): Assigns permissions to users based on their roles within the organization (e.g., admin, user).
  • Attribute-Based Access Control (ABAC): Grants access based on user attributes (e.g., department, clearance level) and environmental conditions (e.g., time of day).
  • Relationship-Based Access Control (ReBAC): Uses relationships between entities (e.g., user owns resource) to determine access permissions.

We’ll be focussing on RBAC in this article, but the principles will apply equally to any other access control method.

Next.js App with Clerk

We'll be using Next.js for our web app because of its excellent developer experience. Clerk will handle our authentication needs, making it super easy to manage users and secure our app. This will provide a solid foundation for our access control implementation.

Our app will have two pages; the “home” page that’s accessible to all users, and a “stats” page that shows statistics about recent pizza toppings popularity.

Set up Next.js + Clerk

To get started, you'll need a Next.js application with Clerk authentication set up. If you prefer to check out some working code, our example repo includes a working example.

With your baseline code ready, we can now move on to configuring Permit.io for permissions-based access control and configuring Arcjet to enhance your app's security.

Setting Up Permit.io

Permit.io allows you to easily implement advanced access control models with your Next.js (or other stack) application, so you can manage permissions dynamically and enforce security policies that align with your business requirements.

💡
If you’re not using our example repo, you can reference Permit.io’s Quick Start documentation, and don’t forget to add PERMIT_PDP=https://cloudpdp.api.permit.io to your .env.local too.

Defining Roles, Resources, and Permissions

Think about the different types of users in your application. Common examples include "Admin," "Reporter," and "Member." Create these roles the Roles section of the Policy Editor:

Screenshot of Permit.io's Roles section in the Policy Editor with the New Role interface showing for the creation of an Admin role.

Identify the key parts of your app that you want to protect (e.g., access to statistics in our example). These become your "resources" in Permit.io. In the Resources section, create a stats resource with the read, create, update, and delete actions:

Screenshot of Permit.io's Resource section in the Policy Editor with the Edit Resource interface showing the values for a "stats" resource.

And then switch to the main Policy Editor section and configure the permissions. We want anyone to read the statistics, but only reporters and admins can “create” or report new data. Deleting and updating existing statistics should be limited to those with the admin role.

Screenshot of Permit.io's Policy Editor showing resource permissions assigned to the new roles.

Making Permissions Available to our Next.js App

In order for the front-end of our application to have access to a logged-in user’s permissions, we’re going to create a server-side /api/permissions endpoint to return the user’s update permission on the stats resource.

// File: /src/app/api/permissions/route.ts
import { NextResponse } from "next/server";
import { currentUser } from "@clerk/nextjs/server";
import { Permit } from "permitio";

const permit = new Permit({
  pdp: process.env.PERMIT_PDP!,
  token: process.env.PERMIT_TOKEN!,
});

export async function GET(req: Request) {
  const user = await currentUser();
  if (!user) {
    return NextResponse.json({ canUpdate: false });
  }
  const canUpdate = await permit.check(user.id, "update", "stats");
  return NextResponse.json({ canUpdate });
}

If you start your application with npm run dev and head to http://localhost:3000/api/permissions now, you should see the result:

{"canUpdate": false}

This endpoint retrieves the logged-in user's ID from Clerk. It then uses the Permit.io SDK to check if the user has the "update" permission on the "stats" resource. Finally, it returns a JSON response indicating whether the user has the permission.

Adding Users to Permit.io

After you’ve logged into your application, you should be able to see your user account in the Clerk Directory. Click that user account, and copy the User ID.

Screenshot of Clerk's Users area showing a list of users, and an overlay showing the User ID required for the next step.

With this User ID, head to the Permit.io User Management page, and create a new user, using the User ID as the Key. Provide an email address, and assign your user a role of Admin.

Screenshot of Permit.io's User Management section with the Create User interface showing. (This image has been edited for brevity and clarity to hide certain elements in the edit interface.)
💡
Note: You’ll probably want to have new users added to Permit.io automatically as they sign up. You can create the user in Permit.io either in middleware, or by having a Clerk Webhook call an API endpoint in your application.

If you head back to your application, and refresh the http://localhost:3000/api/permissions endpoint, you should see it now reads:

{"canUpdate": true}

Securing Your Application with Arcjet

Now that we have the permissions system in place, let’s secure the application with Arcjet, allowing for different rules for different types of users. We’ll protect all users from common attacks using Shield, and then rate limit the user to only be able to retrieve permission information twice a second.

  • If you don’t have one yet, sign up for your free Arcjet account,
  • Once registered, you’ll be prompted to create a new site and be given a key,
  • Install the necessary Arcjet dependency: npm install @arcjet/next, and
  • Copy the ARCJET_KEY from the SDK CONFIGURATION tab of your site and add it to your .env.local.
Screenshot of the SDK CONFIGURATION tab of an Arcjet site, with the ARCJET_KEY required for integration.

Define Rules and Protecting Requests

In this example, we will secure multiple routes, so it's best to use a singleton for our arcjet object. This will define the global configuration that will apply to all calls to the protect() method.

// File: /src/lib/arcjet.ts
import _arcjet, { shield } from "@arcjet/next";

export const arcjet = _arcjet({
  // Get your site key from https://app.arcjet.com
  // and set it as an environment variable rather than hard coding.
  // See: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
  key: process.env.ARCJET_KEY!,
  // Define a global characteristic that we can use to identify users
  characteristics: ["fingerprint"],
  // Define the global rules that we want to run on every request
  rules: [
    // Shield detects suspicious behavior, such as SQL injection and cross-site
    // scripting attacks. We want to run it on every request
    shield({
      mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
    }),
  ],
});

Now we can include this in any routes we want to protect with:

import { arcjet } from "@/lib/arcjet";

Extending Rules for the /api/permissions endpoint

This route will implement rate-limiting and bot detection, so let’s import those methods, as well as the arcjet object:

import { detectBot, slidingWindow } from "@arcjet/next";
import { arcjet } from "@/lib/arcjet";

We want to take the arcjet instance and use the withRule() method to extend the rules. As the same extra rules will always apply to this route, we'll set this up outside of the route hander, right after the import statements. Let's add a slidingWindow rate limiter and detectBot to detect automated bot clients.

const aj = arcjet
    // Add a sliding window to limit requests to 2 per second
    .withRule(slidingWindow({ mode: "LIVE", max: 2, interval: 1 }))
    // Add bot detection to block automated requests
    .withRule(detectBot({ mode: "LIVE", block: ["AUTOMATED"] }));

In the route handler, we can then protect the route by calling aj.protect().

  // Request a decision from Arcjet with the user's ID as a fingerprint
  const decision = await aj.protect(req, { fingerprint: user.id });

And finally, we’ll take the decision returned by protect() and return an error if appropriate:

  // If the decision is denied then return an error response
  if (decision.isDenied()) {
    if (decision.reason.isRateLimit()) {
      return NextResponse.json(
        { error: "Too Many Requests", reason: decision.reason },
        { status: 429 }
      );
    } else {
      return NextResponse.json(
        { error: "Suspicious Activity Detected", reason: decision.reason },
        { status: 403 }
      );
    }
  }

As you’ll see, it’s as easy as installing the SDK, defining the rules, and calling aj.protect() – after that, it’s just a matter of detecting a denied response.

In case it helps - here’s that full permissions route file contents.

Dynamic Arcjet Rules based on User Permissions

Applying one rate limit to the permissions end-point makes sense, because we need the permissions no matter what a user’s status. However, when it comes to accessing other types of resources, we might like to be a little more dynamic with our definitions.

In this example code, we have the ability to view statistics that come from an API end-point. It would be reasonable to provide, for example, limited access to guests, more lenient access for members, and unrestricted access for admins (or in our case, those who have update permissions on the stats).

Let’s look at the contents of /src/app/api/stats/route.ts and see what it’s doing. You can see the full file in our example repository, so we’ll just focus on the Arcjet-related code in this section.

Remember, when we instantiate arcjet(), we’re only providing the shield rule. This is because we want every request to be protected against common attacks. Now we need to dynamically add extra rules depending on the user’s permissions.

For this reason, we can't define it outside of the route handler, but to make the code more readable, we’ll abstract this away into a getClient() method in the same file. Let's start with unauthenticated users by including a rate-limiter that allows 5 requests per minute, and enabling bot detection.

async function getClient() {
  // If the user is not logged in then give them a low rate limit
  const user = await currentUser();
  if (!user) {
    return (
      arcjet
        // Add a sliding window to limit requests to 5 per minute
        .withRule(slidingWindow({ mode: "LIVE", max: 5, interval: 60 }))
        // Add bot detection to block automated requests
        .withRule(detectBot({ mode: "LIVE", block: ["AUTOMATED"] }))
    );
  }

If the user is logged in, then we ask Permit.io if the user has update permissions on the stats. If not, then we add a rate-limiter that allows 10 requests per minute.

  // If the user is logged in but does not have permission to update stats
  // then give them a medium rate limit.
  const canUpdate = await permit.check(user.id, "update", "stats");
  if (!canUpdate) {
    return (
      arcjet
        // Add a sliding window to limit requests to 10 per minute
        .withRule(slidingWindow({ mode: "LIVE", max: 10, interval: 60 }))
    );
  }

Otherwise, we allow logged in users that have permission to update the stats to continue with just the shield protection defined in the original configuration.

  // User is logged in and has permission to update stats,
  // so give them no rate limit
  return arcjet;
}

We can then use this method in the route handler to retrieve the correctly configured Arcjet object, and run the protect() method on that. As the configuration requires a fingerprint to define the current user, we’ll also calculate that:

  // Get the user's ID if they are logged in, otherwise use
  // their IP address as a fingerprint
  const user = await currentUser();
  const fingerprint: string = user ? user.id : req.ip!;

  // Get the Arcjet client and request a decision
  const aj = await getClient();
  const decision = await aj.protect(req, { fingerprint: fingerprint });

Everything In Action

Now that you’ve implemented the permissions and Arcjet security in your two API endpoints, you should be able to see the effect by navigating to the Stats page at http://localhost:3000/stats.

If you’re still logged in, and still have permission to update stats, you’ll see there is no rate limit for you.

Screenshot reads: Rate Limit Data. Your requests are not rate limited.

If you log out, you’ll see you’ll have the high rate limit applied:

Screenshot reads: Rate Limit Data. You are limited to 5 requests every 60 seconds. You have 3 requests remaining before you'll be rate limited.

Now log in as a new user, and set them up in Permit.io with a "Member" or "Reporter" role. When you reload the Stats page in the example code, you’ll see the low rate limit applied.

Screenshot reads: Rate Limit Data. You are limited to 10 requests every 60 seconds. You have 9 requests remaining before you'll be rate limited.

Summary

You’re now using Arcjet for your web application security, and have enhanced your application by configuring the security rules dynamically based on your users’ permissions as defined in Permit.io. This article shows how to use Role-Based Access Control (ABAC), but you could just as easily use more advanced access control models like Attribute-Based (ABAC) and Relationship-Based (ReBAC) Access Control.

Subscribe by email

Get the full posts by email every week.