Backend & APIs

A secure NestJS + Prisma API starter, piece by piece

A walkthrough of the security layer in my NestJS 11 + Prisma 7 starter: Helmet headers, a CORS allow-list, three-window rate limiting, opt-in CSRF, header-free Pino logs and a Prisma error filter, with the real code and the gaps still to close.

Cover for A secure NestJS + Prisma API starter, showing the Prisma error filter switch statement in a code editor
On this page
  1. Running it locally
  2. The bootstrap order matters
  3. Helmet: security headers
  4. CORS: an allow-list with credentials
  5. Cookies and CSRF
  6. Compression
  7. Prefix, versioning and validation
  8. Rate limiting with three windows
  9. Structured logging with Pino
  10. The Prisma error filter
  11. Environment validation, Prisma 7 and health
  12. What to finish before production
  13. Takeaway

Most NestJS tutorials stop at nest new and a controller. The part that takes time is everything that wraps the controllers: security headers, a CORS policy that matches your frontends, rate limits, CSRF for cookie sessions, logs you can actually search, and errors that don't leak your database schema to the client.

nestjs-prisma-starter is the base I use for that layer. It's NestJS 11 with Prisma 7 on PostgreSQL. This post walks through it one piece at a time, using the real code from the repository, and it's honest about the parts that still need finishing before production.

Running it locally#

You need Node.js and a PostgreSQL database. The repository ships a .env.example with the two variables that are required, DATABASE_URL and COOKIE_SECRET:

bash
git clone https://github.com/fabwaseem/nestjs-prisma-starter.git
cd nestjs-prisma-starter
npm install
cp .env.example .env
npx prisma generate
npm run start:dev

Outside production the app serves Swagger at /api/docs, and GET /health tells you straight away whether the database connection works. Generate a long random value for COOKIE_SECRET rather than keeping the placeholder, because it signs your cookies.

The bootstrap order matters#

Everything security-related is wired in src/main.ts, in a deliberate order: body limits, then Helmet, CORS, cookies, CSRF and compression, then the global prefix, versioning, validation, the exception filter and logging. Middleware in Express runs in registration order, so a request is size-checked and given security headers before any route code sees it.

The app is created with buffered logs so that early startup messages go through Pino instead of Nest's default console logger:

ts
const app = await NestFactory.create(AppModule, {
  bufferLogs: true,
});

const configService = app.get(ConfigService);
app.useLogger(app.get(Logger));

app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

The 10 MB body limit is generous for a JSON API. If your endpoints only take small payloads, lowering it is the cheapest denial-of-service protection you'll ever add.

Helmet: security headers#

Helmet sets the response headers browsers use to lock a site down. The starter configures it explicitly instead of relying on defaults:

ts
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: [`'self'`],
        styleSrc: [`'self'`, `'unsafe-inline'`],
        scriptSrc: [`'self'`, `'unsafe-inline'`],
        imgSrc: [`'self'`, 'data:', 'https:'],
      },
    },
    crossOriginEmbedderPolicy: process.env.NODE_ENV === 'production',
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    xssFilter: true,
    frameguard: { action: 'deny' },
  }),
);

A few of these deserve a comment:

  • HSTS is set for one year (31536000 seconds) with includeSubDomains and preload. Preload is a commitment: once your domain is on the browser preload list, every subdomain must serve HTTPS. Turn it on only when that's true.
  • CSP allows 'unsafe-inline' for scripts and styles. A pure JSON API rarely serves HTML, so the policy mostly matters for the Swagger UI page in development. If you serve HTML from this app in production, tighten scriptSrc.
  • crossOriginResourcePolicy: 'cross-origin' lets other origins load responses, which an API consumed by a separate frontend usually needs.
  • frameguard: deny stops the responses from being framed, which closes off clickjacking.

CORS: an allow-list with credentials#

CORS is driven by an ALLOWED_ORIGINS environment variable, a comma-separated list:

ts
const allowedOrigins = configService
  .get<string>('ALLOWED_ORIGINS')
  ?.split(',') || ['http://localhost:3000', 'http://localhost:4200'];

app.enableCors({
  origin: (origin, callback) => {
    if (
      !origin ||
      allowedOrigins.indexOf(origin) !== -1 ||
      allowedOrigins.includes('*')
    ) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Requested-With',
    'Accept',
    'X-CSRF-Token',
  ],
  exposedHeaders: ['X-Total-Count', 'X-Page-Count', 'Set-Cookie'],
  maxAge: 3600,
});

Three behaviors to know. A request with no Origin header is allowed, which is what curl, server-to-server calls and health checks send; CORS is a browser control, not authentication. Because credentials: true is on, the origin function echoes the specific origin back instead of *, so browsers will send cookies. And a * entry in the list allows every origin while still sending credentials, so never put * in production config. Also note that split(',') doesn't trim, so a.com, b.com with a space won't match b.com.

X-CSRF-Token is in the allowed headers, which is the hook for the next piece.

Cookies and CSRF#

CSRF only matters when the browser sends credentials automatically, which means cookie-based sessions. Bearer tokens in an Authorization header aren't sent automatically, so the starter makes CSRF opt-in with CSRF_ENABLED=true:

ts
app.use(cookieParser(configService.get<string>('COOKIE_SECRET')));

if (configService.get<string>('CSRF_ENABLED') === 'true') {
  app.use(
    csurf({
      cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
      },
    }),
  );

  app.use((req: any, res: any, next: any) => {
    res.cookie('XSRF-TOKEN', req.csrfToken(), {
      httpOnly: false,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
    });
    next();
  });
}

This is the classic pattern. The secret lives in an httpOnly cookie the page can't read. A second cookie, XSRF-TOKEN, is readable by JavaScript. The frontend copies its value into the X-CSRF-Token header on state-changing requests, and the middleware compares the two. Axios and Angular's HTTP client look for an XSRF-TOKEN cookie by name. Both cookies are sameSite: 'strict' and secure in production.

One caveat: the csurf package has been deprecated by its maintainers. It still works, but for a new project I'd plan a move to a maintained double-submit implementation, and keep the cookie and header names the same so the frontend doesn't change.

Compression#

Responses over 1 KB are compressed, and a client can opt out by sending an x-no-compression header:

ts
app.use(
  compression({
    filter: (req, res) => {
      if (req.headers['x-no-compression']) {
        return false;
      }
      return compression.filter(req, res);
    },
    threshold: 1024,
  }),
);

If you run behind a reverse proxy or CDN that already compresses, you can drop this and let the edge do the work.

Prefix, versioning and validation#

All routes live under /api, except the health check, metrics and root. URI versioning defaults to 1, so a controller route users is served at /api/v1/users:

ts
app.setGlobalPrefix('api', {
  exclude: ['/health', '/metrics', '/'],
});

app.enableVersioning({
  type: VersioningType.URI,
  defaultVersion: '1',
});

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: {
      enableImplicitConversion: true,
    },
    disableErrorMessages: process.env.NODE_ENV === 'production',
    validationError: {
      target: false,
      value: false,
    },
  }),
);

The validation pipe is the most important security line in the file. whitelist strips properties that have no decorator on the DTO, and forbidNonWhitelisted turns those extra properties into a 400 instead of silently dropping them. That shuts down mass assignment: a client can't add "role": "admin" to a profile update. target: false and value: false keep the submitted object and value out of error responses, and production hides validation messages entirely.

Rate limiting with three windows#

Throttling is set in AppModule with @nestjs/throttler, registered as a global guard so every route is covered without decorators:

ts
ThrottlerModule.forRoot([
  {
    name: 'short',
    ttl: 1000, // 1 second
    limit: 10, // 10 requests per second
  },
  {
    name: 'medium',
    ttl: 60000, // 1 minute
    limit: 100, // 100 requests per minute
  },
  {
    name: 'long',
    ttl: 3600000, // 1 hour
    limit: 1000, // 1000 requests per hour
  },
]),
ts
providers: [
  {
    provide: APP_GUARD,
    useClass: ThrottlerGuard,
  },
],

The limits are deliberately loose for a general API: ten requests a second is enough for a busy single-page app, while a login or password-reset route deserves something much stricter. The throttler supports that per route with the @Throttle() decorator, and @SkipThrottle() exempts routes such as internal webhooks.

Three named windows let you allow short bursts while capping sustained traffic. Every request has to pass all three. The default storage is in memory, which is per process, so with several instances behind a load balancer each one counts separately. That's the point where you add a shared store such as Redis. Behind a proxy, also make sure Express trusts it, or every client will appear to share the proxy's IP.

Structured logging with Pino#

nestjs-pino replaces Nest's logger with Pino, configured in AppModule. In development it pretty-prints on one line. In production it writes JSON at info level:

ts
serializers: {
  req: (req) => ({
    id: req.id,
    method: req.method,
    url: req.url,
    remoteAddress: req.remoteAddress,
    remotePort: req.remotePort,
  }),
  res: (res) => ({
    statusCode: res.statusCode,
  }),
},
autoLogging: {
  ignore: (req) => req.url === '/health' || req.url === '/metrics',
},
customLogLevel: (req, res, err) => {
  if (res.statusCode >= 400 && res.statusCode < 500) {
    return 'warn';
  } else if (res.statusCode >= 500 || err) {
    return 'error';
  }
  return 'info';
},

The serializers are the security feature here. By listing the fields to keep, they leave out headers entirely, so Authorization tokens and session cookies never reach your log storage. Health and metrics checks are skipped so uptime probes don't drown real traffic, and the level follows the status code, so a 4xx is a warning and a 5xx is an error you can alert on.

The starter also registers a LoggingInterceptor that logs method, URL, status, size, response time, IP and user agent. With pino-http already logging every request, that's two lines per request. In production I'd keep one of them.

The Prisma error filter#

The global exception filter turns every error into one response shape: statusCode, timestamp, path and message. It also translates Prisma's error codes into HTTP statuses:

ts
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
  status = HttpStatus.BAD_REQUEST;
  errorName = 'PrismaError';

  switch (exception.code) {
    case 'P2002':
      status = HttpStatus.CONFLICT;
      message = `Unique constraint failed on ${exception.meta?.target}`;
      break;
    case 'P2025':
      status = HttpStatus.NOT_FOUND;
      message = 'Record not found';
      break;
    case 'P2003':
      status = HttpStatus.BAD_REQUEST;
      message = 'Foreign key constraint failed';
      break;
    default:
      message = exception.message;
  }
} else if (exception instanceof Prisma.PrismaClientValidationError) {
  status = HttpStatus.BAD_REQUEST;
  message = 'Validation error';
  errorName = 'PrismaValidationError';
}

So a duplicate email becomes a 409, a missing record a 404 and a broken relation a 400, instead of a generic 500. The response hides details for 500s in production:

ts
response.status(status).json({
  statusCode: status,
  timestamp: new Date().toISOString(),
  path: request.url,
  message:
    process.env.NODE_ENV === 'production' && status === 500
      ? 'An internal error occurred'
      : message,
});

Look at the default branch, though. Any other known Prisma error returns exception.message with a 400, and that masking only applies to 500s. Prisma messages can describe your tables and query, so in production I'd map unknown codes to a generic message as well. The same applies to the plain Error branch: its status stays 500, so it is masked, but a 400 built from a raw message is not.

The filter logs one line per error with the method, URL, status and error name. The stack trace argument is commented out in the code, which keeps logs short but makes a 500 harder to debug. I'd log the stack for 5xx responses only, since that's where you need it.

Environment validation, Prisma 7 and health#

Configuration is validated at startup with Joi. DATABASE_URL and COOKIE_SECRET are required, and abortEarly: false reports every missing variable at once instead of one per restart:

ts
export const validationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test', 'staging')
    .default('development'),
  PORT: Joi.number().default(3000),
  HOST: Joi.string().default('0.0.0.0'),
  ALLOWED_ORIGINS: Joi.string().default('http://localhost:3000'),
  COOKIE_SECRET: Joi.string().required(),
  CSRF_ENABLED: Joi.string().valid('true', 'false').default('false'),
  DATABASE_URL: Joi.string().required(),
  THROTTLE_TTL: Joi.number().default(60),
  THROTTLE_LIMIT: Joi.number().default(10),
});

The Prisma side uses Prisma 7's driver adapter. The client is generated into src/generated/prisma, the connection URL lives in prisma.config.ts, and the service passes a PrismaPg adapter. It logs queries only outside production:

ts
constructor() {
  const adapter = new PrismaPg({
    connectionString: process.env.DATABASE_URL as string,
  });
  super({
    adapter,
    log:
      process.env.NODE_ENV === 'production'
        ? ['error', 'warn']
        : ['query', 'info', 'warn', 'error'],
  });
}

PrismaModule is global, so any feature module can inject PrismaService. There's also a cleanDatabase() helper for tests that refuses to run in production. GET /health runs SELECT 1 and reports database: connected or error, along with uptime and environment, which is what a container orchestrator or uptime monitor needs.

What to finish before production#

The core security layer above is real and working. A few things in the repository are still behind the README, and you should know them before you build on it:

  • The README describes a Users module, a seed script, a docker-compose.yml and prisma:* npm scripts. Those aren't in the code yet, so use npx prisma generate and npx prisma migrate dev directly.
  • THROTTLE_TTL and THROTTLE_LIMIT are validated but not used. The throttler limits are hard-coded in AppModule. Switching to ThrottlerModule.forRootAsync with ConfigService would make them configurable.
  • The e2e test imports src/app.module, but the module lives at src/modules/app/app.module.ts, so the test needs its import fixed.
  • enableShutdownHooks() and the manual SIGTERM/SIGINT handlers both close the app. One of them is enough.

None of these touch the request path, but they're the difference between a starter and a template you can clone and ship.

Takeaway#

Security in a NestJS API is mostly configuration, and it belongs in one place you can read top to bottom. Helmet for headers, an explicit CORS allow-list, opt-in CSRF for cookie sessions, a strict validation pipe, layered rate limits, header-free structured logs and an exception filter that speaks HTTP instead of SQL. The code is on GitHub. If you're upgrading the framework underneath it, my NestJS 12 migration guide covers that step.

Questions