Contact Us

Tell us about your stack and the privacy problems you're trying to solve. We typically respond within one business day.

Prefer email? support@philterd.ai

Please do not enter PII or PHI in this form. If you need to share an example, use a sanitized one.

← All posts

Redacting PII and PHI from Audio Transcriptions with Philter and Amazon Transcribe

Audio is one of the largest and least-governed sources of sensitive data a company holds. Call-center recordings, clinical dictation, support calls, sales calls, and recorded meetings all contain names, ages, account numbers, and other PII and PHI, spoken out loud and then quietly archived. The moment that audio is run through a speech-to-text service, every one of those identifiers becomes searchable text, and that text tends to flow straight into analytics, search indexes, data warehouses, and increasingly into LLMs.

Speech-to-text is now a one-line call. Amazon Transcribe turns an MP3 into a JSON transcript with a single API request, and most teams wire it up in an afternoon. The redaction step is the part that gets skipped, and it is the part that determines whether the resulting transcripts are safe to keep.

This post walks through a deployable pattern for that step, using Philter and the companion philterd/philter-transcriptions repository. You will run the local demo against a real transcript, see the exact before and after, and then look at two AWS architectures for doing the same thing at scale.

The approach: transcribe, then redact, then use

The pattern is simple and worth stating plainly:

[ Audio ] --> [ Amazon Transcribe ] --> [ raw transcript ] --> [ Philter ] --> [ redacted transcript ] --> downstream

Philter sits between the transcription service and every downstream consumer. Transcribe is responsible for turning speech into text. Philter is responsible for making sure that text is safe before anything else touches it. Nothing downstream (your warehouse, your search index, your model) ever sees the raw transcript, so nothing downstream has to be trusted with PHI.

The companion repository is a proof-of-concept that redacts medical transcriptions. Because real medical audio is hard to come by, and because the transcription itself is not the interesting part, the demo uses a sample transcript adapted from mtsamples.com (a diabetes SOAP note) that has been modified to include PII. The repository also ships the plumbing and architecture ideas for producing the transcripts from audio with Amazon Transcribe, so you can extend the demo end to end.

The local demo: docker compose up, then ./redact.sh

The fastest way to understand the pattern is to run it. The demo needs only Docker, curl, and jq.

Clone the repository:

git clone https://github.com/philterd/philter-transcriptions.git
cd philter-transcriptions

Start Philter:

docker compose up

The Compose file brings up two containers: Philter itself (on port 8080) and ph-eye, the named-entity model Philter uses to find people’s names. Philter mounts philter.properties and the policies/ directory, so the example_policy policy is available the moment the container is healthy.

Now redact a transcript:

./redact.sh

The script reads the transcript out of transcribe/job-output.json (an Amazon Transcribe-shaped JSON file) using jq -r .results.transcripts[].transcript, then posts that text to Philter’s /api/explain endpoint with the example_policy policy. The /api/explain endpoint returns the redacted text plus a full accounting of what was changed and why. (There is also a plain /api/filter endpoint, commented out in the script, that returns only the redacted text.)

Before

The raw transcript that comes out of the transcription step contains a patient’s age, a patient ID, and a name:

I am asked to see the 69 year old patient today with ongoing issues
around her diabetic control. Her patient ID is 231113490. We have been
fairly aggressively, downwardly adjusting her insulins ... I talked to
Anna today. She feels a little fatigued. Otherwise, she is doing well.

After

Here is the actual response from Philter, trimmed for readability:

{
  "filteredText": "I am asked to see the {{{REDACTED-age}}} patient today with ongoing issues around her diabetic control. Her patient ID is {{{REDACTED-id}}}. We have been fairly aggressively, downwardly adjusting her insulins, both the Lantus insulin, which we had been giving at night as well as her sliding scale Humalog insulin prior to meals ... I talked to {{{REDACTED-person}}} today. She feels a little fatigued. Otherwise, she is doing well.",
  "context": "none",
  "explanation": {
    "appliedSpans": [
      {
        "characterStart": 22,
        "characterEnd": 33,
        "filterType": "AGE",
        "confidence": 0.9,
        "text": "69 year old",
        "replacement": "{{{REDACTED-age}}}"
      },
      {
        "characterStart": 115,
        "characterEnd": 124,
        "filterType": "IDENTIFIER",
        "confidence": 0.9,
        "text": "231113490",
        "replacement": "{{{REDACTED-id}}}"
      },
      {
        "characterStart": 801,
        "characterEnd": 805,
        "filterType": "PERSON",
        "confidence": 0.9312306046485901,
        "text": "Anna",
        "replacement": "{{{REDACTED-person}}}"
      }
    ]
  },
  "tokens": 153
}

Two things are worth noticing.

First, the filteredText is the safe-to-keep version. Each identifier has been swapped for a typed marker: {{{REDACTED-age}}}, {{{REDACTED-id}}}, and {{{REDACTED-person}}}. The clinical content (the insulin adjustments, the glucose readings, the assessment) is fully preserved. That is the whole point. You keep the meaning and lose the identifiers.

Second, the explanation.appliedSpans array tells you exactly what Philter did. Each span records the character offsets, the filterType (AGE, IDENTIFIER, PERSON), the original text, the replacement, and a confidence score. The age and patient ID matched deterministic rules and report a fixed confidence of 0.9; the name was found by the ph-eye model and reports a model-derived confidence. This explainability is what makes redaction auditable: you can prove what was redacted, where, and why, which is exactly what a compliance review asks for.

Architecture 1: continuous processing

The demo redacts one transcript on your laptop. Production audio arrives continuously, so the first architecture in the repository is event-driven and near real time. New audio dropped into an S3 bucket triggers transcription, and a new transcript triggers redaction, with no schedule and no polling.

[ User/System ]
       |
       v (Upload MP3)
+------------------+
|  S3: Input Bkt   |--(Event: ObjectCreated)--> [ Lambda A: Transcriber ]
+------------------+                                    |
                                                        | (Start Job)
                                                        v
                                             +-----------------------+
                                             |   Amazon Transcribe   |
                                             +----------+------------+
                                                        |
       (JSON Output) <-----------------------------------+
             |
             v
+------------------+
| S3: Raw JSON Bkt |--(Event: ObjectCreated)--> [ Lambda B: Redactor ]
+------------------+                                    |
                                                        | (Filter PII)
                                                        v
                                             +------------------------+
                                             | S3: Final Redacted Bkt |
                                             +------------------------+

The flow has four moving parts:

  1. A user or system uploads an MP3 to the input S3 bucket.
  2. The ObjectCreated event fires a transcriber Lambda, which starts an Amazon Transcribe job. Transcribe writes its JSON output to a raw-JSON bucket.
  3. That write fires a redactor Lambda, which posts the transcript text to Philter (the same /api/filter call the demo uses).
  4. Philter returns the redacted text, and the Lambda writes it to a final redacted bucket.

The raw transcripts can be lifecycled aggressively or kept in a tightly locked-down bucket, because only the redacted bucket feeds anything downstream. This is the right shape when you need transcripts available within minutes of a call ending, for example to feed a near-real-time support analytics dashboard.

Architecture 2: batch processing

When transcripts arrive in bulk and latency does not matter, a scheduled batch job is simpler and cheaper to operate. The second architecture uses Amazon EventBridge to trigger an AWS Step Functions workflow on a schedule, and a Distributed Map state to fan out across every file in the bucket.

[ Scheduled Trigger ]
           |
           v
+-----------------------------+
|   Amazon EventBridge        |  <-- Runs once a month
|   (Cron: 0 0 1 * ? *)       |
+--------------+--------------+
               |
               v
+-----------------------------------------------------------+
|                  AWS STEP FUNCTIONS                        |
|  (Distributed Map / Orchestration)                        |
|                                                           |
|  1. List Objects in S3 Input Bucket                       |
|  2. For Each File Found:                                  |
|     +-----------------------------------------------+     |
|     |  [ Choice State ]                             |     |
|     |  Is file extension .mp3? ---(No)---> [ Skip ] |     |
|     |         |                                     |     |
|     |       (Yes)                                   |     |
|     |         v                                     |     |
|     |  [ Transcribe Task ]                          |     |
|     |  StartTranscriptionJob()                      |     |
|     +---------+-------------------------------------+     |
+---------------|-------------------------------------------+
                |
                v
+------------------------------+      +-------------------------+
|      AMAZON TRANSCRIBE        |      |    AMAZON S3 BUCKETS     |
|                              |      |                         |
|  - Processes MP3s in Batch   +----->| [ Output Bucket ]       |
|  - Manages internal queue    |      | (transcripts.json)      |
+------------------------------+      +-------------------------+

EventBridge fires on a cron schedule (the example runs on the first of every month). Step Functions lists the input bucket, and the Distributed Map iterates the objects, skipping anything that is not an MP3 and starting a transcription job for the rest. Transcribe processes the batch and writes transcripts to the output bucket, where the same redaction step from Architecture 1 takes over.

The trade-off between the two is the usual one. Continuous processing gives you low latency at the cost of more event wiring; batch processing gives you simpler operations and easy throughput control at the cost of latency. Both share the same redaction stage, so the policy and the Philter deployment are identical either way. The repository ships rendered versions of both diagrams if you want the source images.

Policies decide what gets redacted

What Philter redacts is not hard-coded. It is driven by a policy, and the policy is where the HIPAA and PHI angle lives. The demo’s example_policy enables exactly the identifiers the sample transcript contains:

  • age is redacted with a REDACT strategy and a {{{REDACTED-%t}}} format, which is what produces the {{{REDACTED-age}}} marker.
  • A custom patient-ID identifier matches a nine-digit pattern (^\d{9}$) and redacts it as {{{REDACTED-id}}}. This is the mechanism for facility-specific identifiers that no generic detector would know about.
  • person is detected by the ph-eye model and redacted as {{{REDACTED-person}}}.
  • ssn is enabled too, ready for any transcript that contains one.

For real clinical or call-recording workloads you would extend the policy toward the identifiers HIPAA Safe Harbor calls out: dates, phone numbers, email addresses, geographic detail, medical record numbers, and the rest. Each entity type gets its own strategy, so you can mask some values, redact others, and apply format-preserving encryption where a downstream system still needs a stable token. The full set of identifiers, strategies, and policy options is documented in the Philter policy documentation. Because the policy is a plain file, you can version it in Git, review changes in a pull request, and run it through Philter Scope to measure precision and recall before you ship it.

Where this fits with LLMs

Redacted transcripts are often the input to a model. Summarizing support calls, extracting structured fields from clinical dictation, and answering questions over meeting transcripts are all common, and all of them send transcript text to an LLM. Redacting at the transcription stage means the text reaching the model is already clean, but if you also want a guardrail at the model boundary itself, the Philter AI Proxy sits in front of an LLM API and redacts prompts (and re-inserts originals on the way back where appropriate) so that PII never reaches the provider in the first place. The two stages compose: scrub at transcription, and enforce again at the model boundary.

Get started

The fastest path is to run the demo and read its output for yourself:

Audio is too rich a source of sensitive data to leave ungoverned. Transcribe it, redact it, then use it, and keep the raw transcripts out of everything downstream.