Imagine: a developer added a line to the log for diagnostics — User registered: email=ivan@example.com phone=+79001234567. The app works, the bug is found. But now everyone who has access to the logs — colleagues, analytics systems, aggregators — sees users' personal data. This is called a PII leak.
PII (Personally Identifiable Information) — personal data that can identify a person: email, phone, full name, address, IP address, passport details, biometrics. Its leak is not just a technical incident but a violation of personal data law.
In NestJS there are three main places where PII regularly "leaks": logs, error messages and Kafka messages. Let's go through each.
Logs and personal data
Logs are written at every level: debug, info, warn, error. The rule is one — PII must not reach any of them.
A common mistake: making a log "for diagnostics" with real data.
// Bad — PII in the log
this.logger.log(`User registered: email=${cmd.email} phone=${cmd.phone}`);
// Good — internal id only
this.logger.log({ customerId: result.customerId }, 'customer registered');
If diagnostics are really needed — use masking: show part of the data but not all of it.
// shared/pii-masking.ts
export function maskEmail(email: string): string {
if (!email?.includes('@')) return '***';
const [local, domain] = email.split('@');
return `${local[0]}***@${domain}`;
}
export function maskPhone(phone: string): string {
if (!phone || phone.length < 4) return '***';
return `***${phone.slice(-4)}`;
}
// Diagnostics with masking — acceptable
this.logger.log(
{ customerId: result.customerId, emailMask: maskEmail(cmd.email) },
'email verification sent',
);
Systemic protection with nestjs-pino
Even if developers are careful, HTTP requests are logged in full automatically — including the body. nestjs-pino lets you define a list of fields to be cut out before the log is written. This works at the serializer level, even before output to stdout.
// app.module.ts
LoggerModule.forRoot({
pinoHttp: {
redact: {
paths: [
'req.headers.authorization',
'req.body.email',
'req.body.phone',
'req.body.password',
'req.body.customer.email',
'req.body.customer.phone',
],
censor: '[REDACTED]',
},
},
}),
If a domain object (an aggregate, an entity) is passed to the log directly, you need to override toJSON — this is exactly the method pino calls during serialization.
// core/customer/customer.ts
export class Customer {
constructor(
readonly id: string,
readonly email: string,
readonly phone: string,
readonly fullName: string,
) {}
toJSON() {
return { id: this.id }; // id only — no PII
}
}
Error messages and personal data
An exception's text goes straight into two places: the logs (when the exception is caught) and the response to the client (if the Exception Filter passes through err.message). Both paths are dangerous.
// Bad — the email gets into the log and the response
throw new BadRequestException(`Email ${email} already registered`);
// Good — the code only, without the value
throw new CustomerAlreadyExistsError();
A domain exception carries an error code but not data:
// core/customer/errors/customer-already-exists.error.ts
export class CustomerAlreadyExistsError extends DomainError {
readonly code = 'CUSTOMER_ALREADY_EXISTS';
constructor() {
super('Customer with given identity already exists');
}
}
If you need to log the context in the handler — mask it and pass it separately, not in the exception text:
async execute(cmd: RegisterCustomer): Promise<RegisterCustomerResult> {
const exists = await this.customers.existsByEmail(cmd.email);
if (exists) {
this.logger.warn(
{ requestId: cmd.requestId, emailMask: maskEmail(cmd.email) },
'duplicate registration attempt',
);
throw new CustomerAlreadyExistsError();
}
// ...
}
Exception Filter: mapping code to message
The Exception Filter is where the response to the client is formed. A common mistake is to substitute err.message or String(err) into the detail field. If data ever ends up in the exception text, it goes into the response.
The right approach: an explicit dictionary of codes → messages. No direct passing of text from the exception.
// adapters/in/http/filters/domain-exception.filter.ts
@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
catch(err: DomainError, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
const status = domainStatusMap[err.code] ?? 400;
res.status(status).json({
type: `urn:order:${err.code.toLowerCase().replace(/_/g, '-')}`,
title: domainTitleMap[err.code] ?? 'Operation failed',
detail: domainDetailMap[err.code] ?? 'Operation failed',
status,
errorCode: err.code,
});
}
}
const domainDetailMap: Record<string, string> = {
ORDER_NOT_FOUND: 'Order with given id not found',
ORDER_NOT_CANCELLABLE: 'Order in current status cannot be cancelled',
CUSTOMER_ALREADY_EXISTS: 'Customer with given identity already exists',
};
The global handler for uncaught exceptions — the same way:
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(err: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
this.logger.error({ err }, 'unhandled exception');
res.status(500).json({
type: 'urn:system:internal-error',
title: 'Internal server error',
detail: 'An unexpected error occurred',
status: 500,
});
}
}
What to never do in an Exception Filter:
// Bad — any of these strings can contain PII
res.json({ detail: err.message });
res.json({ detail: String(err) });
res.json({ stackTrace: err.stack });
PII in Kafka messages
Kafka is a broadcast channel: all consumers receive the full payload of an event. If customerEmail ends up in an event, it is seen by every service subscribed to the topic — regardless of whether they need that data.
// Bad — all consumers see PII
export class OrderConfirmedEvent {
orderId: string;
customerEmail: string; // leak
customerPhone: string; // leak
totalAmount: number;
}
// Good — identifier only
export class OrderConfirmedEvent {
orderId: string;
customerId: string;
totalAmount: number;
}
The notification service, which needs the email to send a letter, requests it from customer-service directly. This gives fine-grained access control and a log of requests on the data source side.
// adapters/out/http/customer.client.ts
async getCustomerContact(customerId: string): Promise<CustomerContact> {
const { data } = await this.http.get<CustomerContact>(
`/internal/customers/${customerId}/contact`,
{ headers: { Authorization: `Bearer ${await this.tokenProvider.getToken()}` } },
);
return data;
}
Secrets and environment variables
A secret in the code or in a config file that ends up in the repository is a leak. Even if you delete the commit from the branch, the repository may already have been cloned.
# Bad — password in git
database:
password: super-secret-prod
# Good — a reference to an environment variable
database:
password: ${DB_PASSWORD}
In NestJS secrets are read through @nestjs/config. Add schema validation at startup — that way the app fails immediately if a variable isn't set, rather than at the moment of the first database access.
// config/app-config.ts
import { plainToInstance } from 'class-transformer';
import { IsString, IsUrl, IsInt, validateSync } from 'class-validator';
export class AppConfig {
@IsString() DB_PASSWORD: string;
@IsUrl() JWKS_URI: string;
@IsString() CLIENT_SECRET: string;
@IsInt() PORT: number;
}
export function validateConfig(config: Record<string, unknown>) {
const validated = plainToInstance(AppConfig, config, { enableImplicitConversion: true });
const errors = validateSync(validated, { skipMissingProperties: false });
if (errors.length > 0) throw new Error(errors.toString());
return validated;
}
// app.module.ts
ConfigModule.forRoot({ validate: validateConfig, isGlobal: true }),
Where the values come from in production:
- Vault (HashiCorp) — the operator mounts the secret as an environment variable in the pod.
- SealedSecrets (Kubernetes) — an encrypted Secret in git that the controller decrypts in the cluster.
- Cloud Secret Manager — AWS Secrets Manager or GCP Secret Manager via the pod's IAM role.
- Kubernetes Secret — the basic level, if the rest is unavailable.
Add to .gitignore the files that must not end up in the repository:
.env
.env.local
.env.production
*.pem
*.key
If a secret did make it into the git history — rotate it immediately, even if you deleted the commit from the branch.
In short
- PII — email, phone, full name, address, IP — must not be written to logs at any level, including debug.
nestjs-pinowithredactpaths cuts sensitive fields out of HTTP logs before they are written to output.- For diagnostics use masking (
maskEmail,maskPhone), not full data. - An aggregate's
toJSONshould return only{ id }— that's whatpinoserializes into the log. - Exception texts must not contain user data — only the error code and a general description.
- The Exception Filter builds the response through an explicit dictionary of codes, not through
err.messageorString(err). - In a Kafka event — only the identifier (
customerId); the consumer requests PII itself when needed. - Secrets are stored in environment variables, Vault or a Secret Manager — never in code or config files in git.
- If a secret made it into git history — rotate it immediately.
What to read next
- Resource ownership (ABAC) — checking access rights to a specific object in the Handler.
- JWT validation — verifying the token via
passport-jwtandjwks-rsa. - RBAC and roles — the
RolesGuardand the@Rolesdecorator. - Service-to-service — service interaction without anonymous traffic.