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.
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/node1.11.0 declaresengines: ">=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
- Arcjet get started (Node.js + Express) — docs.arcjet.com
- Rate limiting reference — docs.arcjet.com
- Arcjet troubleshooting — docs.arcjet.com
- Official Arcjet Express.js example — github.com
- @arcjet/node on npm — npmjs.com
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
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.
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.
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.
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.
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.
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?