Skip to main content
Technology & EngineeringSecurity Ratelimit343 lines

Arcjet

Rate limiting, bot detection, email validation, and shield attack protection using Arcjet with Next.js middleware and stacking rules

Quick Summary29 lines
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 lines
Paste into your CLAUDE.md or agent config

Arcjet: 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_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.

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 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.
  • 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

Get CLI access →