← Back to the section

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:

  • slog attributes — 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 slog attributes at any logging level.
  • If diagnostics are needed — mask via pii.MaskEmail / pii.MaskPhone from core/pii/.
  • Error structs hold only identifiers, not PII fields.
  • err.Error() doesn't make it into problem.detail — only a pre-written UserMessage or 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; the required tag won't let the application start without them.
  • A secret in the git history — rotate it immediately, deleting the commit doesn't help.