Arcjet
Rate limiting, bot detection, email validation, and shield attack protection using Arcjet with Next.js middleware and stacking rules
Arcjet provides a unified security layer that runs at the edge and in your application. Rather than stitching together separate rate limiting, bot detection, and WAF services, Arcjet combines these into a single SDK with composable rules. Each rule is a building block — you stack them to define security policies per route, and every decision is returned as a structured result you can act on. Dry run mode lets you observe before enforcing, so you never block legitimate traffic by accident. ## Key Points - Start every new rule in `DRY_RUN` mode and monitor logs for a week before switching to `LIVE`. - Use `characteristics: ["userId"]` for authenticated endpoints so rate limits follow the user, not the IP. - Stack `shield` on every route — it is lightweight and catches common injection patterns early. - Return `retryAfter` from the decision so clients can implement proper backoff. - Keep email validation rules on signup and invite routes only; do not apply them globally. - Use granular bot allow-lists (`CATEGORY:SEARCH_ENGINE`) rather than blanket allows. - Combine a strict sliding window with a generous token bucket to allow bursts while capping sustained abuse. - **Blanket LIVE mode on day one.** You will inevitably block legitimate users. Always dry-run first. - **IP-only characteristics on APIs behind a CDN.** All requests may share a single IP; use a user or session identifier instead. - **Ignoring the decision object.** Just calling `aj.protect()` without checking `isDenied()` provides no protection. - **Stacking too many rules without understanding order.** Each rule is evaluated, and a single DENY from any rule triggers denial. Be intentional about which rules apply to which routes. - **Hardcoding ARCJET_KEY in source.** Always use environment variables; the key grants access to your Arcjet account. ## Quick Example ```typescript npm install @arcjet/next ``` ```typescript // .env.local ARCJET_KEY=ajkey_yourkey ```
skilldb get security-ratelimit-skills/ArcjetFull skill: 343 linesArcjet: Application Security Layer
Core Philosophy
Arcjet provides a unified security layer that runs at the edge and in your application. Rather than stitching together separate rate limiting, bot detection, and WAF services, Arcjet combines these into a single SDK with composable rules. Each rule is a building block — you stack them to define security policies per route, and every decision is returned as a structured result you can act on. Dry run mode lets you observe before enforcing, so you never block legitimate traffic by accident.
Setup
Installation
npm install @arcjet/next
Environment Configuration
// .env.local
ARCJET_KEY=ajkey_yourkey
Base Client Initialization
// lib/arcjet.ts
import arcjet, {
tokenBucket,
slidingWindow,
shield,
detectBot,
validateEmail,
} from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [],
});
export default aj;
Key Techniques
Token Bucket Rate Limiting
// app/api/generate/route.ts
import arcjet, { tokenBucket } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
tokenBucket({
mode: "LIVE",
refillRate: 5,
interval: "30s",
capacity: 15,
}),
],
});
export async function POST(req: NextRequest) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Rate limit exceeded", retryAfter: decision.reason.retryAfter },
{ status: 429 }
);
}
return NextResponse.json({ result: "ok" });
}
Sliding Window Rate Limiting
// app/api/auth/login/route.ts
import arcjet, { slidingWindow } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
slidingWindow({
mode: "LIVE",
interval: "15m",
max: 5,
}),
],
});
export async function POST(req: NextRequest) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Too many login attempts. Try again later." },
{ status: 429 }
);
}
// proceed with authentication
return NextResponse.json({ success: true });
}
Bot Detection
// middleware.ts
import arcjet, { detectBot } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
detectBot({
mode: "LIVE",
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:MONITOR"],
deny: ["CATEGORY:AI_SCRAPER"],
}),
],
});
export async function middleware(req: NextRequest) {
const decision = await aj.protect(req);
if (decision.isDenied() && decision.reason.isBot()) {
return NextResponse.json({ error: "Bot denied" }, { status: 403 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*"],
};
Email Validation
// app/api/signup/route.ts
import arcjet, { validateEmail } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
validateEmail({
mode: "LIVE",
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS"],
}),
],
});
export async function POST(req: NextRequest) {
const { email } = await req.json();
const decision = await aj.protect(req, { email });
if (decision.isDenied() && decision.reason.isEmail()) {
return NextResponse.json(
{ error: "Invalid email address", emailTypes: decision.reason.emailTypes },
{ status: 400 }
);
}
return NextResponse.json({ registered: true });
}
Shield (Attack Protection)
// app/api/data/route.ts
import arcjet, { shield } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({
mode: "LIVE",
}),
],
});
export async function POST(req: NextRequest) {
const decision = await aj.protect(req);
if (decision.isDenied() && decision.reason.isShield()) {
return NextResponse.json({ error: "Suspicious request blocked" }, { status: 403 });
}
return NextResponse.json({ data: "safe" });
}
Stacking Rules
// app/api/submit/route.ts
import arcjet, { shield, tokenBucket, detectBot, validateEmail } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
tokenBucket({
mode: "LIVE",
refillRate: 2,
interval: "60s",
capacity: 5,
}),
validateEmail({
mode: "LIVE",
block: ["DISPOSABLE", "INVALID"],
}),
],
});
export async function POST(req: NextRequest) {
const { email } = await req.json();
const decision = await aj.protect(req, { email });
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
}
if (decision.reason.isBot()) {
return NextResponse.json({ error: "Bot detected" }, { status: 403 });
}
if (decision.reason.isEmail()) {
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
}
return NextResponse.json({ error: "Request blocked" }, { status: 403 });
}
return NextResponse.json({ submitted: true });
}
Dry Run Mode
// Observe without enforcing — useful for rollout
import arcjet, { slidingWindow } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
slidingWindow({
mode: "DRY_RUN", // logs decisions, never blocks
interval: "1m",
max: 10,
}),
],
});
export async function POST(req: NextRequest) {
const decision = await aj.protect(req);
// decision.isDenied() can still be true, but mode is DRY_RUN
// so you log it instead of blocking
if (decision.isDenied()) {
console.warn("Would have blocked request", {
reason: decision.reason,
ip: decision.ip,
});
}
return NextResponse.json({ ok: true });
}
Per-User Rate Limiting
import arcjet, { tokenBucket } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["userId"],
rules: [
tokenBucket({
mode: "LIVE",
refillRate: 10,
interval: "1m",
capacity: 20,
}),
],
});
export async function POST(req: NextRequest) {
const session = await getSession(req);
const decision = await aj.protect(req, {
userId: session.userId,
});
if (decision.isDenied()) {
return NextResponse.json({ error: "Quota exceeded" }, { status: 429 });
}
return NextResponse.json({ ok: true });
}
Best Practices
- Start every new rule in
DRY_RUNmode and monitor logs for a week before switching toLIVE. - Use
characteristics: ["userId"]for authenticated endpoints so rate limits follow the user, not the IP. - Stack
shieldon every route — it is lightweight and catches common injection patterns early. - Return
retryAfterfrom the decision so clients can implement proper backoff. - Keep email validation rules on signup and invite routes only; do not apply them globally.
- Use granular bot allow-lists (
CATEGORY:SEARCH_ENGINE) rather than blanket allows. - Combine a strict sliding window with a generous token bucket to allow bursts while capping sustained abuse.
Anti-Patterns
- Blanket LIVE mode on day one. You will inevitably block legitimate users. Always dry-run first.
- IP-only characteristics on APIs behind a CDN. All requests may share a single IP; use a user or session identifier instead.
- Ignoring the decision object. Just calling
aj.protect()without checkingisDenied()provides no protection. - Stacking too many rules without understanding order. Each rule is evaluated, and a single DENY from any rule triggers denial. Be intentional about which rules apply to which routes.
- Hardcoding ARCJET_KEY in source. Always use environment variables; the key grants access to your Arcjet account.
- Using rate limiting as the only auth guard. Rate limiting slows attackers but does not replace authentication or authorization checks.
Install this skill directly: skilldb add security-ratelimit-skills
Related Skills
Cloudflare Turnstile
Privacy-preserving CAPTCHA alternative using Cloudflare Turnstile for bot protection with server-side verification in Next.js and Express
Security Headers
Security headers with Helmet.js, Content Security Policy, CORS configuration, CSRF protection, rate limiting patterns, and Next.js security headers
OWASP ZAP
Automated web application security testing, API scanning, and CI/CD DAST integration using OWASP ZAP
Snyk
Dependency vulnerability scanning, license compliance, and continuous security monitoring using Snyk CLI and CI/CD integrations
Svix
Webhook delivery infrastructure including sending webhooks, retry logic, signature verification, event types, consumer portal, and message logging with Svix
Unkey
API key management, rate limiting, usage tracking, key verification, temporary keys, ratelimit API, and analytics with Unkey