Minimal Next.js + Arcis app showing the two-layer pattern: Edge Middleware for rate-limit + bot, route handler for XSS / SQL / SSTI / etc. payload inspection.
A demo of how a real Next.js app wires Arcis. Next.js Edge Middleware can read URLs and headers but cannot consume request bodies (the stream would be unreadable downstream). So Arcis on Next.js works in two layers:
- Edge Middleware (
middleware.ts) runs ahead of every request. Scope: rate-limit, bot detection, response security headers. Cheap, runs on every page + every API route. - Route handler payload inspection (
app/api/echo/route.ts) usesscanThreatsfrom@arcis/node/sanitizersagainst the parsed body and query. Catches XSS, SQL, NoSQL, path, command, SSTI, XXE, prototype, LDAP, XPath, header-injection payloads. Runs only on API routes that handle untrusted input.
Files in this repo:
middleware.ts: Layer 1 (arcisMiddleware) for rate-limit + bot + headers.app/api/echo/route.ts: Layer 2 (scanThreats) for payload inspection.app/page.tsx: Welcome page.attack.js: Fires 8 attack payloads at the dev server and reports which ones Arcis blocks.
Total runtime dependencies: @arcis/node + next + react. Nothing else.
| Protection | Where it runs | Built in? |
|---|---|---|
| Rate limiting (per-IP, in-memory) | arcisMiddleware in middleware.ts |
yes |
| Bot detection | arcisMiddleware in middleware.ts |
yes |
| Response security headers (CSP, HSTS, X-Frame-Options, etc.) | arcisMiddleware via arcisProtect wrap on outbound |
yes |
| Input sanitization (XSS, SQL, NoSQL, path, command, SSTI, XXE, prototype, LDAP, XPath, header injection) | scanThreats inside app/api/echo/route.ts |
yes, opt-in per-route |
| CSRF protection | not in this demo (opt-in) | use csrf() from @arcis/node/middleware |
| CORS | not in this demo (opt-in) | configure in next.config.js or per-route |
| Secure cookies | not in this demo (opt-in) | use secureCookies() from @arcis/node/middleware |
| URL / redirect / file-upload validation | not in this demo (opt-in) | validateUrl, validateRedirect, validateFile from @arcis/node/validation |
| Error-leakage scrubbing | not in this demo (opt-in) | use errorHandler() from @arcis/node/middleware |
The 8-payload attack.js exercises both layers: rate-limit and bot get caught by Layer 1; XSS/SQL/SSTI/etc. payloads get caught by Layer 2. Other concerns (CSRF, CORS, validation) are deliberate opt-ins per the same pattern as the other Arcis examples.
npm install
npm run dev # listens on http://localhost:3000
npm run attack # in another shell, fires the demo payloadsExpected output:
Arcis attack demo against http://localhost:3000
----------------------------------------------------------------
OK safe safe input: 200 (passed through, as expected)
BLOCK xss <script> in query: 403 (Arcis denied, as expected)
BLOCK xss event handler: 403 (Arcis denied, as expected)
BLOCK sql '; DROP TABLE users; --: 403 (Arcis denied, as expected)
BLOCK nosql { $gt: "" } operator: 403 (Arcis denied, as expected)
BLOCK path ../../etc/passwd: 403 (Arcis denied, as expected)
BLOCK command ; rm -rf /: 403 (Arcis denied, as expected)
BLOCK ssti Jinja2 {{7*7}}: 403 (Arcis denied, as expected)
BLOCK xxe DOCTYPE ENTITY: 403 (Arcis denied, as expected)
----------------------------------------------------------------
8 attacks blocked, 1 safe call passed, 0 unexpected
middleware.ts:
import { arcisMiddleware } from '@arcis/node/nextjs';
import { NextResponse } from 'next/server';
const arcis = arcisMiddleware({
rateLimit: { max: 100, windowMs: 60_000 },
bot: true,
});
export default async function middleware(request: Request) {
const blocked = await arcis(request);
if (blocked) return blocked;
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};arcisMiddleware returns a Response (429 for rate-limit, 403 for bot) when a request hits the pipeline, or undefined when it should fall through. Edge Middleware is block-or-pass by design, so unlike the Express adapter, there is no block toggle. Body inspection is not in this layer because Edge Middleware cannot consume the request body without making it unreadable in the route handler.
app/api/echo/route.ts:
import { scanThreats } from '@arcis/node/sanitizers';
export async function POST(request: Request) {
const body = await request.json().catch(() => ({}));
const url = new URL(request.url);
const query = Object.fromEntries(url.searchParams);
const hit = scanThreats(body) ?? scanThreats(query);
if (hit) {
return Response.json(
{ error: 'request blocked by Arcis', vector: hit.vector, rule: hit.rule },
{ status: 403 },
);
}
return Response.json({ received: body });
}scanThreats is the same function the Express adapter calls internally when you set arcis({ block: true }). It returns a ThreatHit | null describing the first vector / rule that fired, or null if the input is clean. Apply it on any API route that accepts untrusted input.
Use Layer 1 globally (it is cheap and catches the bots that scrape your whole app). Add Layer 2 to API routes that take untrusted input. For routes that only echo back internal state, Layer 2 is unnecessary. For deeper coverage, the main Arcis docs cover CSRF, CORS, cookies, validation, and error scrubbing as separate opt-in middleware.
| Framework | Repo |
|---|---|
| Express | arcis-example-express |
| FastAPI | arcis-example-fastapi |
| Gin (Go) | arcis-example-gin |
| Bun + Hono | arcis-example-bun |
| NestJS | arcis-example-nestjs |
MIT. See LICENSE.