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.
- If you don't have one already, create a new Next.js project,
- Sign up for a free Clerk account and create a new application,
- Install the necessary Clerk dependency:
npm install @clerk/nextjs
, and - Integrate Clerk into your Next.js project (follow the detailed instructions in the Clerk documentation).
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.
- Sign up for an free Permit.io account and create a new project,
- Install the Permit.io dependency:
npm install @permitio/permitio
, and -
Retrieve your
PERMIT_TOKEN
and add it to your.env.local
.
💡
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:
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:
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.
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.
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.
💡
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
.
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.
If you log out, you’ll see you’ll have the high rate limit applied:
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.
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.