Imagine a developer adds logger.info("User registered: email=%s", user.email) — just one line. Six months later the log aggregator is caught in a breach, and users' email addresses end up in the open. That is what a typical PII leak looks like — one innocent line that later costs the company a fine and lost trust.
Let's look at what PII is, why this data must not appear in logs, responses, and message queues, and how to correctly store an application's secrets.
What PII is and why it matters
PII (Personally Identifiable Information) is data that can identify a specific person: email, phone, full name, address, passport details, IP address, biometrics.
Regulators (GDPR, 152-FZ, and others) treat PII as protected data. A leak through a log, an HTTP response, or a message queue is a security incident with all the consequences: an audit, a fine, notifying users.
The main rule: PII must not leave the layer that needs it to work. Logs, HTTP error responses, and events in a queue are not that place.
PII in logs
Here is a common mistake made in the first days of working with a new service:
import logging
logger = logging.getLogger(__name__)
# Never do this
logger.info("User registered: email=%s phone=%s", customer.email, customer.phone)
# Correct — only the internal identifier
logger.info("User registered: customer_id=%s", customer.id)
Logs go to aggregators (ELK, Datadog, Loki), are often accessible to a wide circle of people on the team, and are sometimes exported to third-party analytics systems. Personal data accumulates there unnoticed.
If diagnostics really do need something from the user, use masking — show only part of the value:
# domain/pii_masking.py
def mask_email(email: str) -> str:
if not email or "@" not in email:
return "***"
local, domain = email.split("@", 1)
return local[0] + "***@" + domain
def mask_phone(phone: str) -> str:
if not phone or len(phone) < 4:
return "***"
return "***" + phone[-4:]
# Acceptable for diagnostics
logger.info("Email verification sent: customer_id=%s email_mask=%s",
customer.id, mask_email(customer.email)) # u***@example.com
__str__ and __repr__ of objects with PII
Another hidden trap: if a class with personal data does not override __str__ and __repr__, then logger.info("%s", customer) will automatically print all the fields.
from dataclasses import dataclass
@dataclass
class Customer:
id: str
email: str
phone: str
full_name: str
def __repr__(self) -> str:
return f"Customer(id={self.id!r})"
def __str__(self) -> str:
return f"Customer[id={self.id}]"
Now accidental logging of the object will not reveal personal data.
Structured logs (structlog)
If you use structlog, watch what ends up in the request context:
import structlog
log = structlog.get_logger()
# Correct
log.info("order_created", order_id=order.id, customer_id=order.customer_id)
# Incorrect
log.info("order_created", order_id=order.id, customer_email=customer.email)
PII in exception text
Another common mistake is including a PII value directly in the exception text:
# Never do this
raise ValueError(f"Email {email} is invalid format")
What happens to this text afterwards:
logger.exception(...)writesstr(exc)to the log — and the email ends up there.- An exception handler returns
detail=str(exc)to the client — the user (or an attacker) sees confirmation of the address.
The correct approach is domain exceptions with an error code, without a PII value:
# domain/customer/errors.py
class CustomerDomainError(Exception):
def __init__(self, code: str, message: str) -> None:
self.code = code
self.message = message
super().__init__(message)
class InvalidEmailFormatError(CustomerDomainError):
def __init__(self) -> None:
super().__init__(
code="INVALID_EMAIL_FORMAT",
message="Provided email is in invalid format",
)
The message "Provided email is in invalid format" describes the problem but does not name the address itself. If diagnostics are needed — a separate log with mask_email(email), not through the exception.
The exception handler in FastAPI
The exception handler is a place where it is especially easy to accidentally "slip" PII into the response to the client.
A typical mistake:
# Never do this
return JSONResponse(
status_code=400,
content={"detail": str(exc)}, # may contain PII
)
return JSONResponse(
status_code=500,
content={"detail": str(exc.__cause__)}, # internal system details
)
return JSONResponse(
status_code=500,
content={"traceback": traceback.format_exc()}, # code structure exposed
)
The correct way is an explicit mapping of the error code to a safe message:
# adapters/in/http/exception_handlers.py
from fastapi import Request
from fastapi.responses import JSONResponse
from domain.order.errors import OrderDomainError
async def order_domain_exception_handler(
request: Request,
exc: OrderDomainError,
) -> JSONResponse:
detail = {
"ORDER_NOT_FOUND": "Order with given id not found",
"ORDER_NOT_CANCELLABLE": "Order in current status cannot be cancelled",
}.get(exc.code, "Order operation failed")
return JSONResponse(
status_code=400,
content={
"type": "urn:order:domain",
"title": "Order operation failed",
"detail": detail,
"errorCode": exc.code,
},
)
async def generic_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
request_id = request.headers.get("X-Request-Id", "unknown")
return JSONResponse(
status_code=500,
content={
"title": "Internal server error",
"detail": f"An unexpected error occurred. Reference: {request_id}",
},
)
Registration in the application:
from fastapi import FastAPI
from domain.order.errors import OrderDomainError
from adapters.in.http.exception_handlers import (
order_domain_exception_handler,
generic_exception_handler,
)
app = FastAPI()
app.add_exception_handler(OrderDomainError, order_domain_exception_handler)
app.add_exception_handler(Exception, generic_exception_handler)
In the case of an unexpected error, the client receives a request_id — with it you can find the details in the logs inside the system without exposing them to the outside.
PII in Kafka events
Kafka is a broadcast channel. All subscribers, including those that appear a year from now, will see every message. If PII ends up there — it is stored in the topic until the retention expires and is accessible to any consumer in the system.
# Bad — all consumers see personal data
@dataclass(frozen=True)
class OrderConfirmedEvent:
order_id: str
customer_email: str # leak
customer_phone: str # leak
total_amount: Decimal
# Correct — only the identifier
@dataclass(frozen=True)
class OrderConfirmedEvent:
order_id: str
customer_id: str
total_amount: Decimal
If the notification service needs the email to send a message, it requests it directly from the customer service:
# adapters/out/http/customer_client.py
import httpx
from settings import Settings
class CustomerClient:
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
self._base = settings.customer_service_url
self._http = http
async def get_email(self, customer_id: str) -> str:
response = await self._http.get(
f"{self._base}/customers/{customer_id}/email",
)
response.raise_for_status()
return response.json()["email"]
This gives targeted access with an audit log on the customer service side — it is visible who requested the email and when.
Secrets not in code and not in git
Database passwords, API keys, client secrets — they must not be stored in code or in .env files committed to git.
# Never do this — a secret directly in the code
DB_PASSWORD = "super-secret-password-prod"
The correct approach is pydantic-settings reading values from environment variables:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
db_password: str
client_secret: str
jwks_uri: str
audience: str
issuer: str
The .env file with the real values is only local, in .gitignore:
.env
.env.local
.env.prod
*.pem
*.key
secrets.yaml
Where to get the values in production:
- HashiCorp Vault —
vault-secrets-operatoror thehvacPython client at startup. - SealedSecrets (Kubernetes) — an encrypted secret in git, decrypted by an operator in the cluster.
- Cloud Secret Manager (AWS Secrets Manager, GCP Secret Manager) — the pod's IAM role retrieves the secret directly.
- Kubernetes Secret — the minimal option to start with.
Protection against an accidental commit
Even with careful work, an accidental commit of a secret happens. detect-secrets finds secret-like strings before they get into the history:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
If a secret did end up in the git history — it must be changed immediately. Removing the commit does not help: it may be in forks, the CI cache, or already downloaded by someone.
In short
- PII — email, phone, full name, address, IP — must not be written to logs at any level (including
DEBUG). - For diagnostics use masking (
mask_email,mask_phone), not the address itself. - Override
__str__and__repr__on classes with personal data — otherwise accidental logging of the object will reveal everything. - Exceptions must not contain PII values — only an error code and a general message.
- The exception handler in FastAPI returns predefined text based on the error code, not
str(exc)or a stack trace. - Kafka events carry only the identifier; the service that needs the details requests them directly.
- Secrets in code and in git are unacceptable;
pydantic-settings+ environment variables + Vault/SealedSecrets/Cloud SM. - If a secret got into the git history — change it immediately, even if the commit was removed.
Further reading
- JWT validation in FastAPI — validating tokens via JWKS.
- RBAC: role mapping — roles from claims without PII in the authorities.
- ABAC: resource ownership — checking rights to a specific object.
- Audit of admin commands — an audit log without personal data.