Handling PII/PHI: Redaction, Logging, Error Reporting, and Data Retention
PII/PHI handling for healthcare apps: log redaction, PHI-safe error reporting, field-level encryption, and automated data retention, with production code.
![Unredacted and redacted log lines side by side, with the SSN replaced by [REDACTED]](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F7q8e1huy%2Fproduction%2F477ad448bc5f1710d7a0674409cc161f43984b09-1920x1080.png%3Fw%3D1600%26fit%3Dmax%26auto%3Dformat&w=3840&q=75)
Healthcare software runs on the most sensitive data a person can generate: names, Social Security numbers, diagnoses, substance-use history. PII/PHI handling is the discipline of keeping that data out of the places it doesn't belong: logs, error reports, backups, exports, and analytics. This guide covers the four controls that matter most (redaction, logging, error reporting, and data retention) using a HIPAA and 42 CFR Part 2 compliant patient-intake platform for substance-use-disorder (SUD) treatment centers as the running example. You'll get the production code, the trade-offs behind it, and the gaps we're still closing.
Quick Answer
The reliable way to handle PII/PHI is to stop sensitive data from reaching logs and error reports in the first place. Redact at the logger with a denylist for nested fields and an allowlist for top-level fields, keep Sentry's sendDefaultPii set to false and attach only request method, URL, and error type, encrypt searchable fields such as SSNs with AES-256-GCM plus a lookup hash, and enforce retention automatically with log-group retention windows, S3 lifecycle rules, and scheduled hard deletes.
What Is PII/PHI Handling?
PII (personally identifiable information) is anything that identifies a person or can reasonably be linked to one: name, email, phone, SSN, date of birth. PHI (protected health information) is individually identifiable health information (diagnoses, medications, insurance, treatment history, payment for care) held or transmitted by a HIPAA covered entity or business associate. Context decides the category: a phone number on its own is PII, but in the platform it's PHI because it sits on a treatment record.
PII/PHI handling is four controls working together:
- Redaction masks sensitive fields before they reach any log sink.
- Logging stays structured, injection-safe, and time-boxed.
- Error reporting produces crash data and alerts that carry no PHI.
- Data retention gives every copy of PHI an enforced deletion deadline.
The Problem
Sensitive data rarely leaks through the primary database. It leaks through ordinary operations:
- A support engineer greps logs for a patient and gets name, date of birth, and SSN on one line.
- A Sentry issue contains a full request body, insurance ID included, copied from the error context.
- A soft-deleted patient record still appears in list queries because nothing filters on
deletedAt. - An intake-form PDF stays in S3 indefinitely because
DeleteObjectreturned204for a key that didn't match. The reference is gone, the object isn't, and no retention clock is running. - An audit log stores the old and new value of every edited field, creating a second copy of PHI that outlives the record it describes.
SUD treatment raises the stakes. 42 CFR Part 2 requires patient consent for most disclosures of SUD records, and a leak can cost a clinic its funding and a patient their job or housing.
The Solution
No single control is sufficient. The platform layers four of them, each running before data reaches a system that doesn't need the original values, with a values-free audit trail across all of them.

One caveat: in the platform this is fully implemented in the Node.js intake server. Our Spring Boot and Django services still log without PII scrubbing; that migration is on the roadmap, and the patterns below apply to any stack.
Prerequisites
- A structured logger: Winston or Pino (Node.js), Logback (Java), or
structlog(Python) - An error tracker such as Sentry
- Object storage with lifecycle rules, such as AWS S3
- Key management: environment variables at minimum; a KMS-backed key with rotation for production
- A written data retention policy. You can't automate what you haven't defined.
Step-by-Step Implementation
Step 1: Classify your data before you log anything
You can't redact what you haven't inventoried. In the patient data model, the sensitive surface includes personalInfo (name, DOB, phone, address), ssnEncrypted and ssnHash, medicalHistory, mentalHealth, insurance, substanceUseDisorder, and the HIPAA consent flag in metadata.customFields. The working rule: if you wouldn't paste a value into a public spreadsheet, classify it as PII/PHI. Keep the classification in the repo; it drives your denylist, encryption targets, and retention categories.
Step 2: Redact at the logger, not at call sites
PII redaction has to happen inside the logger. Call sites are easy to forget; a format hook runs on every entry. In the platform, every Winston entry passes through a format that runs maskSensitiveData() over the whole info object before any transport sees it:
const SENSITIVE_FIELDS: Set<string> = new Set([
'name', 'firstName', 'lastName', 'fullName', 'email',
'token', 'authorization', 'password', 'ssn', 'ssnHash',
'ssnEncrypted', 'phone', 'alternativePhone', 'address',
'dateOfBirth', 'accessToken', 'refreshToken', 'personalInfo',
'medicalHistory', 'mentalHealth', 'insurance',
// ... 40+ fields total
]);
export const maskEmail = (email: string): string => {
if (typeof email !== 'string' || !email.includes('@')) return email;
const [localPart, domain] = email.split('@');
if (localPart.length <= 2) return `${localPart[0]}***@${domain}`;
const visibleChars = Math.min(3, Math.floor(localPart.length / 2));
return `${localPart.substring(0, visibleChars)}***@${domain}`;
};
// Top-level log fields survive ONLY if whitelisted — default-deny.
const whitelist = [
'clinicUserEmail', 'clinicUserId', 'method', 'responseTime',
'url', 'statusCode', 'requestId', 'message', 'level',
'clientIp', 'userId',
];
const redactFormat = winston.format((info) => {
const masked = maskSensitiveData(info);
return {
...info,
...masked,
message: typeof masked.message === 'string'
? masked.message
: JSON.stringify(masked.message),
};
});Two mechanisms work together. The denylist walks nested objects recursively and replaces any matching key (ssn, phone, dateOfBirth) with [REDACTED]. The allowlist governs top-level fields: anything not explicitly approved is dropped, so a new field is inert until someone reviews it. Clinic staff emails are partially masked (joh***@clinic.com) so support can correlate activity without seeing full addresses.
Step 3: Encrypt the fields that must stay searchable
Some PHI has to remain queryable. Staff need to find a patient by SSN, so the SSN can't be an opaque blob. The application encrypts with AES-256-GCM, stores a salted SHA-256 hash alongside it for lookups, and runs encryption in a Mongoose pre('save') hook so plaintext never reaches the database layer:
export function encryptSSN(ssn: string): { encryptedSSN: string; iv: string } {
const key = Buffer.from(process.env.ENCRYPTION_KEY || '', 'hex');
if (!process.env.ENCRYPTION_KEY) {
throw new UnauthorizedError('Encryption key is not configured'); // fail closed
}
if (key.length !== 32) throw new UnauthorizedError('Invalid encryption key length');
const iv = crypto.randomBytes(16); // fresh IV per save — ciphertext never repeats
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(ssn, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// stored as base64(IV || ciphertext || authTag)
const combined = Buffer.concat([iv, Buffer.from(encrypted, 'hex'), authTag]);
return { encryptedSSN: combined.toString('base64'), iv: iv.toString('base64') };
}
export function hashSSN(ssn: string): string {
const salt = process.env.SSN_HASH_SALT || '';
return crypto.createHash('sha256').update(ssn + salt).digest('hex');
}
// In the Patient model — encrypt before every save, find by hash, never by plaintext
PatientSchema.pre('save', function () {
if (!this.isModified('personalInfo.ssnEncrypted')) return;
const ssn = this.personalInfo?.ssnEncrypted;
const result = ssn ? processSSN(ssn) : null;
if (result && this.personalInfo) {
this.personalInfo.ssnEncrypted = result.encryptedSSN;
this.personalInfo.ssnHash = result.ssnHash;
}
});The platform has a test asserting that the same SSN produces different ciphertext on every save, so a database dump reveals nothing about duplicate records. And the service fails closed: a missing or wrong-length ENCRYPTION_KEY throws instead of storing plaintext.
Two improvements are on our list. GCM's recommended IV length is 12 bytes (96 bits); 16 works in Node.js but 12 is the standard choice. And an SSN has only about a billion possible values, so a plain salted SHA-256 can be brute-forced by anyone who obtains the salt. HMAC-SHA256 with a secret key held in KMS is the better construction for a lookup hash.
Step 4: Harden the log pipeline and set retention windows
User-controlled strings interpolated into log messages are a log-injection vector. A newline in an address field can forge a fake log entry. The platform strips CR/LF before interpolation:
export const sanitizeForLog = (value: unknown): string =>
String(value).replace(/[\r\n]/g, '');Every sink then gets an explicit retention window. Winston's DailyRotateFile keeps local files for 14 days at 20 MB each. The ECS service's CloudWatch log group sets retention_in_days = 30, and Lambda log groups take a log_retention_days variable. Request logs upload to S3 every five minutes via node-cron, and audit logs ship to S3 when they rotate:
const UPLOAD_INTERVAL = '*/5 * * * *'; // every 5 minutes
const s3Key = `${S3_REQUEST_LOG_PREFIX}${timestamp}.json`;
await uploadFileToS3(LOG_FILE_PATH, s3Key);
// On rotation, ship the old audit file to S3 before it ages out
auditLogCleanup = onLogRotate(async (oldFilename: string) => {
const s3Key = `${S3_AUDIT_LOG_PREFIX}${path.basename(oldFilename)}`;
await uploadFileToS3(oldFilename, s3Key);
});Short windows turn a forgotten log group into a storage-cost problem rather than a compliance problem. The S3 copies need their own lifecycle rule too; shipping logs to a bucket with no expiration just moves the problem.
Step 5: Report errors without leaking PHI
Sentry's sendDefaultPii option defaults to false, and the application sets it explicitly so a config change or copied snippet can't flip it silently. With it off, the SDK doesn't attach user IPs, cookies, or request bodies automatically. Expected validation errors are dropped entirely so real signal isn't buried:
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
beforeSend(event, hint) {
// Validation errors are expected user input errors — don't page anyone
if (event.exception?.values?.[0]?.type === "ValidationError") return null;
return event;
},
sendDefaultPii: false,
});If you're on a recent SDK, check the Sentry configuration options: sendDefaultPii is being deprecated in favor of a more granular dataCollection option, and your config should be reviewed during upgrades.
Every uncaught exception goes through one central Express handler. Sentry receives method, URL, and error type, never the raw body. The local log receives error details that still pass through maskSensitiveData(). Stack traces are dropped in production:
const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction): void => {
const logContext = {
requestId: crypto.randomUUID(),
path: req.path,
method: req.method,
errorName: error.name,
errorMessage: error.message,
stack: config.env === "development" ? error.stack : undefined, // stripped in prod
};
if (config.sentry.enabled) {
Sentry.captureException(error, {
contexts: { request: { method: req.method, url: req.originalUrl || req.url } },
tags: { errorType: error.name },
});
}
logger.error({ ...logContext, errorType: "UnhandledError" });
};The client should receive a generic body with a code and request ID, never the exception message:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong. Please try again.",
"requestId": "req_7b21"
}
}Step 6: Enforce data retention automatically
A PHI data retention policy that depends on someone remembering doesn't exist. The platform enforces retention in three places.
S3 lifecycle rules. Pipeline artifacts such as voicemail recordings and merged PDFs expire after 30 days, noncurrent versions after 7, and incomplete multipart uploads after 7:
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
rule {
id = "expire-old-artifacts"
status = "Enabled"
expiration { days = 30 }
noncurrent_version_expiration { noncurrent_days = 7 }
abort_incomplete_multipart_upload { days_after_initiation = 7 }
}
}Soft delete with metadata. The Patient model carries metadata.deletedAt and metadata.deletedBy, set by the soft-delete handler, with an isDeleted() helper for filtering.
Hard delete for permanent removal. hardDeletePatient() calls Patient.deleteOne() when a clinic's retention policy or a valid deletion request requires the record to go.
The honest gaps: the PHI upload bucket has no lifecycle rule yet, so documents are retained until someone acts, and the soft-delete handler carries a compliance TODO because deleted records aren't filtered from every query. Both are scheduled. The S3 delete path also documents an orphaned-object hazard: DeleteObject returns 204 whether or not the key exists, so a percent-encoding mismatch can silently strand PHI in the bucket. We treat that as a retention bug, not a tidiness issue.
Make deletion verifiable: record the job, policy version, data category, and outcome without copying the deleted data. For media disposal, follow NIST SP 800-88 Rev. 2.
Step 7: Audit without copying PHI
HIPAA expects you to know who accessed or changed what, and when. But an audit log that stores field values is just another copy of PHI. The application's PatientFieldEditEvent is deliberately values-free and append-only:
export interface IPatientFieldEditEvent extends Document {
clinicId: mongoose.Types.ObjectId;
patientId: mongoose.Types.ObjectId;
fieldPath: string; // schema path, never the payload
wasEmptyBefore: boolean;
editedBy?: mongoose.Types.ObjectId;
editedAt: Date;
saveId: string;
}Status transitions follow the same rule: patientId, previousStatus, and restoredBy, never clinical content. At the storage layer, the PHI bucket ships S3 server access logs to a central log bucket, so object-level access is auditable even when it bypasses the application.
Common Problems and Errors
"I added a denylist but PII is still in my logs." Usually nested payloads or keys with unexpected names. Mask in the logger's format hook, which sees every field, and back the denylist with a top-level allowlist so unknown keys are dropped.
"Sentry is still capturing PII with `sendDefaultPii: false`." Check breadcrumbs, console capture, manual Sentry.setUser() calls, and exception messages that embed user input. Scrub in beforeSend, enable server-side data scrubbing as a backstop, and inspect real outgoing events using synthetic test data.
"Newlines in user input are forging log entries." That's log injection. Strip CR/LF from interpolated values before they reach a message, or log user values only as separate structured fields.
"Soft-deleted records are still being returned." Soft delete only works if every query filters on it. Apply the filter in a default query middleware rather than per endpoint, and back it with a scheduled hard-delete job.
"PHI objects pile up in S3 after deletes." A 204 from DeleteObject doesn't prove the object existed. Confirm with a HeadObject check, add a lifecycle backstop to every PHI bucket, and alert on objects with no database reference.
Best Practices
- Default-deny top-level log fields. An allowlist of safe keys beats a denylist of dangerous ones because new fields stay inert until reviewed.
- Encrypt, then hash, for searchable PHI. AES-256-GCM for storage, a keyed hash for lookup, a fresh IV per write, and fail closed when the key is missing.
- Generate correlation IDs at request entry. Create the request ID in middleware so every log line and the error response share it, and never use an email or MRN as an ID.
- Strip stack traces in production. They leak file paths and dependency versions, and sometimes request context.
- Keep audit logs value-free. Record which field changed, who changed it, and when, never the old or new value.
- Automate retention for every copy. Log groups, S3 buckets (including the ones your logs ship to), backups, and exports all need enforced expiry.
- Make redaction regressions break the build. automated tests that SSN ciphertext changes on every save and that no edit event contains a field value.
Security Considerations
Key management is the linchpin. The application reads a 32-byte hex ENCRYPTION_KEY from the environment and validates its length on every operation. At scale, move to a KMS-backed key with rotation and audited access to key material, and store the hash key separately from the encryption key.
For SUD data under 42 CFR Part 2, track consent on the record itself (the platform stores hipaaConsent in patient metadata) and keep presigned URLs for PHI downloads to one hour or less. Treat Sentry and CloudWatch as PHI-adjacent systems: least-privilege access, and a reviewed Business Associate Agreement for any vendor that might receive diagnostic data. Masking alone also doesn't make data de-identified; if a dataset leaves your system for analytics, follow the HHS de-identification guidance.
Trade-offs
These controls cost something, and the cost lands on the people who debug and operate the system.
Debugging gets harder. With masked logs and sendDefaultPii: false, you can't see the value that broke a request. You work from correlation IDs, field names, and error types, and reproduce the failure with synthetic data. Some bugs take longer to find.
The allowlist hides new fields. A field that isn't on the allowlist is dropped from logs until someone reviews it, so a new feature can ship with less logging than its developer expected.
Encrypted fields limit queries. A lookup hash supports exact matches only. Partial matches, range queries, and sorting on an encrypted SSN aren't possible, and every read and write pays for an encrypt or decrypt.
Keys become critical infrastructure. Lose the encryption key and the encrypted fields are unreadable. Rotation, access control, and backup of key material are ongoing work.
Short retention removes evidence. A 14-day or 30-day log window protects patients, but an incident discovered after the window has fewer logs to investigate. Decide which audit records you keep longer, and keep them value-free.
Hard deletes can't be undone. Automated deletion must respect state medical-record retention laws, so the policy needs legal review before the job runs.
For an internal tool with no health data, a denylist at the logger and sensible retention windows may be enough. Field-level encryption, tokenization, and per-category retention earn their cost once you store PHI, and Part 2 data needs all of them.
Alternatives Compared
| Approach | How it works | When it wins | Key risk |
|---|---|---|---|
| Denylist + allowlist at the logger | Mask nested fields by denylist; allow only approved top-level keys | Most applications; no per-call-site discipline needed | Denylist must evolve with the schema |
| Redact at each call site | Developers sanitize before every log call | Very small codebases | One forgotten call site is one leak |
| Tokenization | Replace PHI with tokens; mapping held in a separate vault | Analytics and reporting pipelines | The vault becomes a high-value target |
| Encryption at rest only | Encrypt the database, log freely | Not recommended on its own | Plaintext in logs bypasses encryption entirely |
| Managed discovery and masking (AWS Macie, CloudWatch Logs data protection) | Provider scans stored data and flags or masks it | Large estates, post-incident cleanup | Detects leaks after they happen; cost at scale |
When Should You Use This?
This PII/PHI handling pattern is the right choice whenever your system stores patient data, insurance details, SSNs, or anything else that counts as PHI under HIPAA, and the stricter variant applies to SUD and behavioral-health records under 42 CFR Part 2.
Use managed discovery tools such as AWS Macie as a complement, never a replacement: they find leaks after data lands, while boundary controls stop it landing. Early in a healthcare build, add these controls before the first production deploy.
FAQ
Is logging PHI a HIPAA violation?
Not automatically, but it creates risk. HIPAA requires PHI in any system, logs included, to be protected with access controls, audit controls, and integrity safeguards, and limited to the minimum necessary. Debug logs full of names and diagnoses rarely meet that standard. Log identifiers and actions, not clinical content.
What is the difference between PII and PHI?
PII is any information that identifies or can be linked to a person, such as a name, email, or SSN. PHI is individually identifiable health information (diagnoses, treatment, insurance, payment for care) held by a HIPAA covered entity or business associate. Identifiers become PHI when they're tied to health information in that context.
How long should healthcare application logs be retained?
HIPAA doesn't set a retention period for application debug logs. Its six-year requirement applies to required security documentation such as policies, procedures, and records of required activities, and many teams keep audit logs on that schedule. The application keeps local logs for 14 days and CloudWatch logs for 30, with audit logs archived to S3.
Should I encrypt PII at the application or database level?
Both, for different threats. Application-level field encryption means the database, its replicas, and its backups never hold plaintext for that field. Storage-level encryption at rest protects against stolen disks and snapshots. Field-level encryption is the stronger control for high-risk fields like SSNs.
What does sendDefaultPii do in Sentry?
It controls whether the Sentry SDK automatically attaches personal data such as user IP addresses, cookies, and request bodies to events. It defaults to false. Set it explicitly to false in healthcare apps and pass only non-identifying context, such as request method, URL, and error type.
How do I handle a request to delete patient data?
First confirm it can be deleted: HIPAA doesn't grant a general right to deletion, and state medical-record laws often require retention. If deletion is permitted, soft-delete to remove the record from use, then hard-delete the record and its stored files, verifying each S3 object is actually gone and recording the deletion without copying the data.
Conclusion
PII/PHI handling isn't a feature you ship once; it's a property of every layer that touches patient data. Redact at the logger so nothing sensitive reaches a sink, encrypt searchable fields with AES-256-GCM and a lookup hash, send errors with sendDefaultPii: false and no production stack traces, and enforce retention with log-group windows, S3 lifecycle rules, and scheduled hard deletes, all backed by an audit trail that records who changed what without storing the value. Build these controls in from the start and compliance becomes a by-product of the architecture.
Building a Healthcare Application?
If you need a team that treats PII/PHI handling as architecture rather than paperwork, talk to us about healthcare app development. We design, build, and audit HIPAA and 42 CFR Part 2 compliant platforms, from patient intake to reporting.
References
- OWASP Logging Cheat Sheet
- OWASP Error Handling Cheat Sheet
- HHS HIPAA Security Rule
- HHS Guidance on De-identification of PHI
- 42 CFR Part 2
- Sentry JavaScript configuration options
- AWS S3 lifecycle configuration
- NIST SP 800-66 Rev. 2: Implementing the HIPAA Security Rule
- NIST SP 800-88 Rev. 2: Guidelines for Media Sanitization

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


