Encrypting PHI at Rest and in Transit in Clinical Pipelines

Protected health information moves through a clinical automation pipeline at every stage — a scanned consent form, a coded adverse event, a site’s enrollment record — and each stage is a place where it can be exposed if encryption is treated as a checkbox instead of a design constraint. This guide implements the two halves of that constraint concretely: envelope encryption for PHI fields at rest, where a data key wrapped by a key-management-service master key protects the data without ever writing the master key to disk, and enforced TLS for PHI in transit, with a logging discipline that guarantees plaintext PHI never reaches a log line. It sits under Security Boundaries for Clinical Data, part of Core Architecture & Regulatory Mapping for Clinical Trials, and the field-level encryption it applies is a prerequisite for everything the OCR pipeline extracts and the submission gateway eventually transmits.

Why naive approaches fail

Encryption implemented ad hoc, or bolted on after a schema is already in production, tends to fail in the same handful of ways:

  • A single static key encrypts everything, forever. Without a key-rotation story, one compromised key exposes every record ever encrypted with it, and rotating it later means either re-encrypting the entire dataset atomically or maintaining ambiguous key versioning that nobody documented.
  • The master key is stored next to the data it protects. A key read from the same database, the same disk, or the same configuration file as the ciphertext it decrypts provides no real protection — anyone with access to one has access to the other.
  • TLS is assumed rather than enforced. A client library that silently permits a plaintext fallback, or a misconfigured internal service that skips certificate verification “just for now,” turns transit encryption into a policy nobody actually checks at runtime.
  • PHI ends up in a log line during debugging. A stack trace that includes a request payload, or a debug log statement left in from development, writes a patient’s data into a log aggregation system with a completely different retention and access-control profile than the encrypted data store — an exposure with no ciphertext to show for it.

Architecture overview

A PHI field is encrypted with a freshly generated data key, which is itself wrapped by a key-management-service master key that never leaves the KMS; only the wrapped key travels with the ciphertext.

Envelope encryption pipeline Five stages left to right: plaintext PHI field, data key generation, field encryption, KMS key wrap, and stored envelope. Each stage feeds the next with a single arrow. PHI field Data key Encrypt KMS wrap Envelope

The property that makes this design rotatable is that the master key never touches the field data directly — it only ever wraps and unwraps small data keys. Rotating the master key means re-wrapping stored data keys, a cheap operation, instead of re-encrypting every PHI field in the dataset.

Setup and configuration

cryptography provides both the AES-GCM primitive for field encryption and the Fernet convenience wrapper used for the data key itself; the KMS client is whichever provider’s SDK is already in use for secrets.

pip install "cryptography>=42.0"

Configuration — including which KMS key id wraps data keys, and the minimum TLS version enforced for outbound calls — is read from the environment, never hardcoded:

"""Environment-driven encryption configuration."""
from __future__ import annotations

import os
import ssl
from dataclasses import dataclass


def required(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value


@dataclass(frozen=True)
class EncryptionConfig:
    kms_master_key_id: str
    minimum_tls_version: int = ssl.TLSVersion.TLSv1_2

    @classmethod
    def from_env(cls) -> "EncryptionConfig":
        return cls(kms_master_key_id=required("PHI_KMS_MASTER_KEY_ID"))

Full working implementation

Each PHI field gets its own freshly generated data key; the plaintext data key is used once to encrypt, then wrapped by the KMS master key, and only the wrapped key is retained alongside the ciphertext.

"""Envelope encryption for PHI fields at rest."""
from __future__ import annotations

import base64
import json
import logging
from dataclasses import dataclass
from typing import Protocol

from cryptography.fernet import Fernet

logger = logging.getLogger("security.phi_encryption")


class KmsClient(Protocol):
    """Wraps and unwraps data keys; the master key itself never leaves the KMS."""

    def wrap_key(self, master_key_id: str, plaintext_key: bytes) -> bytes: ...
    def unwrap_key(self, master_key_id: str, wrapped_key: bytes) -> bytes: ...


@dataclass(frozen=True)
class EncryptedField:
    """A PHI field's ciphertext plus the wrapped key needed to decrypt it."""

    ciphertext: bytes
    wrapped_data_key: bytes
    master_key_id: str


def encrypt_field(plaintext: str, config: "EncryptionConfig", kms: "KmsClient") -> "EncryptedField":
    data_key = Fernet.generate_key()
    cipher = Fernet(data_key)
    ciphertext = cipher.encrypt(plaintext.encode("utf-8"))
    wrapped_data_key = kms.wrap_key(config.kms_master_key_id, data_key)
    _record_event("phi_field_encrypted", config.kms_master_key_id)
    return EncryptedField(
        ciphertext=ciphertext,
        wrapped_data_key=wrapped_data_key,
        master_key_id=config.kms_master_key_id,
    )


def decrypt_field(field: "EncryptedField", kms: "KmsClient") -> str:
    data_key = kms.unwrap_key(field.master_key_id, field.wrapped_data_key)
    cipher = Fernet(data_key)
    plaintext_bytes = cipher.decrypt(field.ciphertext)
    _record_event("phi_field_decrypted", field.master_key_id)
    return plaintext_bytes.decode("utf-8")


def _record_event(action: str, master_key_id: str) -> None:
    """Audit the fact that a field was encrypted or decrypted, never its content."""
    event = {"action": action, "master_key_id": master_key_id}
    logger.info(json.dumps(event, sort_keys=True))

Enforcing TLS in transit is a client-configuration concern, not an afterthought: an outbound HTTPS client used for anything carrying PHI should reject a connection that cannot negotiate at least the configured minimum version rather than silently downgrading.

"""Enforce a minimum TLS version for outbound PHI transport."""
from __future__ import annotations

import ssl


def build_tls_context(config: "EncryptionConfig") -> ssl.SSLContext:
    context = ssl.create_default_context()
    context.minimum_version = ssl.TLSVersion(config.minimum_tls_version)
    context.check_hostname = True
    context.verify_mode = ssl.CERT_REQUIRED
    return context

A structured-logging filter is the last line of defense against the most common real-world PHI exposure — a field accidentally passed into a log call. Rather than trust every call site to remember, a filter strips known PHI field names before a record is emitted:

"""Logging filter that removes PHI field names before emission."""
from __future__ import annotations

import logging

PHI_FIELD_NAMES = frozenset({"patient_name", "date_of_birth", "mrn", "consent_text"})


class PhiRedactionFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        for field_name in PHI_FIELD_NAMES:
            if hasattr(record, field_name):
                setattr(record, field_name, "[REDACTED]")
        message = record.getMessage()
        for field_name in PHI_FIELD_NAMES:
            if field_name in message:
                record.msg = "phi_field_reference_redacted"
                record.args = ()
                break
        return True

Validation and edge-case handling

  • Key rotation re-wraps, it does not re-encrypt. Rotating the KMS master key means unwrapping every stored data key with the retiring master key version and re-wrapping it with the new one; the field ciphertext itself is untouched. This is what makes rotation an operation measured in key-wrap calls rather than a bulk re-encryption of every PHI field in the dataset.
  • A wrapped key is useless without the KMS. Because unwrap_key requires a live call to the key-management service, a stolen database export containing only ciphertext and wrapped keys cannot be decrypted offline — the master key, and the authorization to use it, never leave the KMS boundary.
  • TLS enforcement must fail closed. build_tls_context sets verify_mode to CERT_REQUIRED explicitly rather than relying on a default that a future library version might change; a connection that cannot verify the peer certificate should raise, not proceed with a warning.
  • Redaction is a safety net, not the only control. The logging filter above catches accidental inclusion, but the primary control is still discipline at each call site — never pass a raw PHI value into a log call’s message or structured fields in the first place.

Testing and verification

Tests use a fake KMS client so the encryption logic is verified without a real key-management service dependency, plus a check that the redaction filter actually rewrites a record containing a PHI field name.

"""Tests for PHI envelope encryption and log redaction."""
from __future__ import annotations

import logging


class FakeKms:
    """In-memory stand-in for a KMS wrap/unwrap API."""

    def __init__(self) -> None:
        self._master_key = b"0" * 32

    def wrap_key(self, master_key_id: str, plaintext_key: bytes) -> bytes:
        from cryptography.fernet import Fernet
        import base64
        wrapper = Fernet(base64.urlsafe_b64encode(self._master_key))
        return wrapper.encrypt(plaintext_key)

    def unwrap_key(self, master_key_id: str, wrapped_key: bytes) -> bytes:
        from cryptography.fernet import Fernet
        import base64
        wrapper = Fernet(base64.urlsafe_b64encode(self._master_key))
        return wrapper.decrypt(wrapped_key)


def test_encrypt_then_decrypt_round_trips() -> None:
    config = EncryptionConfig(kms_master_key_id="key-1")
    kms = FakeKms()
    field = encrypt_field("Jane A. Doe", config, kms)
    assert field.ciphertext != b"Jane A. Doe"
    recovered = decrypt_field(field, kms)
    assert recovered == "Jane A. Doe"


def test_redaction_filter_strips_phi_attribute(caplog) -> None:
    logger = logging.getLogger("test.phi")
    logger.addFilter(PhiRedactionFilter())
    with caplog.at_level(logging.INFO, logger="test.phi"):
        logger.info("processing record", extra={"patient_name": "Jane A. Doe"})
    record = caplog.records[0]
    assert record.patient_name == "[REDACTED]"


def test_redaction_filter_catches_phi_in_message(caplog) -> None:
    logger = logging.getLogger("test.phi2")
    logger.addFilter(PhiRedactionFilter())
    with caplog.at_level(logging.INFO, logger="test.phi2"):
        logger.info("consent_text was updated for the subject")
    assert "consent_text" not in caplog.text
    assert "phi_field_reference_redacted" in caplog.text

The first test proves a field survives a full encrypt-and-decrypt cycle through the wrap/unwrap boundary; the second and third prove the redaction filter removes a PHI field whether it arrives as a structured attribute or inline in a message string, which are the two ways PHI most often leaks into a log line.

FAQ

Why wrap a data key with a KMS instead of encrypting fields directly with the master key?

Encrypting every field directly with one master key means that key alone decrypts the entire dataset, and rotating it requires re-encrypting everything atomically. Envelope encryption limits each master-key operation to wrapping and unwrapping small data keys, so rotation only touches the much smaller set of wrapped keys, and a stolen database export cannot be decrypted without a live, authorized call to the KMS.

Does TLS in transit make field-level encryption at rest unnecessary?

No. TLS protects data while it moves between systems; it does nothing for data sitting in a database, a backup, or a log store, and it does not protect against a compromised application process reading plaintext directly from memory once the request has been decrypted. At-rest field encryption and in-transit TLS defend against different threats and are both required.

How do you prevent a debug log statement from ever exposing PHI?

Treat the logging filter shown above as a backstop, not the primary control — the primary control is a code-review discipline that never passes a raw PHI value into a log call. The filter exists because that discipline occasionally fails, and a redaction net at the logging boundary catches what review missed before it reaches a log aggregation system with weaker access controls than the encrypted data store.