Secure Logging and Privacy — What Must Never Reach a Log File
Learn exactly what categories of data must never appear in application logs — passwords, tokens, session IDs, private keys, full payment card numbers, and unnecessary personal data — and how to build redaction and masking into a logging pipeline so sensitive data is structurally prevented from leaking into logs, rather than relying on developers remembering not to log it.
Logs exist to help you debug and monitor an application, and that same usefulness is exactly what makes them dangerous: a log line that captures 'everything about this request' for debugging convenience often captures a password, a session token, or a customer's personal data right along with it — and logs tend to be retained for a long time, shipped to third-party log aggregation services, and readable by a much wider set of people (support staff, on-call engineers, anyone with dashboard access) than the systems that generated the data ever intended. A single overly broad log statement — 'log the full request body on error' — can turn a minor bug into a data breach. This lesson covers what must never appear in a log, how to build redaction into the logging pipeline itself so it's structural rather than a matter of individual developer discipline, and what safe, structured logging looks like in practice.
What must never appear in a log, and why each one is dangerous
Passwords — plaintext or hashed — should never be logged. A plaintext password in a log is an immediate, total compromise of that account (and, since people reuse passwords, often others too); even a password hash in a log is worth avoiding, since it needlessly widens where an offline cracking attempt could be mounted from, and there's simply no legitimate debugging reason to ever need it. Session IDs and authentication tokens (API keys, JWTs, OAuth bearer tokens, 'remember me' tokens) are functionally equivalent to a password for the duration they're valid — anyone who reads one from a log can impersonate that user or service for as long as the token remains valid, which for a long-lived API key can be indefinitely.
Private keys (TLS private keys, SSH keys, signing keys, encryption keys) must never be logged — a key that appears in a log has to be treated exactly like a leaked key anywhere else: rotated immediately, because logs are copied, shipped to aggregators, retained for months, and read by more people than the original secret ever was. Full payment card numbers are both a severe security risk and typically a direct compliance violation (PCI-DSS explicitly prohibits storing or logging full card numbers) — payment processors give you a tokenized reference or, at most, the last four digits for exactly this reason, and that's all that should ever reach a log. Finally, unnecessary personal data — full request/response bodies that happen to contain a user's home address, phone number, government ID, or health information — should be logged only when a specific debugging or business need actually requires it, not by default, because every field logged is a field that now lives in a system with weaker access controls than wherever it was collected.
• Passwords: never, plaintext or hashed — no legitimate debugging need ever justifies it. • Session IDs and auth tokens (API keys, JWTs, bearer tokens): functionally equal to a password for their validity window. • Private/signing/encryption keys: treat a key found in a log exactly like a leaked key — rotate it immediately. • Full payment card numbers: a PCI-DSS violation and a severe risk on their own — log a token or last-four digits only, never the full number. • Unnecessary PII (addresses, phone numbers, government IDs, health data): log only what a specific, real need requires, never 'the whole request body' by default.
Redaction and masking: building it into the logging pipeline, not developer memory
Relying on every developer to remember 'don't log the password field' on every log statement they ever write does not scale and reliably fails eventually — the more robust approach is to build redaction into the logging pipeline itself, so sensitive fields are structurally stripped or masked before a log line is ever written or transmitted, regardless of which developer wrote the call site. A common pattern is a logging processor or middleware that runs on every log record before it's emitted: it walks the structured data (not a raw string — see the next section), matches field names against a deny-list (password, token, secret, authorization, ssn, credit_card, and their common variants), and replaces matched values with a fixed placeholder like '[REDACTED]' rather than passing them through.
For values that are useful to correlate in logs without fully exposing them — a card's last four digits, a partial token for matching a specific request across log lines — masking (showing only a safe fragment, like ** ** 1234) is preferable to either full exposure or full redaction. Logging frameworks and libraries (Monolog in the PHP ecosystem, via a custom processor) support exactly this: register a redaction processor once, globally, and every log call — present and future, written by any developer — is automatically covered, which is a fundamentally stronger guarantee than a code-review checklist item asking reviewers to catch it by eye.
• Redaction belongs in the logging pipeline (a processor/middleware run on every record), not in developer discipline at each call site. • Match sensitive field names against a deny-list (password, token, secret, authorization, ssn, card_number, ...) and replace their values before emitting. • Masking (e.g. last-four digits, partial token) is useful for correlation when full redaction would remove needed debugging context. • In PHP, a Monolog processor is the standard mechanism — register once, applies to every subsequent log call automatically.
Structured logging, done safely
Structured logging — emitting log entries as key-value data (commonly JSON) rather than free-form text strings — is both a debugging improvement and a security improvement, because structured fields are exactly what a redaction processor can reliably inspect and act on; a deny-list can match a field literally named password in a structured record with certainty, but has to rely on fragile regex guessing against a free-form string like 'user login failed for password=hunter2'. This is a strong argument for structured logging on security grounds alone, independent of its usual justification (easier querying/filtering in a log aggregation tool).
The other half of safe structured logging is discipline about what gets put into the structured payload in the first place: log identifiers (a user ID, a request ID, an order ID) rather than the sensitive data itself wherever an identifier is enough to look up more detail later through a properly access-controlled system, log the outcome of a security-relevant event (login succeeded/failed, for which user ID, from which IP) without logging the credential that was checked, and treat 'log everything about this object' helpers (a framework's automatic exception/request dumping) with real suspicion — they're exactly the mechanism that tends to serialize an entire request, headers and all, including an Authorization header or a password field, into a log by default unless it's been explicitly configured not to.
• Structured (JSON/key-value) logs let a redaction processor match field names reliably; free-form string logs make that fragile at best. • Log identifiers (user ID, request ID) instead of the sensitive data itself, when an identifier is enough to look further up through an access-controlled system. • Log security-relevant outcomes (login failed for user X) without logging the credential that was checked. • Be suspicious of automatic 'dump the whole object/request' debug helpers — they're the most common accidental source of a leaked credential in logs.
A Monolog redaction processor applied globally
Registering the processor once with pushProcessor() covers every log call made afterward — no individual log statement needs to remember to redact anything. Matching by key-name substring (stripos) catches variants like 'user_password' or 'x-api-key' without needing an exhaustive exact list. This only protects structured context data — a raw, hand-built string passed as the log message itself still needs the developer not to interpolate a secret into it directly.
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\LogRecord;
final class RedactSensitiveFieldsProcessor
{
private const SENSITIVE_KEYS = [
'password', 'passwd', 'secret', 'token', 'authorization',
'api_key', 'session_id', 'private_key', 'card_number', 'ssn',
];
public function __invoke(LogRecord $record): LogRecord
{
$record->context = $this->redact($record->context);
return $record;
}
private function redact(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->redact($value);
continue;
}
foreach (self::SENSITIVE_KEYS as $sensitive) {
if (stripos((string) $key, $sensitive) !== false) {
$data[$key] = '[REDACTED]';
break;
}
}
}
return $data;
}
}
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('php://stdout'));
$logger->pushProcessor(new RedactSensitiveFieldsProcessor()); // applies to every future log callCode Examples
The log entry captures everything actually needed to detect brute-force attempts or investigate a specific login (who, when, from where, success or failure) without the password ever being passed into the logger at all — it isn't redacted after the fact, it's structurally never included in the first place, which is the strongest form of protection.
// Deliberately absent: the password/credential that was checked.
Common mistake: Logging $_POST or the full request body 'for debugging' on a login endpoint captures the plaintext password alongside everything else, every single time.
The first handler's 'log everything about the request for context' instinct is exactly how an Authorization header or a login form's password field ends up sitting in plaintext in an error-tracking system — the second handler keeps enough to debug (path, a correlating request ID, the exception itself) while structurally excluding headers and the raw body, even paired with the redaction processor as defense in depth. This is precisely the pattern that even a global redaction processor can only partially save you from — getallheaders() and a raw request body are unstructured enough that redaction by field name is unreliable, so the safest fix is not collecting them into the log payload in the first place.
declare(strict_types=1);
function logLoginAttempt(
\Psr\Log\LoggerInterface $logger,
string $userId,
bool $success,
string $ipAddress
): void {
$logger->info('login_attempt', [
'user_id' => $userId,
'success' => $success,
'ip' => $ipAddress,
// Deliberately absent: the password/credential that was checked.
// 'user_id' is enough to look up the account through access-controlled systems
// if further investigation is needed.
]);
}
function attemptLogin(\Psr\Log\LoggerInterface $logger, string $userId, string $password): bool
{
$success = verifyPassword($userId, $password); // hypothetical: checks against a stored hash
logLoginAttempt($logger, $userId, $success, $_SERVER['REMOTE_ADDR'] ?? 'unknown');
return $success;
}declare(strict_types=1);
// DANGEROUS default some error handlers use for 'helpful' debug context:
set_exception_handler(function (\Throwable $e) use ($logger) {
$logger->error('unhandled_exception', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request' => [
'headers' => getallheaders(), // includes Authorization: Bearer <token>
'body' => file_get_contents('php://input'), // may include a password field
],
]);
});
// SAFER: log only what's needed to debug, explicitly excluding sensitive request data.
set_exception_handler(function (\Throwable $e) use ($logger) {
$logger->error('unhandled_exception', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request_path' => $_SERVER['REQUEST_URI'] ?? null,
'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null,
// headers and raw body deliberately NOT included
]);
});How a real incident like this usually happens
A very common real-world pattern: a developer adds a debug log line while chasing down a login bug — 'log the whole $_POST array so I can see what's coming in' — fixes the bug, and forgets to remove the log line. Weeks later, every login attempt's plaintext password is sitting in a log aggregation service (Datadog, Splunk, CloudWatch) that dozens of engineers, and sometimes third-party support contractors, have read access to, with a retention policy that keeps it there for months. A global Monolog redaction processor, code review specifically looking for raw $_POST/$_REQUEST/getallheaders() calls anywhere near a logger, and a policy of logging identifiers instead of raw payloads are exactly the layers that catch this before it becomes an incident report.
$logger->debug('Incoming request', ['body' => $request->getBody()->getContents()]);
$logger->debug('Incoming request', ['path' => $request->getUri()->getPath(), 'request_id' => $requestId]);
Logging the full request body 'for debugging' captures whatever fields the client happened to send — including a login form's password, a payment form's card number, or an API request's bearer token — the instant such a request comes through, with no filtering at all.
$logger->error('Payment failed', ['card_number' => $cardNumber, 'cvv' => $cvv]);
$logger->error('Payment failed', ['card_last_four' => substr($cardNumber, -4), 'reason' => $failureReason]);
A full card number (and especially a CVV, which must never be stored or logged at all per PCI-DSS) in a log is both a severe security exposure and a direct compliance violation — the last four digits are enough to help a support agent identify the card without ever exposing the full number.
Which of the following should NEVER appear in an application log, under any circumstances? A. A user ID associated with a login attempt. B. The HTTP status code returned for a request. C. A plaintext password, a session ID, a private key, or a full payment card number. D. The timestamp a request was received.
C. A plaintext password, a session ID, a private key, or a full payment card number. — Passwords, session IDs/tokens, private keys, and full card numbers each grant an attacker who reads the log direct impersonation, decryption, or fraud capability — there is no legitimate debugging need that justifies logging any of them.
Why is building redaction into the logging pipeline (a global processor) better than relying on each developer to avoid logging sensitive fields? A. It makes logs load faster in a viewer. B. It applies automatically to every log call, present and future, rather than depending on every developer remembering every time. C. It removes the need for structured logging entirely. D. It's required by PHP's error_log() function by default.
B. It applies automatically to every log call, present and future, rather than depending on every developer remembering every time. — A pipeline-level redaction processor covers every log statement automatically and consistently, which is fundamentally more reliable than hoping every developer, on every log call, remembers not to include a sensitive field.
Why is structured (JSON/key-value) logging preferable to free-form string logging from a security standpoint? A. Structured logs are always shorter than string logs. B. A redaction processor can reliably match sensitive data by field name in structured data, whereas free-form strings require fragile pattern-guessing. C. Structured logs cannot be read by log aggregation tools, which limits who can see them. D. Structured logging automatically encrypts the log file on disk.
B. A redaction processor can reliably match sensitive data by field name in structured data, whereas free-form strings require fragile pattern-guessing. — A structured record with a field literally named password can be matched and redacted with certainty; a free-form string like 'login failed for password=hunter2' requires guessing at regex patterns, which is far less reliable.
What is the safest way to log a payment card number for later customer support reference? A. Log the full card number so support can verify it exactly. B. Log the card number along with the CVV for completeness. C. Log only the last four digits (or a tokenized reference), never the full number or the CVV. D. Log the card number encoded in base64 instead of plaintext.
C. Log only the last four digits (or a tokenized reference), never the full number or the CVV. — Full card numbers must never be logged (a severe risk and a PCI-DSS violation), and CVVs must never be stored or logged at all — the last four digits or a payment processor's tokenized reference give enough identifying information without the exposure. Base64 is encoding, not protection — it's trivially reversible.
Extend the RedactSensitiveFieldsProcessor from the syntax example so that, instead of fully redacting a field matched as 'card_number', it masks it to show only the last four digits (e.g. '** ** 1234') while still fully redacting every other sensitive field on the deny-list. Keep the recursive handling for nested arrays.
private function redact(array $data): array { foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = $this->redact($value); continue; } if (stripos((string) $key, 'card_number') !== false && is_string($value)) { $last4 = substr($value, -4); $data[$key] = "** ** {$last4}"; continue; } foreach (self::SENSITIVE_KEYS as $sensitive) { if (stripos((string) $key, $sensitive) !== false) { $data[$key] = '[REDACTED]'; break; } } } return $data; }
Give card_number its own branch before the generic deny-list loop, using substr() on the value's last four characters, and fall through to the existing full-redaction logic for every other sensitive key.