Case Study
An automated, auditable pipeline that redacts protected health information from clinical text against HIPAA's Safe Harbor standard — built to demonstrate secure operationalization of an existing detection engine, not novel PHI detection.
At a glance
Why it exists
Healthcare has been the costliest industry for data breaches for over a decade, and most PHI exposure happens downstream of the originating hospital — through third-party vendors, analytics platforms, and AI pipelines that received the data secondhand. This pipeline sits at exactly that handoff point: making clinical text safe to share before it leaves the organization that holds it.
It deliberately does not try to build a better PHI detector than AWS already offers. AWS Comprehend Medical's DetectPHI does the entity detection. The engineering problem this project solves is everything around that call: a defensible confidence threshold, a regex backstop for a measured detection gap, an encrypted review path for low-confidence entities, a way for the Operator who submitted a note to actually get it back, and a security posture that's verified live rather than just written down.
Architecture
Browser (Operator)
│ TLS
▼
Upload Backend ──POST /upload──▶ input-notes bucket (SSE-KMS, Lambda-only)
│ │ S3 event
│ ▼
│ Pipeline Lambda ──DetectPHI, TLS──▶ Comprehend Medical
│ │
│ ┌───────────────────┴───────────────────┐
│ ▼ ▼
│ redacted-output bucket (SSE-KMS) review-artifacts bucket (client-side encrypted)
│ │ │
└──GET /notes/{id}───┘ kms:Decrypt ▼
5-min presigned URL Reviewer
▼
Downstream consumer / Operator download
Decisions worth knowing about
The requirements said from day one that a missed identifier is a compliance failure and an over-redacted word is just noise — but that's a stance, not a threshold. It was resolved with a cost-sensitive formula, t* = C_FP / (C_FP + C_FN), using a 1000x cost ratio grounded in Health Canada's clinical-anonymization guidance and confirmed against three rounds of corpus testing on the live API. The math resolves to 0.001 — and the calibration assumption that could break it is stated plainly rather than glossed over.
POST /upload returned 202 and nothing else — no identity the Operator held had read access to the redacted output. Closed with GET /notes/{note_id}: the frontend polls, then hands back a presigned download link that expires after 5 minutes. Content is deliberately never returned inline — a captured link goes stale; captured response text never does. The route reuses the existing Cognito authorizer rather than standing up a new identity-pool trust relationship just to hand browsers temporary AWS credentials, and a missing note and a still-processing one return the identical 404 so polling can't be used to confirm whether an ID was ever valid.
Comprehend Medical is trained on US clinical text and phone formats. Measured directly: an Australian mobile number in spaced format comes back truncated, and in international format isn't detected at all. A regex backstop for AU mobile formats was layered in and unioned with the API's own entities — closing the measured gap, not attempting to out-detect AWS.
S3 → pipeline Lambda, S3 → CloudFront, API Gateway → two separate backend Lambdas, and CloudTrail → its own log bucket all needed the same shape: a resource policy scoped to one caller ARN, not the broader service principal alone. Treating it as one recurring idiom — including resolving a genuine circular dependency on the fifth instance — is what let each subsequent case go in correctly the first time.
The first hardening pass removed a Cognito auth flow, reasoning it closed a refresh-token pathway. Checked against AWS's own documentation: that setting doesn't govern the API family this app actually uses. The real fix — cutting refresh-token lifetime from 30 days to 1 hour — was applied once that was understood, and the change record says so honestly rather than claiming the wrong fix worked.
Every access-control layer governed whether someone could decrypt a flagged review entity; nothing recorded whether they actually did. CloudTrail pricing was checked against AWS's current rates before designing anything, then scoped narrowly on purpose — one bucket, read events only. Verified live: a real reviewer's access produced a log entry within 5 minutes.
Testing
140 passing tests on the latest verified run, including two intentionally-failing xfail(strict=True) cases that pin a known Comprehend Medical limitation so the suite fails loudly, not silently, if AWS ever changes that upstream behaviour. Both handlers that once had no coverage were checked for whether their tests could actually fail before being trusted — which caught a check that was verifying the wrong thing.
GitHub Actions runs the test suite on every PR and every push to main. From main, the Lambda functions deploy once those tests pass, and the frontend deploys through its own workflow — authenticated via OIDC, with no long-lived AWS keys stored anywhere. Infrastructure changes go through Terraform, by hand, never from CI.
Known limitations — documented, not hidden
ADDRESS entity type produces occasional false positives on phrases like "physiotherapy department" — accepted as noise, consistent with the FR-4 cost-ratio decision.