What is permissions-based security in Next.js?
Permissions-based security is three jobs that teams often collapse into one:
- Authentication answers who this caller is. A session, a JWT, or an auth vendor such as Clerk gives you a user ID.
- Authorization answers whether that identity may perform this action on this object. A policy engine such as Permit.io stores roles and relationships and returns allow or deny on
permit.check(userId, action, resource). - Runtime attack protection answers whether this request is abusive, automated, or an injection, and how fast this identity may call the route. That is Arcjet.
Arcjet does not replace Permit.io. Permit.io does not replace Arcjet. Authorization is a business-policy question. Runtime protection is a request-path question. You need both, plus authentication, before a Next.js Route Handler or server action is safe to run. What is runtime application security? is the definition of the third job.
What does Arcjet do, and what does Permit.io do?
| Arcjet | Permit.io | |
|---|---|---|
| Job | Runtime attack protection: Shield, bot detection, rate limiting, email validation | Authorization: RBAC, ABAC, and ReBAC policies evaluated as
|
| Typical question | Is this request an attack, a bot, or over the limit for this identity? | May this user |
| Where it runs |
| Your policy check, after you know the user ID, before you load or mutate the object |
| What it is not | Not an authorization engine. It does not store roles. | Not a WAF or rate limiter. A permitted user can still flood or inject. |
Role-based access control (RBAC) assigns permissions to roles (admin, reporter, member). Attribute-based access control (ABAC) adds user or environment attributes. Relationship-based access control (ReBAC) uses links such as "user owns document." The wiring below uses RBAC. The Arcjet side is the same if you later switch Permit.io to ABAC or ReBAC: you still get a boolean, and you still choose rules from it.
How do the three layers fit on one route?
Take a /api/stats Route Handler that returns pizza-topping counts and, for some users, accepts writes.
- Clerk (or any auth library) reads the session and gives you
user.id, or tells you the caller is anonymous. - Permit.io answers
permit.check(user.id, "update", "stats"). That boolean is the authorization result. You also use it to choose how strict the runtime rules should be. - Arcjet runs Shield on every caller, denies bots on anonymous traffic, and applies a sliding window keyed on
userIdfor members. Admins who may update stats skip the tight limit and still run Shield.
Guests get the tightest limit and bot detection. Members get a higher limit. Users with update on stats get Shield only. The policy lives in Permit.io. The attack and abuse controls live in Arcjet. Neither file is a screenshot of a vendor console. If you recreate this, the Permit.io policy editor is where you add an Admin / Reporter / Member role, a stats resource with read / create / update / delete, and the grants (everyone can read, reporters can create, admins can update and delete). The Clerk dashboard is where you copy the user ID you first sync into Permit.io as the user key.
Sync new users into Permit.io from a Clerk webhook or from your signup path so you are not pasting IDs by hand after the first test account.
How do you check a permission?
Create a Permit.io project, install permitio, and set PERMIT_TOKEN and PERMIT_PDP (for example https://cloudpdp.api.permit.io). Then expose a small endpoint that returns the boolean the UI needs. The UI should not guess roles from Clerk metadata.
import { NextResponse } from "next/server";import { currentUser } from "@clerk/nextjs/server";import { Permit } from "permitio";import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";
const permit = new Permit({ pdp: process.env.PERMIT_PDP!, token: process.env.PERMIT_TOKEN!,});
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] }), slidingWindow({ mode: "LIVE", interval: 60, max: 20 }), ],});
export async function GET(req: Request) { const decision = await aj.protect(req);
if (decision.isDenied()) { if (decision.reason.isRateLimit()) { return NextResponse.json({ error: "Too Many Requests" }, { status: 429 }); } return NextResponse.json({ error: "Forbidden" }, { status: 403 }); }
const user = await currentUser(); if (!user) { return NextResponse.json({ canUpdate: false }); }
const canUpdate = await permit.check(user.id, "update", "stats"); return NextResponse.json({ canUpdate });}This route is itself a public API. Shield, bot detection, and a sliding window belong on it so a client cannot scrape permissions as a denial-of-service. detectBot takes allow or deny, never a legacy block list. allow: [] denies every detected bot.
This client declares no characteristics, so the limit is keyed on ip.src, the default. That matters here. If you declare characteristics: ["userId"] and then pass userId: user?.id ?? "anonymous", every signed-out visitor shares the single fingerprint "anonymous", so one client can exhaust the whole guest budget and lock out everyone else. Key anonymous traffic on IP and reserve a userId characteristic for routes where you know there is a user, which is what the next section does.
Decide what happens when the PDP is unreachable. permit.check throws on a network failure, and an unhandled throw here returns a 500 that the UI will probably treat as "no permission" by accident rather than by design. Wrap it, log it, and return canUpdate: false deliberately. Authorization is the one dependency you fail closed on.
How do you vary Arcjet rules from a permission?
The pre-v1 fingerprint field is gone. Pass characteristic values into aj.protect(req, { userId }) instead.
You have two supported shapes for varying the rules themselves. Build one client per policy with arcjet({ rules: [...] }), which is what the rest of this section does, or keep one base client and add a rule per handler with withRule(). withRule() is current API, not a legacy chain, and it is what the SDK recommends when a route needs a rule the base client does not have:
const decision = await aj .withRule(slidingWindow({ mode: "LIVE", interval: 60, max: 5 })) .protect(req, { userId });Separate clients read better when the policies differ a lot, as they do below. withRule() reads better when one route needs a single extra rule. Rules built inside a handler are constructed on every request, so build as much as you can at module scope either way.
import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";import { currentUser } from "@clerk/nextjs/server";import { Permit } from "permitio";
const permit = new Permit({ pdp: process.env.PERMIT_PDP!, token: process.env.PERMIT_TOKEN!,});
const key = process.env.ARCJET_KEY!;
const ajGuest = arcjet({ key, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [] }), slidingWindow({ mode: "LIVE", interval: 60, max: 5 }), ],});
const ajMember = arcjet({ key, characteristics: ["userId"], rules: [ shield({ mode: "LIVE" }), slidingWindow({ mode: "LIVE", interval: 60, max: 10 }), ],});
const ajPrivileged = arcjet({ key, characteristics: ["userId"], rules: [shield({ mode: "LIVE" })],});
export async function GET(req: Request) { const user = await currentUser();
if (!user) { const decision = await ajGuest.protect(req); if (decision.isDenied()) { return Response.json({ error: "Forbidden" }, { status: 403 }); } return Response.json({ stats: [] }); }
const canUpdate = await permit.check(user.id, "update", "stats"); const aj = canUpdate ? ajPrivileged : ajMember; const decision = await aj.protect(req, { userId: user.id });
if (decision.isDenied()) { const status = decision.reason.isRateLimit() ? 429 : 403; return Response.json({ error: "Forbidden" }, { status }); }
// Load stats. Enforce canUpdate again before any write.}Guests share a limit on IP (ip.src is the default characteristic). Members share a limit on userId. Privileged users still run Shield so an admin session is not a free pass for injection. If you later add writes on this route, call permit.check again on create / update / delete immediately before the mutation. A GET that returned canUpdate: true is not a capability token.
Where else do you enforce the same split?
Server actions are public POST endpoints. Put aj.protect(req) and permit.check inside the action, not only in middleware. Next.js server action security covers the request-path half of that. The authorization half is the same check you use in a Route Handler.
Middleware can redirect anonymous users to sign-in. It cannot see Permit.io relationships or Arcjet characteristics unless you call those APIs there, and it has been bypassable. Keep the real deny in the handler.
Start Shield in DRY_RUN if you want to measure false positives, then switch that rule to LIVE. Keep rate limits in LIVE on public GETs that are cheap to flood.
How should you combine Arcjet and Permit.io?
Authenticate first so you have a stable user ID. Authorize with Permit.io so roles and relationships are not hardcoded in every route. Protect the request with Arcjet so a permitted user still cannot inject, scrape, or stampede the handler. Create separate arcjet({ rules: [...] }) clients for guest, member, and privileged traffic, and call aj.protect(req, { userId }). That is permissions-based security: policy in Permit.io, runtime attack protection in Arcjet, both in the function that is about to do the work.
Frequently asked questions
What is permissions-based security in Next.js?
Three jobs: authentication (who), authorization (may this identity do this action on this object), and runtime attack protection (is this request abusive or an injection). Arcjet does the third. Permit.io does the second.
Does Arcjet replace Permit.io?
No. Arcjet is Shield, bots, and rate limits. Permit.io is RBAC, ABAC, and ReBAC. A permitted user can still flood or inject. A clean Arcjet decision is not an authorization grant.
How do you key an Arcjet limit on a user in v1?
Set characteristics: ["userId"] on the client and pass { userId } into aj.protect(req, { userId }). Do not pass a pre-v1 fingerprint field.
How do guests, members, and admins get different limits?
Build one arcjet({ rules: [...] }) client per policy. Guests get Shield, detectBot({ allow: [] }), and a tight sliding window. Members get a higher user-keyed window. Privileged users still run Shield.
Can you put the Permit.io check only in middleware?
No. Middleware is a convenient redirect, not the authorization system, and it has been bypassable. Call permit.check and aj.protect in the Route Handler or server action.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.