Skip to content
Security Intermediate Tutorial

Add Rate Limiting and Bot Protection to a Node.js API with Arcjet

Drop one Arcjet middleware into Express to rate-limit abusive IPs and block scrapers.

Emeka Okafor
Emeka Okafor
Security Editor · Aug 31, 2026 · 5 min read
Add Rate Limiting and Bot Protection to a Node.js API with Arcjet

What you'll build

An Express API protected by Arcjet: per-IP rate limiting, bot detection that blocks scrapers while letting search engines through, and a WAF layer against SQL injection and XSS. One middleware function, about 40 lines, and no Redis cluster to babysit because Arcjet's cloud tracks the counters.

Prerequisites

  • Node.js 22.21.0 or newer on the 22 line, or 24.5.0+. Not 23.x. @arcjet/node 1.11.0 declares engines: ">=22.21.0 <23 || >=24.5.0", and if your Node doesn't satisfy that range, npm quietly resolves an older SDK release instead (see Troubleshooting).
  • A free Arcjet account. Sign up at console.arcjet.com; no credit card needed.
  • Arcjet is ESM-only. CommonJS require() won't work, so the project below sets "type": "module".

Verified against @arcjet/node 1.11.0, @arcjet/inspect 1.11.0, and Express 5.2.1 on macOS.

1. Create the project

mkdir arcjet-demo && cd arcjet-demo
npm init -y
npm pkg set type=module
npm install @arcjet/node@1.11.0 @arcjet/inspect@1.11.0 express@5

@arcjet/inspect is optional but small; it adds helpers for catching clients that fake a Googlebot user agent.

2. Get an Arcjet key

In the Arcjet console, create a site and copy its key from the SDK installation tab. It starts with ajkey_.

Create .env.local in the project root:

ARCJET_KEY=ajkey_yourkeyhere
ARCJET_ENV=development

ARCJET_ENV=development tells the SDK to accept private addresses like 127.0.0.1 as the client IP, which every local request has. Without it, requests fail fingerprinting. Remove it in production so real client IPs are enforced.

3. Write the middleware

Create index.js:

import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/node";
import { isSpoofedBot } from "@arcjet/inspect";
import express from "express";

const app = express();
const port = 3000;

const aj = arcjet({
  key: process.env.ARCJET_KEY,
  rules: [
    // Shield blocks common attacks like SQL injection and XSS
    shield({ mode: "LIVE" }),
    // Block all bots except search engine crawlers and link previews
    detectBot({
      mode: "LIVE", // "DRY_RUN" logs decisions without blocking
      allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"],
    }),
    // Max 5 requests per 10 seconds per IP
    slidingWindow({
      mode: "LIVE",
      interval: "10s",
      max: 5,
    }),
  ],
});

// Run Arcjet on every request before it reaches a route
app.use(async (req, res, next) => {
  const decision = await aj.protect(req);

  if (decision.isDenied()) {
    if (decision.reason.isRateLimit()) {
      return res.status(429).json({ error: "Too many requests" });
    }
    if (decision.reason.isBot()) {
      return res.status(403).json({ error: "Bots are not allowed" });
    }
    return res.status(403).json({ error: "Forbidden" });
  }

  // Catch clients pretending to be Googlebot etc.
  if (decision.results.some(isSpoofedBot)) {
    return res.status(403).json({ error: "Bot spoofing detected" });
  }

  next();
});

app.get("/api/hello", (req, res) => {
  res.json({ message: "hello" });
});

app.listen(port, () => {
  console.log(`API listening on http://localhost:${port}`);
});

Shield and bot detection run inside the SDK's WebAssembly analyzer on your server, so they add near-zero latency. The sliding window counter lives in Arcjet's cloud, keyed by client IP by default; a sliding window avoids the burst-at-the-boundary problem fixed windows have. If the Arcjet API is unreachable the SDK fails open and allows the request, which is the behavior you want from a third-party dependency in the hot path.

The full list of bot categories and individual bots you can allow or deny is at arcjet.com/bot-list.

4. Start the API

Node 20.6+ reads env files natively, so you don't need dotenv:

node --env-file .env.local index.js

Expected output:

✦Aj WARN Arcjet will use 127.0.0.1 when missing public IP address in development mode
API listening on http://localhost:3000

The warning is normal in development mode.

Verify it works

Test the rate limit first and the bot block second. Deny decisions are cached against your IP fingerprint for up to 60 seconds, and in development every local request shares the 127.0.0.1 fingerprint, so a curl bot-block bleeds into your next test if you run them in the other order.

Send 7 quick requests with a browser user agent (plain curl would trip the bot rule instead):

for i in $(seq 1 7); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" \
    http://localhost:3000/api/hello
done

Expected: five 200s, then 429 for the rest of the 10-second window.

200
200
200
200
200
429
429

Now the bot rule. Wait 15 seconds for the window to reset, then hit the API with curl's default user agent:

curl -s -w "\n%{http_code}\n" http://localhost:3000/api/hello

Expected:

{"error":"Bots are not allowed"}
403

Open http://localhost:3000/api/hello in a real browser and you'll get {"message":"hello"}. Every request also shows up in the Arcjet console under your site, with the rule that matched and why, which is where you'll debug rules later.

Troubleshooting

Everything returns 403 after you tested with curl. The bot deny was cached for 60 seconds against your IP fingerprint, and locally all requests share it, so even browser requests get the cached deny. Wait a minute or restart the server; the cache is in-process memory.

✦Aj ERROR [unauthenticated] invalid key (or decision.conclusion is "ERROR" with [unauthenticated] unauthorized). Your ARCJET_KEY is wrong or empty. Copy it again from the site's SDK installation tab in the console; it must start with ajkey_. Note the SDK fails open here: requests are allowed while the key is bad, so bot blocking appears to work (it's local) but rate limiting silently doesn't.

generateFingerprint: ip is empty or [failed_precondition] client IP not provided. You're running locally without ARCJET_ENV=development, so the SDK refuses 127.0.0.1 as a client IP. Add it to .env.local.

npm installed @arcjet/node 1.5.0 instead of 1.11.0. Your Node version doesn't satisfy the SDK's engines range, and npm resolves the newest version that claims support for your runtime instead of the actual latest. node --version should print 22.21.0+ (below 23) or 24.5.0+; upgrade, delete node_modules and package-lock.json, and reinstall.

Next steps

The sliding window here limits by IP, which is right for anonymous traffic. For authenticated APIs, add characteristics: ["userId"] to the rule and pass userId into aj.protect(req, { userId }) so each user gets their own bucket; the rate limiting reference covers this plus the token bucket and fixed window algorithms. Before enforcing in production, flip rules to mode: "DRY_RUN" and watch decisions in the console to see what would have been blocked. The Node.js SDK reference documents the other protections in the same SDK: email validation, sensitive-data redaction, and signup form protection.

Sources & further reading

  1. Arcjet get started (Node.js + Express) — docs.arcjet.com
  2. Rate limiting reference — docs.arcjet.com
  3. Arcjet troubleshooting — docs.arcjet.com
  4. Official Arcjet Express.js example — github.com
  5. @arcjet/node on npm — npmjs.com
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 6

Join the discussion

Sign in or create an account to comment and vote.

Nina Petrova @night_owl_nina · 1 day ago

did the redis cluster thing for rate limiting on a side project and it genuinely felt like overkill until it didn't—suddenly you're debugging connection pool exhaustion at 2am because a single endpoint got hammered. this middleware-in-a-box approach would've saved me a week of that particular pain, though i'm curious how their cloud-side counter actually handles clock skew across regions without breaking.

Maya Ito @opensource_maya · 2 days ago

the 'no redis cluster to babysit' part is real — i spent weeks debugging distributed rate limit state across services before switching to a managed solution. that said, i'd want to audit what data arcjet holds and their data retention policy before throwing it on prod. last thing you need is a rate-limit service that goes dark and suddenly your legit users are locked out.

Sofia Jensen @sofia_jensen · 3 days ago

does arcjet's bot detection actually distinguish between different scraper types, or is it more of a blanket block? asking because i've had to whitelist legit data aggregators before and don't want to trade one headache for another.

Hal Mercer @greybeard_unix · 2 days ago

yeah, that's the real question nobody asks until it's 2am and your b2b partner's crawler is blocked. the article glosses over tuning—their "lets search engines through" is doing heavy lifting that probably depends on how you configure it, and i'd bet good money the out-of-the-box rules aren't granular enough for most real traffic patterns. you'll end up maintaining an allowlist anyway, just now it's offloaded to arcjet instead of your own middleware.

Lena Vogel @lowlevel_lena · 2 days ago

arcjet's detection is rules-based, not ML-fuzzy. you get granular controls per bot class—search engines, scrapers, ai crawlers—so whitelisting specific ones is straightforward. still beats managing that yourself in middleware.

Tobias Lindqvist @securepaws · 3 days ago

so if arcjet's cloud is tracking rate limit counters, what happens to that state if their service goes down or has latency spikes—do you get a local fallback or does the entire rate limiting just fail open and hammer your backend?

Related Reading