← Back to the section

Among all security mistakes, a leak of personal data stands apart: its consequences are not visible right away, but the scale can be enormous — regulatory fines, loss of user trust, and mandatory notification of everyone affected. Let's look at where data leaks most often and how to prevent it.

What PII is

PII (Personally Identifiable Information) is personal data that can be used to identify a specific person. Russia's Federal Law 152-FZ and the European GDPR classify the following as PII:

  • email, phone number;
  • full name, date of birth;
  • address, passport data;
  • IP address, device identifiers;
  • biometric data.

The key rule: PII must not leave the layer that works with it. It must not end up in logs, in error messages, or be sent to third-party services without a genuine need.

PII in logs

The most common leak happens through logging. A developer writes something "for debugging" and forgets to remove it, and the logs are shipped to a centralized store that several teams have access to.

// Bad — email and phone end up in the log
log.info("User registered: email={} phone={}", user.email(), user.phone());

// Good — only the internal identifier
log.info("User registered: userId={}", user.id());

// If you need diagnostics — mask the data
log.info("Email verification sent: userId={} emailMask={}",
    user.id(), maskEmail(user.email()));  // u***@example.com

Masking is easy to implement once in a utility class and reuse everywhere:

public final class PiiMasking {
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) return "***";
        var parts = email.split("@");
        return parts[0].charAt(0) + "***@" + parts[1];
    }

    public static String maskPhone(String phone) {
        if (phone == null || phone.length() < 4) return "***";
        return "***" + phone.substring(phone.length() - 4);
    }
}

Another hidden trap is an object's toString() method. If you write log.info("User: {}", user), Slf4j will call user.toString(), and if it contains fields with PII, they will end up in the log.

public record Customer(Long id, String email, String phone, String fullName) {
    @Override
    public String toString() {
        return "Customer[id=" + id + "]";  // only id, no personal data
    }
}

PII in exception text

A similar problem is embedding data into an exception message. It might seem that an exception is an internal matter of the application. But it quickly becomes public: through logs, through debug output, and — most importantly — through the API response.

// Bad — email ends up in the exception text
throw new InvalidEmailException("Email " + email + " is invalid format");

Here is what happens:

  1. The exception lands in the log — PII in the log.
  2. The error handler (RestControllerAdvice) takes ex.getMessage() and puts it into the detail field of the response.
  3. The user (or an attacker) sees confirmation that such an email does or does not exist.

The correct approach is to use error codes without data in the text:

public class InvalidEmailException extends DomainException {
    public InvalidEmailException() {
        super("INVALID_EMAIL_FORMAT", "Provided email is in invalid format");
    }
}

The exception text holds a general description without the specific value. If you need diagnostics, log a separate line with the masked value.

A safe error handler

RestControllerAdvice is the central place where all the application's exceptions converge. It is easy to accidentally pass internal details through to the client here.

// Bad — taking the exception text directly
problem.setDetail(ex.getMessage());           // may contain PII
problem.setDetail(ex.getCause().getMessage()); // and the cause even more so
problem.setProperty("stackTrace", ex.getStackTrace()); // the full code structure

A safe implementation is an explicit mapping of the error code to pre-written text:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderDomainException.class)
    public ProblemDetail handleOrderDomain(OrderDomainException ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setType(URI.create("urn:order:domain"));
        problem.setTitle("Order operation failed");
        problem.setDetail(switch (ex.errorCode()) {
            case "ORDER_NOT_FOUND" -> "Order with given id not found";
            case "ORDER_NOT_CANCELLABLE" -> "Order in current status cannot be cancelled";
            default -> "Order operation failed";
        });
        problem.setProperty("errorCode", ex.errorCode());
        return problem;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleGeneric(Exception ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setTitle("Internal server error");
        problem.setDetail("An unexpected error occurred. Reference: " + MDC.get("requestId"));
        return problem;
    }
}

The client gets a clear message with no internal details. A requestId is enough to search the logs.

PII in messages between services

Kafka topics are a broadcast channel: an event is seen by every consumer subscribed to the topic. If you include a customer's email or phone in an event, they immediately reach every service that processes that event.

// Bad — every consumer of the topic sees the personal data
public record OrderConfirmedEvent(
    Long orderId,
    String customerEmail,   // leak
    String customerPhone    // leak
) {}

// Good — only the identifier, data is requested when needed
public record OrderConfirmedEvent(
    Long orderId,
    Long customerId,
    Money totalAmount
) {}

If the notification service needs the email, it requests it directly from customer-service: GET /customers/{id}/email. This gives targeted access and a full log of who requested the data and when.

Secrets not in git

Database passwords, external-service keys, tokens — all of these are called secrets. The main mistake is to put them straight into a configuration file and commit them to the repository.

# Bad — the secret will end up in git history
spring:
  datasource:
    password: super-secret-password-prod

# Good — a reference to an environment variable
spring:
  datasource:
    password: ${DB_PASSWORD}

The problem with git is that even a deleted commit stays in history, in forks, and in the CI cache. If a secret has made it into the repository, it must be considered compromised and rotated immediately, not just removed from the file.

Where to really store secrets:

  • HashiCorp Vault — a dedicated secrets store with access management, rotation, and auditing. Spring Cloud Vault integrates directly.
  • Kubernetes SealedSecrets — the secret is encrypted in the repository and decrypted by an operator inside the cluster.
  • Cloud Secret Manager (AWS Secrets Manager, GCP Secret Manager) — a managed cloud service; the pod receives the secret through an IAM role.
  • Environment variables — the basic option that works everywhere.

Add to .gitignore the files that must never reach the repository:

application-prod.yml
application-secrets.yml
*.pem
*.key
.env

An extra layer of protection is commit-check hooks. Tools like git-secrets and trufflehog scan changes before a commit and block it if they find strings that look like passwords or tokens.

In short

  • PII is email, phone, full name, address, and similar data. It must not be written to logs at any level, not even DEBUG.
  • For diagnostics, mask it: u***@example.com, ***1234. Use a single utility class.
  • Override toString() on objects with PII so that only the identifier is printed.
  • Keep exception text free of data values. Only error codes and general descriptions.
  • RestControllerAdvice — explicit code mapping, no ex.getMessage() in detail.
  • In Kafka events — identifiers only. If you need the data, request it through the specific service's API.
  • Never keep secrets in git. Use environment variables or dedicated stores.
  • A secret that has made it into the repository history must be considered compromised and rotated.
  • Logging and observability — the full rules for logging.
  • Error handling and RFC 9457 — how to build error responses correctly.
  • Kafka: event design — what to include in events.
  • Auditing administrative commands — how to record actions without leaking data.