Skip to content
bizurk
← ALL WRITING

2026-05-15 / 16 MIN READ

HIPAA audit log implementation: the four-field shape

A walkthrough of HIPAA audit log implementation: the actor/action/target/outcome shape, retention rules, what to omit, and ingestion sinks auditors trust.

Audit logging is the single requirement in a HIPAA build that looks easy in a spec and gets hard in production. The Security Rule asks for "mechanisms that record and examine activity." Every Next.js app already records activity. None of those records will pass an audit. I learned the difference shipping audit logging from scratch at a regulated healthcare client in sleep medicine, where the audit log was the deliverable that closed out the security review.

This is a tutorial about the shape of the data, not the shape of the table. The table-schema decisions, the append-only role, and the WAL-level protections are covered in the companion post on Postgres audit-table architecture. Here I want to walk through the four fields every audit event needs, the field-discipline rules that keep PHI out of those fields, the retention story, and the ingestion sinks that an auditor will actually accept. Code is Next.js 15 + Postgres. The patterns hold on Next.js 16 with the proxy rename, which I cover for the broader stack in the App Router boundary patterns post.

What HIPAA actually asks for in an audit log

The relevant text is short. 45 CFR 164.312(b) names "audit controls" as a required implementation specification, and it asks the covered entity to "implement hardware, software, and procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information." That is the whole sentence. The Security Rule does not prescribe a format. It does not name a retention period. It does not list which actions to log. It asks for evidence.

The trap in a Next.js app is that you already have evidence of activity in request logs, scattered console.log calls, and Postgres query logs. Some of those probably contain PHI. None of them are organized for the question an auditor asks during a breach review: "show me everyone who accessed this member's record in the last 90 days." Pulling that answer out of an unstructured pile of console.log lines takes hours, and any line that contains PHI compounds the breach.

An audit log is a separate stream, with a strict shape, written intentionally.

The four-field shape: actor, action, target, outcome

Every audit event answers four questions: who did something, what did they do, what did they do it to, and did it work. Those four questions become four fields. Every other column on the row is either a timestamp or a context wrapper around the four.

create table audit.events (
  -- the four fields
  actor_id        uuid,                  -- who
  actor_type      text not null
    check (actor_type in ('user','system','admin')),
  action          text not null,         -- what
  resource_type   text not null,         -- against what
  resource_id     text not null,
  outcome         text not null
    check (outcome in (
      'success','auth_fail','authz_fail','validate_fail','error'
    )),
  outcome_code    text,                  -- machine-readable error code

  -- wrapper fields
  event_id        uuid not null default gen_random_uuid(),
  event_time      timestamptz not null default now(),
  request_id      uuid not null,
  ip_address      inet,
  user_agent      text,
  primary key (event_id)
);

The four-field shape is what an investigator reaches for. Outcome is split into five values rather than a boolean because auditors care about the reason a thing failed: a failed authentication attempt is a security event, a failed authorization attempt against a resource the user is not allowed to see is a different security event, and a failed validation against a malformed payload is operational noise. Compressing all three into success: false loses the signal an investigator needs.

The action field is a verb-noun string from a closed vocabulary your team owns. Examples: member.profile.read, consent.record.create, prescription.list.export, admin.member.role_change. The vocabulary lives in a TypeScript enum or a registry committed to git, and adding to it goes through code review like any other change. Without the closed vocabulary, every developer invents their own action names and the audit log becomes unsearchable.

The audit log is not for debugging. It is for answering the question "who touched what, when, and from where" during a post-incident investigation.

The wrapper fields earn their seats too. event_id is the primary key, separate from any application id. event_time is timestamptz so daylight savings does not corrupt your timeline. request_id is the Next.js request id, propagated from the platform's request header through middleware (proxy in 16) to the server action that wrote the log. ip_address and user_agent are part of HHS guidance on what an audit log should retain.

HIPAA audit log implementation: the field-discipline rules

The fields above are the easy part. The hard part is keeping them clean over the next two years of feature work, when developers will reach for shortcuts under deadline pressure. I have seen all of these shortcuts attempted, and I have rejected them at code review for the same reason every time: they put PHI on a row that will outlive the application.

// audit.ts - the only function that writes to the audit table
type AuditEvent = {
  actorId: string | null;
  actorType: 'user' | 'system' | 'admin';
  action: AuditAction;          // closed enum
  resourceType: ResourceType;   // closed enum
  resourceId: string;           // never a name, never an email
  outcome: 'success' | 'auth_fail' | 'authz_fail' | 'validate_fail' | 'error';
  outcomeCode?: string;
  requestId: string;
  ipAddress?: string;
  userAgent?: string;
};

export async function recordAuditEvent(event: AuditEvent) {
  // single insert, no batching, no swallowed errors
  await db.insert(auditEvents).values(event);
}

Notice what the function signature leaves out: no details field, no metadata jsonb, no notes string. The signature constrains what can be written, and the constraint is the discipline.

A field that requires a developer judgment call at log time will eventually contain PHI. The first developer who reaches for details: { reason: 'member declined to provide social security number' } has just written PHI into the audit log. Design that judgment out by not having the field.

The closed vocabulary deserves its own commit. When a developer wants to add a new action string, they open a PR that touches the AuditAction enum, and the reviewer asks two questions: does this action describe a single resource type, and is the verb specific enough that a future investigator will know what happened. member.profile.update passes. member.misc does not.

The resource_id field is also load-bearing. It must be a stable identifier, never a display name. A member's email looks unique enough at the time you log it, then the member changes their email and the audit row no longer points anywhere. Use the database primary key.

What to log and what to omit

The Security Rule is silent on what specifically to log, but HHS guidance and OCR enforcement actions have built up a working list. Every regulated app I have shipped logs the same categories.

LOG every read on PHI-containing tables. This surprises people. Yes, even reads. The audit standard wants accountability for who accessed what, and "accessed" includes "looked at." A clinician viewing a member's chart is a logged event. A member viewing their own chart is a logged event. The actor_type field distinguishes them.

LOG every write, delete, and export. Bulk exports are the highest-risk action in any regulated system, and a missing audit row for a 10,000-record export is the kind of finding that becomes a corrective action plan.

LOG every authentication and authorization failure. Brute-force attempts, password resets, role changes, session revocations. These are the events that show up in a breach timeline, and the rotation events from the regulated-app session and refresh-token tutorial all flow into this same audit stream.

LOG every admin action on a member account. If your support team can impersonate a member, every impersonation is an audit event. If an admin can change a member's email, that is a logged event with the old and new resource_ids in two separate rows, never in a details blob.

OMIT anything that arrived in a request body. The body of a POST to update a member's profile contains the new values, and those values are PHI. Do not log the body. Log that the action happened, log the resource_id, log the outcome.

OMIT search queries that contain free text. A member typing their symptoms into a search bar is PHI being entered into your system. Logging "search query: 'sleep apnea symptoms during pregnancy'" is logging PHI. If you must log the search action, log search.execute with the resource_type of search and a synthetic resource_id, and resolve the actual query from your search service's own logs, which had better be on a BAA. The redaction-at-the-emitter pattern that keeps free text out of application logs is covered in the companion post on PHI-safe production logging.

OMIT URL paths if the URL contains a member identifier in a query string. The member_id is fine; the member's name in a query string is not.

The substitution pattern is consistent: log the resource you ended up touching, not the request that asked for it.

Retention period and the rotation story

HIPAA does not name a retention period for audit logs. The Security Rule is silent. People who tell you "HIPAA requires six years" are conflating two different requirements: §164.530(j) requires six years of retention for documentation related to compliance policies and procedures, and most regulated shops apply that floor to audit logs as a defensible operational standard. State law often extends it. Some BAAs require longer. Check both before you settle on a number.

The retention story I run is hot for 90 days, warm for the rest of the retention window, with a tested restore path. Hot means the audit table sits in the primary application database, queryable in milliseconds, used during active investigation. Warm means the rows have been shipped to object storage with object lock or write-once configuration, indexed enough to find by member_id but not necessarily fast.

// hot-to-warm rotation, runs nightly
async function rotateAuditEvents() {
  const cutoff = subDays(new Date(), 90);
  const batch = await db
    .select()
    .from(auditEvents)
    .where(lt(auditEvents.eventTime, cutoff))
    .limit(10_000);

  if (batch.length === 0) return;

  // write to S3 with object lock; serialize as JSONL
  await s3.putObject({
    Bucket: AUDIT_COLD_BUCKET,
    Key: coldKey(batch[0].eventTime),
    Body: batch.map((row) => JSON.stringify(row)).join('\n'),
    ObjectLockMode: 'COMPLIANCE',
    ObjectLockRetainUntilDate: addYears(new Date(), 6),
  });

  // only after the put succeeds, remove from hot
  await db
    .delete(auditEvents)
    .where(inArray(auditEvents.eventId, batch.map((r) => r.eventId)));
}

The detail that matters is the object lock. S3 object lock in COMPLIANCE mode means the object cannot be deleted or overwritten by any IAM user, including the root account, until the retain-until date passes. That is what immutability looks like in object storage, and it is what an auditor wants to see.

The annual restore test matters too. Pick a random cold object once a year, restore it to a staging environment, parse it against the current schema, and confirm you can answer a query against it. Schema drift will break this if you skip it. Auditors ask about the test in the annual review.

Ingestion sinks that satisfy auditors

The audit table in the primary database is the source of truth, but it is not the only place the audit data lives. The ingestion-sink question is where most teams get stuck, because the sink they want for queryability (Datadog, Sentry, Logtail) is not necessarily a sink that satisfies HIPAA's requirements about service providers.

The pattern I run is two-tier. The first tier is a synchronous insert into the primary database, in the same transaction as the underlying action where possible. If the audit insert fails, the action fails. That sounds harsh until you walk through the alternative: the action succeeded but the audit row is missing, which is the exact state HIPAA's audit controls were written to prevent.

// inside a server action
export async function readMemberProfile(memberId: string) {
  const session = await getSession();
  await assertCanReadMember(session, memberId);

  const profile = await db.transaction(async (tx) => {
    const member = await tx.select().from(members)
      .where(eq(members.id, memberId)).limit(1);

    await tx.insert(auditEvents).values({
      actorId: session.userId,
      actorType: 'user',
      action: 'member.profile.read',
      resourceType: 'member',
      resourceId: memberId,
      outcome: 'success',
      requestId: session.requestId,
      ipAddress: session.ip,
      userAgent: session.ua,
    });

    return member[0];
  });

  return profile;
}

The second tier is asynchronous shipping to a sink built for queryability, longevity, or both. The non-negotiables on any sink are the same regardless of which tool you pick. There must be a signed business associate agreement with the vendor. The data must be encrypted at rest. Read access must be role-based and logged. The retention must be enforced at the sink, not just at the source. The sink must be immutable for the retention window, either through object lock, write-only roles, or a vendor-side guarantee.

Sink options worth considering. Datadog and New Relic both offer HIPAA tiers with a BAA available on enterprise contracts; the cost reflects that. Cloudflare Logpush to an S3 bucket with object lock works if your audit data flows through a worker or an edge function, and is the cheapest defensible option I have shipped. A self-hosted ELK or OpenSearch cluster on a VPC with no PHI in the field shape passes most non-enterprise audits. A SIEM with HIPAA terms is the right answer for an enterprise customer who already runs one.

Cost-wise, the S3 + object lock pattern is roughly an order of magnitude cheaper than a managed SIEM and satisfies most of the audits I have been through, including the AWS environment recovery I shipped at a healthcare client where the audit trail was the artifact that closed the corrective action.

Operational tests that prove the audit log works

A passing audit log earns its keep through five operational tests. Run them quarterly, document the results, and keep the documentation in the same place you keep your security policies. Auditors ask for the documentation; teams that have it move through the review faster. The same five-test cadence shows up in the productized DTC stack audit when the engagement covers a regulated client.

The "search for one event" test. Pick a member id from production. Find every audit row touching that resource_id in the last 90 days, in under five minutes, using the same query interface a real investigation would use. If it takes longer, your indexes are wrong or your sink is misconfigured.

The "tampering attempt" test. From the application database user, attempt an UPDATE on an existing audit row, and a DELETE on an existing audit row. Both must fail. If either succeeds, your role separation is broken and the audit log is mutable, which is the one thing it cannot be.

The "access without log" test. Use the application database user to read from a PHI table directly, bypassing the application code path. The audit log must show a row for that read, or your audit-write path is in the wrong layer. The right layer is wherever the database access happens, not wherever the request handler runs.

The "PHI in log" test. Grep the last 90 days of audit rows for known PHI strings drawn from a test fixtures dataset (never real members). Zero hits required. If anything matches, you have a details column or a free-text field somewhere, and you need to remove it before the next audit.

The annual retention restore test. Pull an audit row from cold storage that is at least four years old. Confirm the JSON shape parses against the current schema, and verify the resource_id still resolves to a known resource, or the resource has been documented as removed. Schema drift over a six-year retention window is the failure mode auditors specifically ask about.

FAQ

Do reads need to be logged under HIPAA?

Yes. The Security Rule's audit controls standard at 45 CFR 164.312(b) does not distinguish between reads and writes; it asks for mechanisms that record and examine activity. OCR guidance and enforcement actions have consistently treated reads as auditable activity.

Can I use a JSON details column for ad-hoc context?

No. Once the column exists, a developer under deadline pressure will write PHI into it. If you need richer context for a specific event type, add a typed column with a closed vocabulary, and have it pass code review.

What is the right retention period?

HIPAA does not name one. §164.530(j) requires six years for compliance documentation, and most regulated shops apply that floor to audit logs. State law and BAA terms can extend it. Settle on a number, write it into your security policy, and enforce it at the storage layer.

Does the audit log need to be on a separate database?

Not strictly. It needs to be on a separate role with separate permissions: the application's main database user must not be able to UPDATE or DELETE audit rows. A separate schema in the same Postgres cluster works.

Can I use Datadog or Sentry as my audit log sink?

Only on a HIPAA-eligible plan with a signed business associate agreement. Both vendors offer those tiers. The default plan does not include a BAA, and shipping audit data that can re-identify a member to a non-BAA vendor is a disclosure.

Sources and specifics

  • 45 CFR 164.312(b) is the HIPAA Security Rule's audit controls standard. Full text: "Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information."
  • 45 CFR 164.530(j) is the six-year retention rule for compliance documentation. The Security Rule itself does not name a retention period for audit logs.
  • The four-field shape (actor, action, target, outcome) and the two-tier ingestion pattern were tested in production at a regulated healthcare client in sleep medicine on Next.js 15 + Postgres in 2025.
  • S3 Object Lock in COMPLIANCE mode prevents deletion or overwrite by any IAM user, including the root account, until the retain-until date passes. AWS documentation confirms this behavior.
  • Datadog and New Relic offer HIPAA-eligible tiers with a business associate agreement on enterprise contracts; the default plans for both do not include a BAA.

// related

Let us talk

If something in here connected, feel free to reach out. No pitch deck, no intake form. Just a direct conversation.

>Get in touch