API security

How do you secure a Node.js/Express API?

Secure a Node.js/Express API by calling Arcjet 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.

7 min read
In short: Secure a Node.js/Express API by calling Arcjet 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:

Terminal window
npm install @arcjet/node@1.10.0 express

Create 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:

Terminal window
ARCJET_ENV=development
ARCJET_KEY=ajkey_your_site_key

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

RuleWhat it decidesTypical 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 allow or deny

HTTP 403

fixedWindow, slidingWindow, or tokenBucket

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 HTTP 429. Include Retry-After when you have decision.reason.reset (seconds until the window refills). A 429 without a retry hint turns a well-behaved client into a hot loop.
  • isBot(): return HTTP 403. The client matched a detected bot that your allow or deny list does not permit. Inspect decision.reason for the identified bots and categories.
  • isShield(): return HTTP 403. 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. Log decision.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"), and capacity. Pass { requested } to protect() 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.