Secrets and Dependencies
Read secrets from the environment, fail at boot when one is missing, and install from a lockfile you have audited.
ConfigModule.forRoot({ isGlobal: true, validationSchema }) / configService.getOrThrow<T>(key)Two sources of trouble ship with every service: the credentials it holds, and the code it did not write.
Secrets come from the environment
A secret in the repository is a secret forever, because it stays in the git history after the commit that removes it. Keep them in the environment, and keep .env out of version control. @nestjs/config reads the .env file, merges it over process.env and serves the result through ConfigService. Note the direction of that merge: a variable already set in the runtime environment wins over the same key in .env, unless you pass override: true.
Fail at boot, not at 3am
A missing secret should stop the process at startup, not surface as a 500 on the one endpoint that uses it. Two ways to get that. ConfigModule.forRoot({ validationSchema }) validates every variable before the app bootstraps; in @nestjs/config 12 the schema is any Standard Schema implementation, such as Zod, Valibot or ArkType, and Joi 18 or later. And configService.getOrThrow('KEY') throws when a value is undefined, with a return type that excludes undefined, so TypeScript stops asking you to handle a case that cannot happen.
Dependencies
A Nest app is mostly other people's code. package-lock.json records the exact version of every transitive dependency; commit it. npm ci installs precisely that tree and fails if the lockfile disagrees with package.json, while npm install may quietly resolve a new transitive version. Use npm ci in CI and in your Dockerfile. npm audit reports known advisories, npm audit --omit=dev limits it to what actually ships, and the overrides field in package.json pins a fixed version of a transitive package your direct dependency has not updated yet.
Example
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod'; // npm i zod
import { OrdersModule } from './orders/orders.module.js';
Module({
imports: [
ConfigModule.forRoot({
// Makes ConfigService injectable everywhere without importing
// ConfigModule into each feature module.
isGlobal: true,
// Reading process.env is slow; cache the resolved values.
cache: true,
// Any Standard Schema library works in @nestjs/config 12: Zod,
// Valibot, ArkType. Joi still works from v18, which implements it.
validationSchema: z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
// Environment variables are always strings, so coerce the number.
PORT: z.coerce.number().int().positive().default(3000),
// No default: the app refuses to bootstrap without these.
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
}),
// Unknown variables (PATH, HOME, ...) are not errors by default,
// and every failing variable is reported, not just the first.
// Each error reads "KEY: message", joined by newlines.
}),
OrdersModule,
],
})
export class AppModule {}
// A typical package.json for the same project:
//
// "scripts": {
// "start:prod": "node dist/main",
// "deps:audit": "npm audit --omit=dev --audit-level=high"
// },
// "overrides": {
// // Pin a fixed transitive version until the direct dependency
// // releases an update that does it for you.
// "tar-fs": "3.0.8"
// }
//
// CI and Dockerfile install with `npm ci`, never `npm install`:
// it installs exactly the committed lockfile and fails if the lockfile
// and package.json disagree.When to use it
- Make a deploy with a missing DATABASE_URL fail at startup instead of on the first request.
- Verify a Stripe webhook signature without the comparison leaking the expected value.
- Reproduce an old build exactly by installing from the committed lockfile with npm ci.
- Fail a CI job when a shipped dependency has a high-severity advisory.
More examples
Reading a secret, shorthand and longhand
getOrThrow() and get()-plus-a-check are the same thing; you will meet both, and the shorthand is worth preferring because the type it returns removes every downstream non-null assertion. The structural point is doing it in the constructor: a value read there fails during module instantiation, which is before the server listens, so a bad deploy never serves traffic.
// src/payments/payments.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PaymentsService {
private readonly apiKey: string;
constructor(private readonly config: ConfigService) {
// Shorthand. Throws if the value is undefined, and its return type
// excludes undefined, so `apiKey` is a string with no narrowing.
this.apiKey = this.config.getOrThrow<string>('STRIPE_SECRET_KEY');
// The longhand it replaces - identical behaviour, four more lines:
//
// const apiKey = this.config.get<string>('STRIPE_SECRET_KEY');
// if (apiKey === undefined) {
// throw new Error('Configuration key STRIPE_SECRET_KEY does not exist');
// }
// this.apiKey = apiKey;
//
// get() returns `T | undefined`, so without the check every use of
// the value needs a `!` and the missing variable surfaces as a
// runtime error on the first payment instead of at startup.
}
// Resolving configuration in the constructor, not per request, means a
// missing or malformed value fails while the module is being created.
async charge(amountInCents: number) {
// Never log the key, and never put it in an error message: logs and
// error trackers are read by more people than the deploy pipeline.
return this.callStripe(amountInCents, this.apiKey);
}
private async callStripe(amountInCents: number, apiKey: string) {
void amountInCents;
void apiKey;
throw new Error('not implemented in this example');
}
}The custom validate() function
validationSchema and validate are two ways into the same hook, and only one of them can express a cross-field rule like this JWT_SECRET check. The function must be synchronous, and it has to return the full configuration object rather than only the keys it validated, because ConfigService serves what it returns. Note the error message: it names the variables, never their values, because startup logs are shipped to places secrets should not reach.
// src/config/env.validation.ts
// The alternative to validationSchema: a plain synchronous function.
// Useful when validation needs logic a schema cannot express, or when
// you do not want another dependency.
export interface Env {
NODE_ENV: 'development' | 'test' | 'production';
PORT: number;
DATABASE_URL: string;
JWT_SECRET: string;
}
// Receives the merged variables from .env and the process. Whatever it
// returns is what ConfigService serves, so coercions here take effect.
// If it throws, the application does not bootstrap.
export function validate(config: Record<string, unknown>): Env {
const errors: string[] = [];
const nodeEnv = config.NODE_ENV ?? 'development';
if (nodeEnv !== 'development' && nodeEnv !== 'test' && nodeEnv !== 'production') {
errors.push(`NODE_ENV: expected development | test | production, got ${String(nodeEnv)}`);
}
const port = Number(config.PORT ?? 3000);
if (!Number.isInteger(port) || port <= 0) {
errors.push(`PORT: expected a positive integer, got ${String(config.PORT)}`);
}
const databaseUrl = String(config.DATABASE_URL ?? '');
if (!databaseUrl) {
errors.push('DATABASE_URL: required');
}
const jwtSecret = String(config.JWT_SECRET ?? '');
// A rule a schema would struggle with: the shared development secret
// must never reach production.
if (jwtSecret.length < 32 || (nodeEnv === 'production' && jwtSecret === 'dev-secret')) {
errors.push('JWT_SECRET: at least 32 characters, and not the development default');
}
if (errors.length > 0) {
// The message is printed at startup, so it must not echo the values.
throw new Error(`Invalid environment:\n${errors.join('\n')}`);
}
return {
NODE_ENV: nodeEnv,
PORT: port,
DATABASE_URL: databaseUrl,
JWT_SECRET: jwtSecret,
};
}
// src/app.module.ts
// import { validate } from './config/env.validation.js';
//
// @Module({
// imports: [ConfigModule.forRoot({ isGlobal: true, validate })],
// })
// export class AppModule {}Using a secret without leaking it through timing
Reach for this on any inbound webhook from Stripe, GitHub or a payment provider: the signing secret is how you know the request is theirs. It shows the three things that go wrong with a secret in use rather than in storage - comparing with === instead of timingSafeEqual, hashing the parsed body instead of the raw bytes, and reading the secret per request instead of once at construction. rawBody requires rawBody: true in the application options and the built-in body parser left enabled.
// src/webhooks/signature.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
@Injectable()
export class SignatureGuard implements CanActivate {
private readonly secret: string;
constructor(config: ConfigService) {
this.secret = config.getOrThrow<string>('WEBHOOK_SIGNING_SECRET');
}
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<RawBodyRequest<Request>>();
// Sign the bytes that arrived, not the re-serialised JSON: key order
// and whitespace change, and the signature stops matching.
// req.rawBody needs `rawBody: true` in the NestFactory.create()
// options, and it is undefined if bodyParser was disabled.
const raw = request.rawBody;
const header = request.headers['x-signature'];
if (!raw || typeof header !== 'string') {
throw new UnauthorizedException();
}
const expected = createHmac('sha256', this.secret).update(raw).digest();
let received: Buffer;
try {
received = Buffer.from(header, 'hex');
} catch {
throw new UnauthorizedException();
}
// timingSafeEqual throws on a length mismatch, so check first.
// A plain `===` on the hex strings returns early at the first wrong
// character, which leaks the correct prefix to a patient caller.
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
throw new UnauthorizedException();
}
return true;
}
}
Discussion