PHI Validation — Protected Health Information Rejection
Overview
The PHI Validation middleware scans all inbound request bodies for 18 Protected Health Information identifiers and rejects requests with 422 Unprocessable Entity if any are detected. Purpose: Prevent accidental upload of PII/PHI into non-SUD-compliant fields. Rule: Better to reject and let user re-submit than to accept and later discover we've stored identifiable data.The 18 Identifiers (Safe Harbor, 45 CFR § 164.514)
Name-Related
- Patterns: "John Doe", "Doe, John", "JD", "Dr. Smith"
- Initials of Name — Single letters (J.D., M.R.)
- Pattern: Consecutive single letters (excluding common words)
- Street Address — Any US street (including apartment/suite)
- Patterns: "123 Main St", "P.O. Box 456"
- City — City/town name
- Patterns: "Seattle", "Los Angeles"
- State — State or province code
- Patterns: "WA", "CA", "WA"
- ZIP Code — Postal code
- Patterns: "98101-5555", "98101" (partial allowed if 3+ digits)
- Phone Number — Landline or mobile
- Patterns: "(206) 555-1234", "206.555.1234", "2065551234"
- Email Address — Any email
- Patterns: "john@example.com", "john.doe@acme.org"
- URL — Web address containing identifiable info
- Patterns: "http://john-doe.com" (personal domain)
- Medical Record Number (MRN) — Patient ID in medical system
- Patterns: 6–12 alphanumeric (e.g., "ACC123456")
- Health Insurance ID — Insurance member ID
- Patterns: Insurance claim numbers, member IDs
- Health Insurance Claim Number — Claim ID
- Patterns: Insurance-specific formats
- Biometric ID — Fingerprint, retinal scan records
- Patterns: Base64-encoded binary data (heuristic)
- Full-Face Photographic Image — Recognizable photo
- Patterns: Image file references, embedded Base64 images
- Genetic Information — DNA sequences, genetic markers
- Patterns: DNA sequences (ATCG patterns)
- Account Numbers — Bank, credit card, etc.
- Patterns: Visa/MC/Amex format, 12–19 digits
- Device Serial Numbers — Medical device IDs
- Patterns: Device-specific formats (context-dependent)
- Certificate/License Numbers — Professional license
- Patterns: State-specific formats (e.g., medical license "MD12345")
- Social Security Number — US federal ID (Critical)
- Pattern:
\b\d{3}-\d{2}-\d{4}\b - Also:
\b\d{9}\b(9 consecutive digits)
Contact Information
Medical Record Identifiers
Biometric & Genetic
Account & Device
Special: Social Security Number (SSN)
Rejection Algorithm
Step 1: Scan Request Body
// src/middleware/phi-validation.ts
const PHI_PATTERNS = {
ssn: /\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b/g,
mrn: /\b[A-Z]{2,4}\d{6,12}\b/g,
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
phone: /\b(\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
creditCard: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
zipCode: /\b\d{5}(?:-\d{4})?\b/g,
// ... (more patterns)
};
middleware.use((req, res, next) => {
const body = JSON.stringify(req.body);
for (const [identifier, pattern] of Object.entries(PHI_PATTERNS)) {
if (pattern.test(body)) {
return res.status(422).json({
status: 'error',
type: 'phi_detected',
message: Protected Health Information detected: ${identifier},
offending_identifier: identifier,
guidance: 'Remove all personally identifiable information and re-submit',
});
}
}
next();
});
Step 2: Return 422 Unprocessable Entity
If any PHI detected:{
"status": "error",
"type": "phi_detected",
"message": "Protected Health Information detected: phone",
"offending_identifier": "phone",
"details": {
"why": "Request contains patient phone number. Patient data must be de-identified.",
"what_to_do": [
"1. Review your request body",
"2. Remove phone numbers, SSNs, MRNs, and other patient identifiers",
"3. Re-submit with de-identified or aggregate clinical data only"
]
}
}
HTTP Status: 422 Unprocessable Entity (not 400 — indicates user data is present but not accepted)
Step 3: Let Request Through (Clean)
If no PHI detected, proceed to next middleware.SUD-Specific Validation (42 CFR Part 2)
For Substance Use Disorder data, additional rules:Rule 1: Consent Verification
IF data_origin_type = 'SUD' THEN
IF sharing_restrictions NOT IN ('consent_required', 'originator_only') THEN
REJECT with 422
Error Message:
{
"status": "error",
"type": "sud_compliance_violation",
"message": "SUD data requires explicit sharing restrictions",
"details": {
"data_origin_type": "SUD",
"required_field": "sharing_restrictions",
"allowed_values": ["consent_required", "originator_only"],
"current_value": "none"
}
}
Rule 2: Cross-Tenant SUD Data
// If precedent is marked SUD, it cannot be accessed by other tenants
// even if in marketplace
SELECT FROM precedent_objects
WHERE cluster_id = $1
AND tenant_id = $2
AND data_origin_type = 'non-SUD' // Only non-SUD visible to other tenants
Rule 3: SUD Audit Logging
Every access to SUD precedent is logged (separate audit stream):INSERT INTO precedent_audit_log (
precedent_id, event_type='accessed',
user_id, tenant_id,
event_data={
"is_sud": true,
"access_reason": "library_browseappeal_evidence
export"
}
)
Exceptions & Allowed Contexts
De-Identified Data (Allowed)
Clinical summaries with no identifiable information are allowed:{
"precedent_name": "Cigna IOP Frequency Limit Victory 2024",
"clinical_summary": "Patient with GAF score 35, functionally impaired, ongoing psychiatric need. Cigna denied continued IOP citing frequency limit. Appeal filed citing functional deterioration risk.",
"payer": "Cigna",
"outcome": "Won"
}
✅ Allowed: No names, DOBs, MRNs, SSNs, claim IDs
Clinical Context (Allowed)
Structured clinical data without identifiers:{
"gaf_score": 35,
"diagnosis": "Major Depressive Disorder",
"functional_impact": "Unable to manage symptoms independently",
"treatment_duration_days": 60,
"clinician_notes": "Patient demonstrates significant clinical deterioration risk if discharged"
}
✅ Allowed: Clinical facts without patient identity
Aggregate Data (Allowed)
Population-level statistics:{
"population_size": 1000,
"appeal_success_rate": 0.72,
"average_resolution_days": 14
}
✅ Allowed: No individual-level data
Middleware Integration
PHI validation runs immediately after request parsing, before any route handler:// src/server.ts
app.use(express.json());
app.use(phiValidationMiddleware); // <-- HERE (runs on all routes except /health, /registries)
app.use(tenantMiddleware);
app.use(deidentificationMiddleware);
app.use(auditMiddleware);
app.use('/api/precedents', precedentsRoutes);
app.use('/api/appeals', appealsRoutes);
// ...
Bypass Routes (Exemptions)
Some routes bypass PHI validation (no user input):/health — Health check (no body)/api/registries/ — Read-only reference data (no body)/api/marketplace/clusters — Query existing data (no patient data)POST /api/precedents — Creating precedent (must be de-identified)POST /api/appeals — Creating appeal (must be de-identified)POST /api/precedents/:id/update — Updating (must be de-identified)PUT /api/appeals/:id/save — Saving draft (must be de-identified)Logging & Monitoring
Log Rejected Requests
Every PHI rejection is logged:{
"timestamp": "2024-04-14T10:30:45Z",
"event_type": "phi_detection",
"status": "rejected",
"offending_identifier": "phone",
"request_path": "/api/precedents",
"user_id": "user-uuid",
"tenant_id": "tenant-uuid"
}
Purpose: Track if users are attempting to submit PHI (may indicate training need)
Monthly Report
SELECT
DATE_TRUNC('month', timestamp) AS month,
COUNT(*) AS rejections,
COUNT(DISTINCT offending_identifier) AS identifier_types,
COUNT(DISTINCT user_id) AS affected_users
FROM phi_rejection_log
GROUP BY month
ORDER BY month DESC;
Alert if: Rejection rate > 10% of submissions (training opportunity)
Testing
Test Case 1: SSN Detection
test('rejects request with SSN', async () => {
const response = await request(app)
.post('/api/precedents')
.send({
precedent_name: "Test",
patient_ssn: "123-45-6789" // <-- Should be rejected
});
expect(response.status).toBe(422);
expect(response.body.offending_identifier).toBe('ssn');
});
Test Case 2: Email Detection
test('rejects request with patient email', async () => {
const response = await request(app)
.post('/api/precedents')
.send({
precedent_name: "Test",
narrative: "Patient contacted at john.doe@gmail.com" // <-- Should be rejected
});
expect(response.status).toBe(422);
expect(response.body.offending_identifier).toBe('email');
});
Test Case 3: De-Identified Data Allowed
test('allows de-identified clinical summary', async () => {
const response = await request(app)
.post('/api/precedents')
.send({
precedent_name: "Test",
narrative: "Patient with GAF score 35, ongoing psychiatric need, functional impairment"
// No names, emails, SSNs, etc.
});
expect(response.status).toBe(201); // Should succeed
});
See also
Code reference
src/middleware/phi-validation.ts — PHI detection middlewaresrc/patterns-conventions/validation-patterns.md — Validation best practicessrc/routes/precedents.ts — POST endpoint (phi-validated)src/routes/appeals.ts — POST endpoint (phi-validated)