← Back to the section

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-pino with redact paths 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 toJSON should return only { id } — that's what pino serializes 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.message or String(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.