Application & framework security

How to secure your NestJS application with Arcjet

@arcjet/nest v1 is ESM-only. Switch NestJS 10 or 11 to "type": "module" and module: "nodenext", register ArcjetModule.forRoot, apply ArcjetGuard, and overlay per-route rules with @WithArcjetRules. Bot rules take allow lists, not block.

5 min read
In short: @arcjet/nest v1 is ESM-only. Switch NestJS 10 or 11 to "type": "module" and module: "nodenext", register ArcjetModule.forRoot, apply ArcjetGuard, and overlay per-route rules with @WithArcjetRules. Bot rules take allow lists, not block.

How do you secure a NestJS application with Arcjet?

Use @arcjet/nest v1 on NestJS 10 or 11. Configure ArcjetModule.forRoot, apply ArcjetGuard globally or per controller, and attach route rules with @WithArcjetRules. Bot rules use detectBot({ allow: [...] }), not the old block list.

Arcjet is ESM-only. NestJS starters default to CommonJS. Switch the project to ESM before you import the SDK, or you will get ERR_REQUIRE_ESM.

This article covers that module setup, the guard, and per-route rules. For login-specific limits see secure login pages. For abuse that is not framework-specific see the web app security checklist.

What versions does this apply to?

  • NestJS 10.4 or later, including NestJS 11
  • Node.js 22 (Arcjet supports 22.21.0 or later on the current SDK line)
  • Express or Fastify adapters
  • "type": "module" in package.json
Terminal window
npm i @arcjet/nest@1.10.0 @nestjs/config

Why do you need ESM for @arcjet/nest?

The SDK ships as ECMAScript modules so it can run on modern Node, Deno, and Bun. require() of an ESM package throws:

Error [ERR_REQUIRE_ESM]: require() of ES Module .../arcjet-nest/... not supported.

NestJS supports both CommonJS and ESM. The CLI starter still emits CommonJS ("module": "commonjs" in tsconfig.json, no "type" in package.json). Change both files.

tsconfig.json:

{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2022"
}
}

package.json:

{
"type": "module"
}

Use .js extensions on relative imports in emitted files (./app.service.js) as Node ESM requires. Nest's default jest config often still expects CommonJS; update the test runner or run tests with a Node ESM preset so CI matches npm run start. The Arcjet NestJS example shows a working layout.

If you cannot move the whole monorepo to ESM, isolate the Nest app in a package that is ESM and keep other packages CommonJS. Do not try to require() @arcjet/nest from a CommonJS file.

How do you register ArcjetModule?

Create one root client in AppModule. Rules you set here run on every guarded request. Leave rules empty if you prefer to declare them only on controllers.

import { ArcjetGuard, ArcjetModule, shield } from "@arcjet/nest";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ArcjetModule.forRoot({
isGlobal: true,
key: process.env.ARCJET_KEY!,
rules: [shield({ mode: "LIVE" })],
}),
],
providers: [
{
provide: APP_GUARD,
useClass: ArcjetGuard,
},
],
})
export class AppModule {}

ARCJET_KEY is not read implicitly; pass it into forRoot. A global ArcjetGuard calls protect() for you. You do not see the ArcjetDecision unless you inject ARCJET and call protect() in the controller.

How do you add per-route rules?

@WithArcjetRules overlays rules on the root client for that controller. Keep ArcjetGuard on the controller if you are not using a global guard.

import { Controller, Get, UseGuards } from "@nestjs/common";
import {
ArcjetGuard,
WithArcjetRules,
detectBot,
fixedWindow,
} from "@arcjet/nest";
@Controller("login")
@UseGuards(ArcjetGuard)
@WithArcjetRules([
detectBot({
mode: "LIVE",
allow: [],
}),
fixedWindow({
mode: "LIVE",
window: "10m",
max: 5,
}),
])
export class LoginController {
@Get()
ready() {
return { ok: true };
}
}

allow: [] denies every detected bot. On a public page, allow crawlers you want:

detectBot({
mode: "LIVE",
allow: ["CATEGORY:SEARCH_ENGINE"],
});

Token-bucket and sliding-window constructors work the same way as in the other Arcjet SDKs. Use a tighter window on login and password reset than on a public GET.

When do you call protect() yourself?

Use a guard when a deny should become a generic 403 or 429 and you do not need the decision in the handler. Call protect() in the controller when you need the reason, IP analysis, or custom status bodies.

import { ARCJET, type ArcjetNest, detectBot } from "@arcjet/nest";
import {
Controller,
Get,
HttpException,
HttpStatus,
Inject,
Req,
} from "@nestjs/common";
import type { Request } from "express";
@Controller("page")
export class PageController {
constructor(@Inject(ARCJET) private readonly arcjet: ArcjetNest) {}
@Get()
async index(@Req() req: Request) {
const decision = await this.arcjet
.withRule(
detectBot({
mode: "LIVE",
allow: [],
}),
)
.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
throw new HttpException(
"Too many requests",
HttpStatus.TOO_MANY_REQUESTS,
);
}
if (decision.reason.isBot()) {
throw new HttpException("No bots allowed", HttpStatus.FORBIDDEN);
}
throw new HttpException("Forbidden", HttpStatus.FORBIDDEN);
}
return { message: "Hello" };
}
}

On ERROR results, decide per route whether to fail open (log and continue) or fail closed (503). Fail closed on login and payment.

protect() accepts the Express or Fastify request object Nest already injected. Do not pass a plain object you built yourself unless it has the headers and IP the SDK expects. If Nest sits behind a load balancer, set proxies on ArcjetModule.forRoot so fingerprints use the client address.

A global guard cannot take per-request options such as ipSrc or metadata. Use a controller-level protect() call when you already trust a client IP from your own proxy parser.

What NestJS security work sits outside Arcjet?

Guards and rules do not replace:

  • Validation pipes (ValidationPipe with whitelist: true) on every DTO
  • Authentication (Auth.js, Passport, or your session) and object-level authorization
  • Helmet (or equivalent) for security headers
  • Parameterized queries and least-privilege database roles
  • CSRF protection on cookie-authenticated browser POSTs

Enable ValidationPipe globally with whitelist: true and forbidNonWhitelisted: true so undeclared DTO fields are dropped or rejected. Helmet belongs in main.ts (app.use(helmet())) for Express. Cookie-authenticated browser POSTs still need a CSRF strategy; an API that uses only bearer tokens does not.

Centralize those in modules the same way you centralize ArcjetModule. A controller that skips the validation pipe is an unvalidated endpoint, even if Shield runs. Exception filters should return generic bodies to clients and keep stack traces in the Nest logger.

How do you log NestJS and Arcjet together?

Implement LoggerService and pass it as log to ArcjetModule.forRoot so SDK lines go through Logger. When you call protect() yourself, log decision.id, decision.conclusion, and the reason flags. Do not log passwords or tokens.

Use DRY_RUN on a new rule, iterate decision.results, and only then switch to LIVE.

What should you do next?

Protect login and signup with the 5-per-10-minute window and an empty bot allow list from secure login pages. Then apply the same habit to every costly route on the web app security checklist.

Frequently asked questions

Which NestJS versions work with @arcjet/nest v1?

NestJS 10.4 or later, including NestJS 11, on Node.js 22.21.0 or later. Express and Fastify adapters are supported. CommonJS is not.

Why does NestJS throw ERR_REQUIRE_ESM with Arcjet?

The SDK is ESM-only. The Nest starter is CommonJS. Set "type": "module" and TypeScript module/moduleResolution to nodenext.

What is the difference between ArcjetGuard and protect()?

ArcjetGuard (global or @UseGuards) calls protect() for you and maps a deny to an HTTP error. Call protect() in the controller when you need the decision, a custom body, or per-request options.

How do you allow search engines but block other bots in NestJS?

Use detectBot({ mode: "LIVE", allow: ["CATEGORY:SEARCH_ENGINE"] }) on public controllers. Use allow: [] on login.

Application security in your code

Protect your application with Arcjet

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