protect() in middleware before the route runs. Combine Shield, detectBot allow or deny lists, and a production rate limit; treat fixedWindow({ max: 1 }) as a demo-only way to see a deny.How do you secure a Node.js/Express API?
Protect the request in the handler before business logic runs. Authentication, object-level authorization, and input validation stay in your code. Arcjet adds three request-path controls you can compose on the same protect() call: Shield for common web attacks, detectBot for automated clients, and a rate limit for abuse.
This guide assumes you already have an Express app. @arcjet/node declares >=22.21.0 <23 || >=24.5.0, so use Node.js 24 (Active LTS) or Node.js 22.21.0 or later; Node.js 20 went end-of-life in April 2026 and Node.js 23 is not supported. You also need Express 4 or 5 and an Arcjet site key in ARCJET_KEY. ESM ("type": "module") is required rather than recommended, because the package is ESM-only and cannot be required. TypeScript is optional. Skip scaffolding if the app already compiles and starts.
For more information about the broader API baseline, see API security best practices. For more information about algorithm choice, see rate limiting algorithms.
What do you install?
Pin the Node adapter at v1:
npm install @arcjet/node@1.10.0 expressCreate a .env.local file in the project root. Set ARCJET_ENV=development when Node does not already set NODE_ENV, and paste the site key from the Arcjet dashboard:
ARCJET_ENV=developmentARCJET_KEY=ajkey_your_site_keyNever commit the key. In development, start Node with --env-file .env.local. In production, inject ARCJET_KEY from the host secret store.
Which Arcjet rules belong on an Express API?
The following table lists the three rules you usually combine on a public API. Each answers a different question, so one rule does not replace another.
| Rule | What it decides | Typical deny status |
|---|---|---|
shield | Suspicious request patterns such as SQL injection, XSS, and path traversal in the path, query, and headers. Shield does not read the request body | HTTP 403 |
detectBot | Whether the client is a known bot or category you chose to allow or
deny. Pass exactly one of | HTTP 403 |
| Whether this identity has remaining capacity in the configured window | HTTP 429 |
mode is "LIVE" (enforce) or "DRY_RUN" (log only). Start new rules in dry run on a busy route, then switch to live one rule at a time.
detectBot takes exactly one of allow or deny. An allow list denies every detected bot that is not named. A deny list allows every detected bot that is not named. allow: [] is valid and blocks every detected bot. Do not use the older block: ["AUTOMATED"] option; it is not part of the v1 API.
Pass allow: ["CATEGORY:SEARCH_ENGINE"] on public content that search engines should index. Pass a deny list when you only want to block a few abusive families and leave other automation through. For more information about allow lists, verification, and spoofed user agents, see bot detection techniques.
How do you call protect() in Express middleware?
Create one client at module scope. Call protect() once per request, before the route handler. The following example uses Shield, an empty bot allow list, and a tight fixed window so you can see a deny without generating load.
import arcjet, { detectBot, fixedWindow, shield } from "@arcjet/node";import express from "express";
const app = express();
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ shield({ mode: "LIVE" }), detectBot({ mode: "LIVE", allow: [], }), // Demo-only: one request per minute per default characteristic (IP). // Do not ship this limit. Use a production window and max, or tokenBucket. fixedWindow({ mode: "LIVE", window: "1m", max: 1, }), ],});
app.use(async (req, res, next) => { const decision = await aj.protect(req);
if (decision.isErrored()) { // Default fail-open: log and continue. Fail closed on sensitive routes. console.error("Arcjet error", decision.reason.message); }
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: "Forbidden" }); } if (decision.reason.isShield()) { return res.status(403).json({ error: "Forbidden" }); } return res.status(403).json({ error: "Forbidden" }); }
return next();});
app.get("/", (_req, res) => { res.status(200).json({ message: "Hello World" });});
app.listen(3000);fixedWindow({ window: "1m", max: 1 }) is a demonstration so a second request in the same minute returns 429. A production API needs a limit that matches the resource: for example window: "1h" and max: 1000, or a sliding window of 100 requests per 60 seconds. A login or password-reset route should use a much smaller max than a public catalog.
The default characteristic is ip.src. Shared NATs collapse many users onto one key, and an attacker who rotates addresses resets the counter. After authentication, set characteristics: ["userId"] on the rule and pass { userId } to protect().
How do you handle each denial reason?
decision.isDenied() is the high-level verdict. Branch on decision.reason so clients get the correct status and you can tune one rule without guessing.
isRateLimit(): return HTTP429. IncludeRetry-Afterwhen you havedecision.reason.reset(seconds until the window refills). A429without a retry hint turns a well-behaved client into a hot loop.isBot(): return HTTP403. The client matched a detected bot that your allow or deny list does not permit. Inspectdecision.reasonfor the identified bots and categories.isShield(): return HTTP403. Shield scored the request as a common web attack. Do not echo the matched payload back to the client.isErrored(): a rule could not finish (timeout, misconfiguration, or transport). Arcjet fails open by default so an outage does not take the API down. Logdecision.reason.message. Fail closed only on routes where an unknown verdict is worse than downtime.
Look up decision.id in the Arcjet dashboard when you need the full request record. Local decisions use an lreq_ prefix; cloud decisions use req_.
How do you choose a production rate limit?
Replace the demo max: 1 before you deploy. Pick the algorithm from the cost of the route, not from a global default.
- Fixed window is simple and bursts at the reset. Use it for coarse internal quotas.
- Sliding window is the usual public-API default when you want "no more than n in the last interval."
- Token bucket is the right model when request cost varies. Configure
refillRate,interval(seconds as a number, or a duration string such as"60s"), andcapacity. Pass{ requested }toprotect()so a cheap read costs 1 token and an export costs more.
The following token-bucket rule allows a burst of 20, then 10 tokens every 60 seconds, keyed on the authenticated user:
import arcjet, { tokenBucket } from "@arcjet/node";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ tokenBucket({ mode: "LIVE", characteristics: ["userId"], refillRate: 10, interval: 60, capacity: 20, }), ],});
app.use(async (req, res, next) => { const userId = req.user?.id; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); }
const decision = await aj.protect(req, { userId, requested: 1 }); if (decision.isDenied() && decision.reason.isRateLimit()) { return res.status(429).json({ error: "Too Many Requests" }); } return next();});For more information about burst behavior, identifiers, and 429 responses, see rate limiting algorithms.
How do you run and verify the rules?
Start the process with the env file loaded, for example node --env-file .env.local --import tsx src/app.ts or your existing TypeScript runner. A first GET / should return 200 and {"message":"Hello World"}. A second request inside the demo window should return 429. A curl request against allow: [] should return 403 because curl is a detected tool.
When you are ready for real traffic, raise max, switch the bot rule to the allow or deny list that matches the route, and keep Shield in live mode. Compile TypeScript to dist/ and start the compiled file in production; the Arcjet client is the same in both environments.
Frequently asked questions
How do you secure a Node.js/Express API?
Call protect() once per request in Express middleware before business logic. Combine Shield for common web attacks, detectBot with an allow or deny list, and a rate limit keyed on IP before login and on user ID after authentication.
What is the difference between Shield, detectBot, and fixedWindow?
Shield scores injection and similar request patterns. detectBot classifies known bots and categories you allow or deny. fixedWindow (or sliding window or token bucket) counts capacity for an identity. Each deny maps to HTTP 403 except rate limits, which return 429.
Should you ship fixedWindow with max 1?
No. max: 1 is a demonstration so a second request in the same window returns 429. Production routes need a max and window that match the resource, or a token bucket with requested cost.
How does detectBot work in Arcjet v1?
Pass exactly one of allow or deny. An allow list denies every detected bot that is not named. A deny list allows every detected bot that is not named. allow: [] blocks every detected bot. Do not use block: ["AUTOMATED"].
What status code should you return when Arcjet denies a request?
Return HTTP 429 when decision.reason.isRateLimit() is true, and HTTP 403 when the reason is a bot or Shield deny. Log isErrored() and fail open unless the route must fail closed.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.