HIPAA Audit Logging: What to Capture in Every Record
Most audit logs fail HIPAA not because they do not exist — but because they miss three specific fields. Here is the schema that passes an OCR audit, the retention rules nobody reads, and why the 2026 update makes this urgent.
VertiComply Team
April 24, 2026
12 min read
A healthcare startup I worked with had “HIPAA audit logging” on their architecture diagram. They had a audit_log table. They had a middleware that wrote to it on every request. Their compliance consultant signed off.
Six months later, a disgruntled ex-employee complained to OCR. The investigation took three weeks. The audit logs were useless. They had timestamps and request paths, but no user ID, no record ID, no indication of what was read versus written. “Someone accessed /api/patients/1274” is not an audit trail — it is noise.
This happens constantly. Teams build audit logging in a hurry, write to a table, and assume they are covered. Then a real audit shows up and the logs cannot answer the one question OCR always asks: which patient records did which employees view, and why.
The § 164.312(b) Audit Controls standard has two sentences. The implementation specification is one of the shortest in HIPAA. That brevity is why so many teams get it wrong — there is no specific field list to copy.
I am going to give you that list. Seven fields. Any audit log that has all seven will pass an OCR review. Any log missing even one of the first four will fail. I will also show you the exact SQLAlchemy schema that generates a compliant log, the middleware that writes to it, and the retention pattern that keeps it trustworthy for the six-year retention window.
What HIPAA actually says
Here is the full text of § 164.312(b):
“Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information.”
That is it. Record and examine activity. HIPAA deliberately does not specify fields or formats because the rule is meant to be technology-neutral — it applies equally to EHR systems, telehealth apps, clinical trial databases, and paper logs scanned into PDFs.
What HIPAA does not spell out, OCR enforcement actions have made clear over 20+ years of case law. When OCR investigators ask for audit logs, they test three things:
Can you identify who accessed PHI? (Not just a service account — a specific human user.)
Can you identify which specific record was accessed? (Not just the endpoint — the patient ID.)
Can you distinguish what action was taken? (Read, write, delete, export, print — each has different breach implications.)
If your audit log cannot answer these three questions for any event from the last six years, you fail. That is true regardless of how many logs you have or how sophisticated your pipeline is. The violations we documented in the $130M+ OCR enforcement series include at least four where the primary finding was inadequate audit logging — not absent logging, inadequate.
Common misconception
Application server logs (nginx access logs, uvicorn request logs) are not HIPAA audit logs. They record HTTP requests, not PHI access. OCR has explicitly rejected access-log-only audit trails in multiple enforcement actions.
The 7 fields every audit log must capture
These are the fields OCR expects. The first four are non-negotiable — a log missing any of these is considered incomplete. Fields 5-7 are strongly recommended and required for any app handling non-trivial PHI volume.
user_id + user_role
Identifies the human who performed the action. Must be a specific user identifier, not a service account or API key. The role at time of access matters too — auditors reconstruct permissions as they were that day, not today.
Example: u_7ab492 (role=doctor)
action
The verb. READ, CREATE, UPDATE, DELETE, EXPORT, PRINT. Each has different breach implications — an export of 10,000 records is a very different event than 10,000 individual reads. Use a controlled vocabulary.
Example: action=READ
resource_type + resource_id
Which record was touched. Opaque IDs are best — do not log patient names or SSNs directly in the audit trail (the audit log itself should not become PHI). A join to the patients table reveals who, if needed for investigation.
Example: resource=patient:1274
timestamp (UTC)
When it happened. Always UTC, always with millisecond precision. Local time zones break correlations across distributed systems. Timestamp manipulation is the most common audit log tampering — lock it down.
Example: 2026-04-24T10:15:22.384Z
source_ip + user_agent
Context for the access. IPs reveal VPN/remote access patterns; user agents reveal mobile vs desktop vs automated scripts. Both are critical for detecting credential sharing or unauthorized device use.
Example: 10.0.4.17 / Chrome/128.0.0.0
status + success
HTTP status code and a boolean. Failed access attempts are as important as successful ones — a pattern of 403s against one patient record is the signature of snooping. Do not filter out failures from the log.
Example: status=200, success=true
purpose (treatment | payment | operations | break-glass)
HIPAA allows disclosure for treatment, payment, or healthcare operations (TPO). When it is none of those — such as a researcher or an emergency break-glass access — that must be logged explicitly. This is what distinguishes legitimate from suspicious access.
Example: purpose=treatment
Bonus: request_id
Correlating a single user action across services (API → DB → external integration) requires a shared request ID. Generate it at the ingress and propagate via X-Request-ID. Not required by HIPAA, but required by your future self during an incident investigation.
3 mistakes that fail real audits
Mistake 1: Logging the endpoint instead of the resource
Most frameworks capture the URL path by default. That is not enough. POST /api/patients/bulk-export tells you an endpoint was hit, but not which patients were in the export. When OCR asks “which patient records did Dr. Smith export on April 12?”, endpoint logs cannot answer.
Fix: resolve resource IDs inside the request handler and append them to the audit event before returning the response. If the handler touched 47 patient records, write 47 audit rows — not one row that says “bulk export”.
Mistake 2: Mutable audit tables
If your audit log is a normal SQL table that your application can UPDATE or DELETE from, OCR does not trust it. Even if you never actually modify it, the capability to modify it makes the log an unreliable witness. Auditors have explicit language for this: “logs subject to tampering.”
Fix: separate database user with INSERT-only permission on audit tables. No UPDATE, no DELETE, not even for admins. Pair with a periodic hash chain (each row includes prev_hash) so any after-the-fact edit breaks the chain and is detectable.
Mistake 3: PHI leaking into the audit log itself
Writing full patient names, SSNs, or diagnosis details into the audit trail turns the audit log into a second copy of your PHI database — one that now has its own encryption, retention, and access-control requirements. Teams do this accidentally by logging entire request bodies.
Fix: audit logs contain references to PHI (opaque IDs), never PHI itself. If you need to look up “which patient is this audit row about”, do a controlled join. Most full-PHI logging happens in exception handlers — sanitize those too. Our HIPAA compliance checklist has a section on PHI-in-logs detection.
The schema that passes
Here is the SQLAlchemy model we ship in every HIPAA project generated by VertiComply. It maps directly to the seven fields above and is enforced at the ORM layer so developers cannot accidentally skip a field.
# backend/compliance/hipaa_audit.py
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Index
from datetime import datetime
from database import Base
class AuditLog(Base):
"""HIPAA § 164.312(b) — Audit Controls."""
__tablename__ = "audit_log"
id = Column(Integer, primary_key=True)
user_id = Column(String(64), nullable=False, index=True)
user_role = Column(String(32), nullable=False)
action = Column(String(16), nullable=False) # READ|CREATE|UPDATE|DELETE|EXPORT
resource_type = Column(String(64), nullable=False) # e.g. "patient", "prescription"
resource_id = Column(String(64), nullable=False) # opaque ID only
timestamp = Column(DateTime, nullable=False,
default=datetime.utcnow, index=True)
source_ip = Column(String(45)) # IPv4/IPv6
user_agent = Column(String(256))
status_code = Column(Integer)
success = Column(Boolean, nullable=False, default=True)
purpose = Column(String(32)) # treatment|payment|operations|break_glass
request_id = Column(String(64), index=True)
prev_hash = Column(String(64)) # for tamper-evident chain
row_hash = Column(String(64))
# Composite index for the most common audit query:
# "show me everything user X did to resource Y"
__table_args__ = (
Index("ix_audit_user_resource",
"user_id", "resource_type", "resource_id", "timestamp"),
)Three things make this schema pass an OCR review that most hand-written schemas fail:
user_role is stored on the row, not joined from a users table. Roles change over time — the audit must preserve the role at access time, not current role.
resource_id is opaque — no names, no SSNs. The audit log is not a PHI database.
prev_hash / row_hash columns let you build a tamper-evident chain without giving up SQL query performance. Even without a blockchain, any after-the-fact edit is detectable.
Retention and immutability
Minimum retention: 6 years from creation (or from “date when last in effect” for policy logs). This comes from § 164.316(b)(2)(i), not from § 164.312(b). The audit control rule says “record,” the documentation rule says “retain for 6 years.” You need both.
State overrides: California extends this to 7 years for medical records; several states require 10 years for records of pediatric patients (typically 7 years past the patient's 18th birthday, whichever is longer). For most apps, picking 10 years as your retention target covers every state.
Practical immutability patterns
You have three realistic options for tamper-evident audit logs, in order of operational simplicity:
INSERT-only database role. Cheapest. A dedicated PostgreSQL user with INSERT but no UPDATE/DELETE on the audit table. Your app connects as this user for audit writes only. Admins cannot tamper through the application.
Periodic hash-chain snapshot. Each row stores a hash of (row + previous row's hash). A nightly job signs the latest hash with a key stored elsewhere. Any historical edit breaks the chain — auditors can verify with one SQL query.
Write-once external store. AWS S3 with Object Lock (WORM mode), or Azure immutable blob storage. Your app writes to the DB for hot reads and to the WORM store for compliance. Gold standard, but costs $$ and adds ops complexity.
Do not over-engineer
Most startups try to start with Option 3 and give up. Start with Option 1. Move to Option 2 when you have the engineering capacity. Option 3 is for organizations with $5M+ ARR or a major customer that demands it.
Middleware pattern: write once, capture everywhere
Hand-writing an audit log call in every request handler is how fields get forgotten. The reliable pattern is a middleware that runs on every request and writes the audit row automatically, with the handler only needing to tag the event with its resource_id.
# backend/compliance/hipaa_audit.py (continued)
import logging
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class HIPAAAuditMiddleware(BaseHTTPMiddleware):
"""Writes an audit log row for every authenticated HTTP request."""
async def dispatch(self, request, call_next):
response = await call_next(request)
# Skip non-PHI paths (health, static assets, docs)
if _is_non_phi_path(request.url.path):
return response
user = getattr(request.state, "user", None)
if not user:
return response # Unauthenticated reqs handled by auth middleware
try:
entry = AuditLog(
user_id = str(user.id),
user_role = getattr(user, "role", "unknown"),
action = _action_from_method(request.method),
resource_type = _resource_from_path(request.url.path),
resource_id = getattr(request.state, "resource_id", "?"),
source_ip = request.client.host if request.client else None,
user_agent = request.headers.get("user-agent", "")[:256],
status_code = response.status_code,
success = 200 <= response.status_code < 400,
purpose = getattr(request.state, "purpose", "operations"),
request_id = request.headers.get("x-request-id"),
)
# Hash chain — omitted for brevity; see full impl in our repo
_write_audit(entry)
except Exception as e:
logger.warning("Audit write failed (non-blocking): %s", e)
return responseRoute handlers only need to set request.state.resource_id when they know it (usually after parsing the URL or loading the record). The middleware does everything else.
Fail open, not closed
If the audit write fails (database down, disk full), the request should still succeed. An outage of the audit system must not take down patient care. Log the failure separately, alert ops, and catch up from a buffered queue. HIPAA expects best effort — it does not require denial-of-service on yourself.
The 2026 shift: testable audit controls
The most important change in healthcare compliance this year is not a new regulation — it is a shift in how regulators evaluate existing ones. The 2026 HIPAA Security Rule update reframes the Audit Controls standard from “document your intent” to “prove technical enforcement.”
In practical terms: having a written policy that says “we log all PHI access” is no longer enough. Auditors now expect automated tests that verify the logging pipeline is actually capturing events — with sample queries, synthetic test data, and live verification against the running system.
“Testable controls” is the phrase that will define 2026 HIPAA audits. Your policy PDF used to be the audit artifact. Now a failing test in CI is the audit artifact — and auditors expect to see the test exist, run, and pass.
What testable audit controls look like
An automated test that makes an authenticated request and verifies an audit row was written within 5 seconds
A synthetic check that verifies the last 24 hours of audit data has all 7 required fields populated for every row
A CI assertion that the audit middleware is actually registered on the FastAPI app (not silently stripped by a refactor)
A runtime endpoint (like /api/compliance-status) that reports audit middleware health as part of every deployment's smoke tests
This is exactly what we built into the VertiComply generation pipeline. Every HIPAA project ships with a /api/compliance-status endpoint and the builder verifies — via a live HTTP call against the running Docker container — that HIPAAAuditMiddleware is registered before the build passes. If you want the full architecture, our writeup on runtime compliance verification covers the six assertions we run on every build.
Audit-ready checklist
Run through this before your next audit, after your next refactor, or before shipping anything that touches PHI.
All 7 fields present on every audit row (user_id, role, action, resource, timestamp, source, status)
Resource IDs are opaque — no patient names or SSNs in the audit log itself
Audit table is INSERT-only for the app user; admins cannot delete rows
Retention is at least 6 years (10 years if you have any pediatric exposure)
Middleware writes on every request — not called from handlers one-by-one
Audit writes fail open — an audit DB outage does not deny patient care
A runtime test verifies the middleware is registered in every deploy
Failed access attempts are logged, not filtered out
Timestamps are UTC with millisecond precision, no local time zones
Composite index on (user_id, resource_type, resource_id, timestamp) for the “who touched what” query
Frequently asked questions
What fields does a HIPAA audit log need to capture?
HIPAA § 164.312(b) does not dictate specific fields, but OCR investigators consistently look for seven: who (user ID + role), what (action: read/write/delete), which record (resource type + ID), when (UTC timestamp), from where (source IP + user agent), outcome (success or failure + status code), and why (purpose of access, when applicable). Missing any of the first four makes an audit functionally impossible.
How long must HIPAA audit logs be retained?
Minimum 6 years from creation, per § 164.316(b)(2)(i). Many state laws require longer — California mandates 7 years for medical records, and several states require 10 years for pediatric records. Most apps pick 10 years to cover every state.
Do audit logs need to be immutable?
HIPAA does not explicitly require immutability, but logs that users can modify are not trusted by OCR. The 2026 Security Rule update emphasizes testable controls — auditors now expect append-only storage, hash chains, or WORM storage. Practically: no UPDATE or DELETE permissions on audit tables for any application user, including admins.
What is the difference between access logs and audit logs?
Access logs record authentication events (login, logout). Audit logs record every interaction with PHI (read, write, delete, export, print). HIPAA requires both. Access logs alone do not satisfy § 164.312(b) because they cannot answer which patient records were viewed by which user.
Should audit logs themselves be considered PHI?
Audit logs contain references to PHI (patient IDs) but should not contain PHI directly. Never log full names, SSNs, dates of birth, or diagnoses in the audit trail. Use opaque identifiers that require a join to another table — then access to the audit database alone cannot reveal PHI. This is sometimes called de-identified audit logging.
How does the 2026 HIPAA update change audit logging?
The proposed 2026 HIPAA Security Rule update shifts from documenting intent to proving technical enforcement. For audit logging, auditors now expect automated tests that verify the logging pipeline is actually capturing events — not just a written policy that says it should. VertiComply performs this verification at build time via a live HTTP assertion against the audit middleware endpoint.
Generate audit-ready HIPAA code in minutes
Every VertiComply HIPAA project ships with the schema and middleware above, pre-wired and verified at build time. The builder runs a live HTTP assertion against your running container to confirm HIPAAAuditMiddleware is registered — so you never ship an app that says it logs but actually does not.
Keep reading
More on HIPAA compliance and healthcare development
How to Build a HIPAA-Compliant Healthcare App Without Code in 2026
The complete 2026 guide to building HIPAA-compliant healthcare apps without code. Covers compliance rules, no-code platforms, what to look for, real costs, common mistakes, and a step-by-step practical sequence for US healthcare startups.
Read article
10 HIPAA Violations That Cost Real Money (2026 Guide)
Real HIPAA enforcement cases, actual penalty amounts ($100 to $1.9M/year), what triggers OCR investigations, and how to prevent each violation in your healthcare app.
Read article
How to Build a Compliant Healthcare App in 2026
Step-by-step guide to building healthcare apps that meet HIPAA, GDPR, SOC 2 and HITRUST compliance. Covers the 5 essential pillars and AI automation.
Read article