It happens like this: a developer adds request logging and writes the user's email into it — to make it easier to search the logs. Or puts the database password straight into a configuration file. Or adds the customer's phone number into a Kafka event, "so the notification doesn't have to make an extra request."
Each of these cases is a leak. An email in the logs ends up in monitoring systems, a Kafka event is read by all subscribed services, and a password in the git history stays there forever — even after the commit is deleted.
Let's look at exactly where personal data and secrets leak in a Go application and how to prevent it.
What PII is and why it matters
PII (Personally Identifiable Information) is data by which a specific person can be identified. This is email, phone number, full name, address, passport details, IP address.
Regulators (GDPR, 152-FZ, and others) require protecting this data: controlling who has access to it, where it's stored, and to whom it's passed. A leak through a log, through an API response, or through Kafka is an incident that can turn into a fine and a loss of user trust.
In a Go application, personal data most often leaks in three places:
slogattributes — logs go to collection systems that store everything for a long time;- error strings (
error.Error()) — errors end up in logs and may go into the API response; - Kafka events — a broadcast channel, all consumer groups receive the payload.
Personal data not in slog attributes
The most common mistake is to pass PII directly into a log attribute:
// BAD — the email ends up in the logs
slog.InfoContext(ctx, "order created",
slog.String("customer_email", cmd.CustomerEmail),
slog.String("order_id", order.ID),
)
// GOOD — only the identifier
slog.InfoContext(ctx, "order created",
slog.String("customer_id", order.CustomerID),
slog.String("order_id", order.ID),
)
This rule works at all levels: Debug, Info, Warn, Error. Even slog.Debug can end up in a centralized logging system that several people look at.
If the email is still needed for diagnostics, it can be masked:
// core/pii/mask.go
package pii
import "strings"
func MaskEmail(email string) string {
at := strings.IndexByte(email, '@')
if at < 1 {
return "***"
}
return string(email[0]) + "***@" + email[at+1:]
}
func MaskPhone(phone string) string {
if len(phone) < 4 {
return "***"
}
return "***" + phone[len(phone)-4:]
}
slog.DebugContext(ctx, "email verification sent",
slog.String("customer_id", customer.ID),
slog.String("email_mask", pii.MaskEmail(customer.Email)), // u***@example.com
)
Masking functions are placed in core/pii/ — this package is available to all layers without circular imports.
The danger of slog.Any with an aggregate
If you pass a struct with PII fields through slog.Any, slog will call fmt.Sprintf("%+v", c) — and all fields end up in the log. To prevent this, implement the slog.LogValuer interface:
type Customer struct {
ID string
Email string
Phone string
FullName string
}
func (c Customer) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", c.ID),
)
}
Now slog.Any("customer", c) will output only id.
Personal data not in error strings
An error string (error.Error()) is what ends up in the log, gets passed up the call stack, and may end up in the API response. If email or phone gets in there, the leak will be hard to trace.
// BAD — PII in the error string
type CustomerNotFoundError struct {
Email string
}
func (e *CustomerNotFoundError) Error() string {
return fmt.Sprintf("customer not found: email=%s", e.Email) // leak
}
// GOOD — only the identifier
type CustomerNotFoundError struct {
CustomerID string
}
func (e *CustomerNotFoundError) Error() string {
return fmt.Sprintf("customer not found: id=%s", e.CustomerID)
}
The error struct holds only the aggregate's id — not email, not phone, not full name. Order identifiers and statuses aren't PII, so they can be included:
type OrderNotCancellableError struct {
OrderID string
Status string
}
func (e *OrderNotCancellableError) Error() string {
return fmt.Sprintf("order %s cannot be cancelled in status %s", e.OrderID, e.Status)
}
Safe error rendering in the HTTP response
Another leak point is the API response. If you write detail: err.Error(), internal details end up at the client: messages from the database, the call stack, the names of internal services.
The right approach is to split the error into an "internal" one (for logs) and a "client-facing" one (pre-written text):
// adapters/in/http/httperr/render.go
package httperr
type problemDetail struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Code string `json:"code,omitempty"`
}
func toProblem(err error) problemDetail {
var domErr *apperr.DomainError
if errors.As(err, &domErr) {
return problemDetail{
Type: "urn:domain:" + domErr.Code,
Title: "Domain rule violated",
Status: http.StatusUnprocessableEntity,
Detail: domErr.UserMessage, // pre-written text, not err.Error()
Code: domErr.Code,
}
}
// technical errors — only a generic phrase
return problemDetail{
Type: "urn:internal",
Title: "Internal server error",
Status: http.StatusInternalServerError,
Detail: "an unexpected error occurred",
}
}
UserMessage is a field the developer writes when declaring a domain error:
// core/apperr/domain.go
type DomainError struct {
Code string
UserMessage string
}
func (e *DomainError) Error() string { return "domain: " + e.Code }
// core/order/errors.go
var ErrOrderNotFound = &apperr.DomainError{
Code: "ORDER_NOT_FOUND",
UserMessage: "Order with given id not found",
}
The client gets a clear message. err.Error() with internal details doesn't make it into the response.
Personal data not in Kafka events
Kafka is a broadcast channel: all registered consumer groups receive every message. If an event contains email or phone, they go to all consumers — even those that don't need this data.
// BAD — all consumer groups see the PII
type OrderConfirmedEvent struct {
OrderID string `json:"order_id"`
CustomerEmail string `json:"customer_email"` // leak
CustomerPhone string `json:"customer_phone"` // leak
TotalAmountKop int64 `json:"total_amount_kop"`
}
// GOOD — only the identifier
type OrderConfirmedEvent struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
TotalAmountKop int64 `json:"total_amount_kop"`
}
The notification service, which needs the email to send a letter, requests it separately:
func (c *Client) GetContactInfo(ctx context.Context, customerID string) (ContactInfo, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/customers/"+customerID+"/contact", nil)
tok, _ := c.tokenSrc.Token()
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
// ... decode ContactInfo{Email, Phone}
}
This way the source service logs pinpoint access to PII — who requested it, when, and for which customer. This is manageable and verifiable.
Secrets not in the repository
Secrets — the database password, API keys, the OAuth client_secret — must not be stored in code or in configuration files that end up in git.
The right way in Go is to read secrets from environment variables through envconfig:
// cmd/app/config.go
import "github.com/kelseyhightower/envconfig"
type Config struct {
DB struct {
DSN string `envconfig:"DB_DSN,required"`
Password string `envconfig:"DB_PASSWORD,required"`
}
S2S struct {
ClientID string `envconfig:"S2S_CLIENT_ID,required"`
ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
TokenURL string `envconfig:"S2S_TOKEN_URL,required"`
}
}
func loadConfig() (Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return Config{}, fmt.Errorf("config: %w", err)
}
return cfg, nil
}
The required tag guarantees that the application won't start if the variable isn't set. There's no risk of accidentally launching prod without a secret.
In .gitignore you need to explicitly exclude files with secrets:
.env
.env.local
*.pem
*.key
*-secret.yml
application-prod.yml
For local development it's convenient to use a .env file with godotenv:
// cmd/app/main.go
import "github.com/joho/godotenv"
func main() {
_ = godotenv.Load() // silently ignores the absence of .env in production
cfg, _ := loadConfig()
// ...
}
In production, environment variables are set through Kubernetes secretKeyRef or Vault Agent Injector.
Important: if a secret ended up in the git history, it must be rotated immediately. git reset or git filter-repo won't help — the value may already have been obtained from a fork, a CI cache, or a direct clone.
Common mistakes
slog.String("email", email) — instead use slog.String("customer_id", id) or a masked value through pii.MaskEmail.
PII in error.Error() — the error struct holds only the aggregate's identifier, not its personal fields.
err.Error() in problem.detail — instead use a pre-written UserMessage or the generic phrase "an unexpected error occurred".
PII fields in a Kafka event — instead only customer_id; PII is requested pinpoint by whoever needs it.
A secret in code or a YAML file — instead use envconfig from an environment variable.
slog.Any("customer", c) without LogValue() — slog will output all struct fields; you need to implement slog.LogValuer.
In short
- PII — email, phone, full name, address — must not be written into
slogattributes at any logging level. - If diagnostics are needed — mask via
pii.MaskEmail/pii.MaskPhonefromcore/pii/. - Error structs hold only identifiers, not PII fields.
err.Error()doesn't make it intoproblem.detail— only a pre-writtenUserMessageor a generic phrase.- Kafka events contain only
customer_id; email and phone are requested by the service that needs them. - Secrets are read from environment variables through
envconfig; therequiredtag won't let the application start without them. - A secret in the git history — rotate it immediately, deleting the commit doesn't help.
What to read next
- Auditing admin commands — how to write to the audit journal without PII in plain fields.
- JWT validation — token validation and mapping to 401 without revealing the reason.
- Resource ownership (ABAC) —
ForbiddenErrorwithout PII in the string. - Service-to-service —
oauth2.TokenSourceand safe token passing between services.