Application & framework security

How do I functionally test security rules?

Send the traffic that should trip each LIVE rule and assert the status. Newman is the functional default. k6 and Artillery work if you keep VUs at 1. DRY_RUN cannot produce a 403 or 429.

6 min read
In short: Send the traffic that should trip each LIVE rule and assert the status. Newman is the functional default. k6 and Artillery work if you keep VUs at 1. DRY_RUN cannot produce a 403 or 429.

How do I functionally test security rules?

Send the traffic that should trip the rule to the same app binary you run in production, then assert the status code. Arcjet evaluates protect() locally (WebAssembly) and, when the rule needs shared state, through the decide API. That path is identical on a laptop, in CI, and on Fly. You do not stand up a separate WAF fixture.

Untested rules fail in two directions. They fail open: a bot rule that never matches curl because the handler reads req.headers.get("user-agent") after a rewrite. They fail closed: a rate limit you flip to LIVE in production that returns an HTML error page to an API client, or a window so tight that a legitimate form POST is 429. Both are cheaper to see in CI than in a support thread.

Create the client once with a v1 key and rules array. Put the test target in LIVE mode. DRY_RUN logs the decision but still returns allow, so a functional test against DRY_RUN cannot see a 403 or 429. Use DRY_RUN on the production deploy until you have a day of dashboard data, then switch that rule to LIVE and keep the functional suite on a dedicated test key.

import arcjet, { detectBot, fixedWindow, shield } from "@arcjet/node";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
fixedWindow({ mode: "LIVE", window: "3s", max: 50 }),
],
});

Route-handler unit tests (mocked protect(), mocked auth()) live in testing Next.js App Router API routes. This page is the complementary suite: real HTTP against a running server. Use OrbStack HTTPS if production is TLS-only and cookies or HSTS depend on it.

How do I test each Arcjet rule type?

RuleRequest that should passRequest that should failExpected deny
ShieldOrdinary browser GET

Five requests with x-arcjet-suspicious: true, then a sixth

Sixth request denied (test hook, not a real exploit)
Rate limit

At most max requests in the window

max + 1 requests from the same identity

429 on request max + 1

Bot protection

A normal browser User-Agent

User-Agent: curl when the rule does not allow it

403 (or your handler's deny status)

Email validationA deliverable address on a domain with MX

not-an-email or a domain with no MX

Handler rejects the signup

Shield's x-arcjet-suspicious header is a documented test flag. Do not send attack payloads at production to "exercise the WAF." Rate limits need a short window in the test environment (3 seconds in the example) so the suite finishes. Bot tests should assert the JSON body your handler returns, not an HTML error page from a generic gateway.

Which load tool should I use?

Newman, k6, and Artillery are all current. They are not interchangeable.

ToolStrengthWeaknessUse it when

Newman

Postman Collections, per-iteration assertions, works as a CLI or a Node library. Postman itself is optional

Not a load generatorFunctional checks: 51st request is 429, curl is 403
k6VUs, thresholds, JS scripts, CI-friendly binaryEasy to accidentally load-test instead of assert

The same assertions plus a later soak, with vus: 1 for functional runs

ArtilleryYAML scenarios, HTTP and WebSocket, load phasesAssertions are secondary to load phases

You already have Artillery scenarios and want a deny check in them

Prefer Newman or a one-VU k6 script for security rules. A 200-VU k6 run against a 50-request window will trip the limit and tell you nothing about which request should have been the first deny.

How do I run a Newman collection against a rate limit?

Save this as tests/high-rate-limit.json. The route under test uses the fixedWindow rule above (max: 50, window: "3s").

{
"info": {
"name": "high-rate-limit",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [{ "key": "baseUrl", "value": "http://localhost:8080" }],
"item": [
{
"name": "Test high rate limit",
"request": {
"method": "GET",
"header": [{ "key": "Accept", "value": "application/json" }],
"url": "{{baseUrl}}/api/high-rate-limit"
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('status matches iteration', () => pm.response.to.have.status(pm.info.iteration < 50 ? 200 : 429))"
]
}
}
]
}
]
}

Start the app, then:

Terminal window
npx newman run tests/high-rate-limit.json -n 51

Expected output (timings vary):

newman
high-rate-limit
→ Test high rate limit
✓ status matches iteration
… (50 passing 200s)
→ Test high rate limit
✓ status matches iteration # 51st request, status 429
┌─────────────────────────┬──────────┬──────────┐
│ │ executed │ failed │
├─────────────────────────┼──────────┼──────────┤
│ iterations │ 51 │ 0 │
│ requests │ 51 │ 0 │
│ test-scripts │ 51 │ 0 │
│ prerequest-scripts │ 0 │ 0 │
│ assertions │ 51 │ 0 │
├─────────────────────────┼──────────┼──────────┤
│ total run duration: 1.4s │
└─────────────────────────┴──────────┴──────────┘

failed must be 0. If request 51 is still 200, the rule is DRY_RUN, the window reset between calls, or you are hitting a different instance than the one that holds the counter. If every request is 429, you inherited a hot key from a previous run; wait for the 3-second window or change the test characteristic.

The same collection runs from Node's test runner:

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { run } from "newman";
const newmanRun = promisify(run);
describe("rate limit", () => {
it("denies the 51st request", async () => {
const summary = await newmanRun({
collection: fileURLToPath(
new URL("./high-rate-limit.json", import.meta.url),
),
iterationCount: 51,
});
assert.equal(summary.run.failures.length, 0);
});
});
Terminal window
node --test

How do I run the same test with k6?

tests/high-rate-limit.k6.js
import http from "k6/http";
import { check } from "k6";
export const options = {
scenarios: {
functional: {
executor: "shared-iterations",
vus: 1,
iterations: 51,
},
},
thresholds: {
checks: ["rate==1"],
},
};
export default function () {
const res = http.get("http://localhost:8080/api/high-rate-limit", {
headers: { Accept: "application/json" },
});
const expected = __ITER < 50 ? 200 : 429;
check(res, { "status matches iteration": (r) => r.status === expected });
}
Terminal window
k6 run tests/high-rate-limit.k6.js

Expected summary:

✓ status matches iteration
checks.........................: 100.00% ✓ 51 ✗ 0
http_reqs......................: 51 35/s
iterations.....................: 51 35/s

checks at 100% and ✗ 0 is the pass. A bot rule is a single iteration with headers: { "User-Agent": "curl" } and expected = 403.

Keep functional security tests in CI on every change to rules or to the handler that calls protect(). A refactor that moves protect() below a database write will still pass a mocked unit test and fail this suite, which is the point.

Wire the suite so the server is up before Newman or k6 start. A node --test file can spawn node --import tsx src/server.ts, wait on /health, then call newmanRun. GitHub Actions should use a single job: install, start the app in the background, wait, run npx newman run … -n 51 and k6 run if you keep both. Do not hit a shared staging site from every PR. You will collide on the same rate-limit key and flake.

Key the test route on a characteristic you control (x-test-run-id mapped to characteristics: ["header:x-test-run-id"], or a dedicated ARCJET_KEY site) so parallel CI jobs do not share a window. The 3-second window in the example is for the suite, not for production. Production login limits stay at minutes; the test route can be a /api/__test/rate-limit handler that you disable when NODE_ENV === "production".

Frequently asked questions

How do I functionally test security rules?

Run the production app binary, send the request that should trip the LIVE rule, and assert 403 or 429. Arcjet's local Wasm plus decide API is the same path on a laptop and in CI.

Why can't I assert deny in DRY_RUN?

DRY_RUN computes and logs the decision but the conclusion stays ALLOW. Functional deny tests need LIVE. Use DRY_RUN on production until you have dashboard data.

Are Newman, k6, and Artillery still current?

Yes. Newman (Postman Collections, Postman optional) is the functional default. k6 and Artillery are load tools; use vus: 1 or a single scenario for rule assertions.

What should the 51st request return?

429 when max is 50. Newman and k6 examples in the article expect 50 times 200 and one 429, with zero failed assertions.

How do I test Shield without attacking production?

Send five requests with x-arcjet-suspicious: true, then a sixth. That is the documented test hook. Do not send exploit payloads at production.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.