Skip to content
bizurk
← ALL WRITING

2026-02-19 (Updated 2026-10-23) / 14 MIN READ

Audit logging patterns for regulated Next.js apps

A walkthrough for audit logging healthcare nextjs apps: an append-only Postgres table, a server-action writer, and the pieces that keep it tamper-evident.

Audit logging under HIPAA is one of those requirements that looks simple in the spec and gets hard in the implementation. The Security Rule's audit controls standard at 45 CFR 164.312(b) asks for "mechanisms that record and examine activity" in systems that hold electronic PHI. The trap is that every Next.js project already has logs. None of those logs are an audit trail.

This is the walkthrough I wish I had the first time I built one of these, on a HIPAA-grade member platform I ran solo. By the end, you will have an append-only audit table, a Postgres role that can only write to it, a server action that records events on every PHI touch, and a read path that keeps the log queryable without making it mutable. The code runs on any Postgres (Supabase, Neon, RDS, self-hosted) with Next.js 14 or 15 and the app router.

Prerequisites

You need Next.js 14 or 15 on the app router, a Postgres database you control, and an understanding of which actions in your app touch PHI. If you have not drawn the PHI boundary yet, do that before you start - the patterns I cover in the regulated DTC healthcare hub walk through the boundary decision. Audit logging is the enforcement layer; without a boundary to enforce, the log is theater.

You also need a vendor with a signed business associate agreement if the audit data itself will flow to an observability platform. Do not skip this. Audit data about PHI access is typically treated as PHI for compliance purposes.

Close-up macro of a single jagged ice shard inside the cave at dusk, fine cracks and refractive surfaces visible, abstract crystalline detail.
// the shard · cracks and refraction close up

Step 1: design the audit event shape

Before any code, decide what an audit event is. The mistake I see most is treating the audit log like an application log: dumping full request bodies, response payloads, or stack traces. 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 minimal shape for a HIPAA-relevant audit event is eight columns.

create table audit.events (
  id              bigserial primary key,
  event_time      timestamptz not null default now(),
  actor_id        uuid,
  actor_type      text not null check (actor_type in ('user','system','admin')),
  action          text not null,
  resource_type   text not null,
  resource_id     text not null,
  success         boolean not null,
  request_id      uuid not null,
  ip_address      inet,
  user_agent      text
);

Notice what the schema leaves out: the full request body, the response, and any detail column that would tempt a developer to dump a member's clinical record into the audit row. The action names ("member.profile.read", "consent.record.create", "prescription.list.export") plus the resource id are enough to investigate. If you need more detail, you pull the record from the primary table at the time it was audited, not from the log.

Centered on the single bright pinkish light source at the heart of the icy cave, surrounding ice formations dimly visible glowing with reflected light.
// the light source · pink at the heart of ice

Step 2: create the append-only table and the role

The table above is not yet append-only. In Postgres, append-only is a role-and-permissions property, not a schema property. The application's main database user should never have UPDATE or DELETE on the audit table.

create schema audit;
-- (table definition from step 1)

-- The role your Next.js app uses for normal queries
-- has no access at all:
revoke all on schema audit from public;
revoke all on all tables in schema audit from public;

-- A dedicated writer role, INSERT only:
create role audit_writer noinherit;
grant usage on schema audit to audit_writer;
grant insert on audit.events to audit_writer;
grant usage on sequence audit.events_id_seq to audit_writer;

-- A dedicated reader role, SELECT only:
create role audit_reader noinherit;
grant usage on schema audit to audit_reader;
grant select on audit.events to audit_reader;

If you are on Supabase or another managed platform, the exact role management varies. The principle does not. The audit table must be writable only by a role that cannot UPDATE or DELETE, and readable only by a role that cannot modify. Your application's main connection string should use neither of those roles for normal traffic.

Row Level Security can add another layer, but the role grants are the primary control. RLS is useful when you want to restrict which rows a reader sees (for example, scoping a compliance officer's view to their organization's events). It is not a substitute for revoking UPDATE.

Ultra-wide looking deep into the icy cave at dusk, jagged ice formations on both sides receding to a vanishing point at the bright distant light source.
// into the cave · ice receding toward light

Step 3: write the audit from a server action

In the app router, server actions are the right place to write audit events because they run on the server, have access to session, and wrap database work in a transaction you control. The pattern is to open a second connection under the writer role for the audit insert.

// src/lib/audit.ts
import { Pool } from "pg";
import { headers } from "next/headers";
import { randomUUID } from "crypto";

const auditPool = new Pool({
  connectionString: process.env.AUDIT_DATABASE_URL,
  max: 4,
});

export async function writeAudit(event: {
  actor_id: string | null;
  actor_type: "user" | "system" | "admin";
  action: string;
  resource_type: string;
  resource_id: string;
  success: boolean;
  request_id: string;
}) {
  const h = await headers();
  // Set through a validated adapter for the trusted deployment proxy.
  const ip: string | null = null;
  const ua = h.get("user-agent");

  await auditPool.query(
    `insert into audit.events
       (actor_id, actor_type, action, resource_type, resource_id,
        success, request_id, ip_address, user_agent)
     values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
    [
      event.actor_id,
      event.actor_type,
      event.action,
      event.resource_type,
      event.resource_id,
      event.success,
      event.request_id,
      ip,
      ua,
    ]
  );
}

Two things to notice. AUDIT_DATABASE_URL is a separate connection string that authenticates as the audit_writer role, not the main application role. And the call is awaited. You do not want a fire-and-forget audit write that might be lost when the server action's lambda scales down.

The server action that touches PHI looks like this:

"use server";
import { writeAudit } from "@/lib/audit";
import { randomUUID } from "crypto";
import { getSession } from "@/lib/auth";

export async function readMemberProfile(memberId: string) {
  const session = await getSession();
  if (!session) throw new Error("unauthorized");
  await assertCanReadMember(session, memberId);
  const requestId = randomUUID();
  try {
    const profile = await db.members.read(memberId);
    await writeAudit({
      actor_id: session?.user.id ?? null,
      actor_type: "user",
      action: "member.profile.read",
      resource_type: "member",
      resource_id: memberId,
      success: true,
      request_id: requestId,
    });
    return profile;
  } catch (err) {
    await writeAudit({
      actor_id: session?.user.id ?? null,
      actor_type: "user",
      action: "member.profile.read",
      resource_type: "member",
      resource_id: memberId,
      success: false,
      request_id: requestId,
    });
    throw err;
  }
}

This example writes its audit event over a separate connection, so a business rollback does not erase the event. A successful business write and its independent audit insert can still fail separately. For mutations, review the failure policy and a transactional outbox where atomic event capture is required. Authorize the resource before the read, and record denied attempts through the application's separate failure path.

Looking straight up inside the icy cave at dusk, jagged ice formations hanging from the ceiling above, dramatic ceiling-of-ice composition.
// looking up · ceiling of ice

Step 4: capture the right context

Context matters because an audit row with just the action and the actor tells you what, but not how. The three context fields most useful after the fact are the IP address, the user agent, and a request id that correlates to your application logs.

For the IP, use only forwarding headers supplied and sanitized by the trusted deployment proxy. A request can contain forged forwarded values when that trust boundary is missing. Validate the address before storing it in an inet column and document the hosting provider's header behavior.

The user agent is less critical but useful for distinguishing automated access from human access. Pair it with the actor type: if you see actor_type = 'system' with a human-looking user agent, something is wrong with how your system jobs authenticate.

The request id is the link between the audit log and your application logs. Use it consistently: every log line in your application log for the same request carries the same request id, so a compliance officer investigating an audit event can pull the corresponding application log lines from your observability platform. This is the only place it is safe for the application log and the audit log to converge; they still live in different systems, but they reference the same ids.

Step 5: make the log queryable without making it mutable

The final piece is the read path. Compliance officers and privacy officers will need to query the audit log. If you hand them the same connection string the writer uses, you have accidentally widened the surface.

Build a thin read API that uses the audit_reader role. For small teams, a set of read-only server actions exposed to a compliance admin page is enough. For larger organizations, a separate internal tool that the security team uses. Either way, the path that writes and the path that reads do not share a connection.

A useful default query is something like "all events for a specific member in the last 90 days":

select event_time, actor_id, action, success, ip_address
  from audit.events
 where resource_type = 'member'
   and resource_id = $1
   and event_time > now() - interval '90 days'
 order by event_time desc;

Save this as a parameterized view or a helper function so the read path is consistent. Export format matters too: CSV with the columns you need, generated on demand, is almost always what a compliance review wants.

Low angle of the cave's ice floor at dusk, refractive ice surface catching the bright pinkish light source from above and reflecting it across the floor.
// the floor · refraction across the surface

Common mistakes

The patterns fail in predictable ways. I have seen each of these in production.

Logging PHI in a details column. The column starts innocent. Someone adds the new member's email to make debugging easier. Six months later, the audit log has unencrypted member records in it, and now the log itself is PHI that needs the same protections as the primary data. Do not add a details column at all.

Running the writer as the app's main database user. This removes the append-only property. Any code path (or any compromised session) can UPDATE or DELETE audit rows. Always use a restricted role.

Letting the ORM manage the audit table. Most ORMs generate migrations that grant UPDATE and DELETE broadly to whichever role ran the migration. Treat the audit schema as out of scope for the ORM: create it manually, grant explicitly, and keep the migration runner's role out of the audit schema entirely.

Losing the event during a rollback. Decide which events must survive a failed transaction and how they are durably captured. Separate audit writes and transactional outbox designs have different failure cases. Test the chosen path under database and sink failures.

Treating ordinary application logs as a complete audit trail. Review collection, permissions, integrity checks, independent storage, and retrieval together. An INSERT only role limits that role; an administrator with wider privileges can still change the database. A separately administered copy and operational checks address a different part of that threat model.

What to try next

With the core pattern in place, there are three enhancements worth considering based on the investigation work in my earlier HIPAA Next.js field notes.

Hash-linked rows. Add a column that stores a hash of the previous row's hash plus the current row's content. An attacker who gains DELETE permission (which should not happen, but defense in depth) cannot delete a row without breaking the chain visibly. This is how Git structures its commit log; you are building a smaller version of the same idea.

Streaming to a SIEM with a BAA. Once the Postgres log is stable, stream new rows to a security information and event management platform for alerting. Make sure the SIEM provider offers a BAA and treats the ingested data as PHI.

Anomaly alerting. A member's record is read 400 times in an hour. An admin account accesses every record in a schema. A single IP hits the audit log itself. These are the patterns a compliance team wants to know about in minutes, not during the annual review. The audit table is the canonical feed for those rules.

For teams building the commerce side of a regulated product, the BAA and vendor-risk questions for a small team cover the procurement side that this audit depends on. If you are auditing a tracking layer specifically, the productized DTC stack diagnostic walks the commerce and attribution surface with the same rigor.

“The audit log is not for debugging. It is for answering who touched what, when, and from where during a post-incident investigation.”
Ultra-wide at a distant vantage looking back at the cave entrance at dusk, the cave mouth visible as a bright pinkish opening in a vast ice landscape.
// the entrance · cave mouth in vast ice

Field discipline, retention, and verification

Actor, action, target, and outcome are the useful core of the event. Keep action names in a reviewed vocabulary such as member.profile.read or consent.record.create. Use a stable resource ID for the target. A free text explanation can pull clinical detail into a log that has its own access and retention requirements.

Start with a boolean success field when that is enough for the investigation. When investigation needs more detail, add a closed outcome vocabulary: success, auth_fail, authz_fail, validate_fail, and error. A machine readable outcome code can distinguish a denied permission from a failed upstream service without storing the original request body.

Retention needs a recorded decision. The HIPAA audit controls standard requires mechanisms to record and examine activity. A separate six year documentation rule does not, by itself, set a six year retention period for every audit event. Confirm the policy against applicable obligations, contracts, and the organization's risk analysis. The HHS audit protocol is a reference for the control and documentation review.

For the storage design, a restricted Postgres table gives the application a queryable hot store. An independently administered object store can provide a longer archive and retention controls. A managed logging service can add search and alerting, provided the account terms and data handling fit the deployment. Running a self hosted search cluster moves that operational responsibility onto you. Choose the sink against those requirements rather than assuming a product name makes the design acceptable.

The original hot and warm split used 90 days in the queryable store, then an archive with a tested restore path. That is a design example. Archive deletion should happen only after durable export, verification, and an authorized retention workflow. Ordinary application credentials should remain unable to modify audit history.

Test the deployed path by retrieving events for a test resource, attempting a forbidden update and delete as the application role, exercising a denied access, and checking fixture identifiers against the logs. An application helper cannot capture a direct database query that bypasses it. Review database access and its separate auditing alongside the helper. Include an archive restore test and record the result.

Sources and specifics

  • HIPAA Security Rule audit controls standard: 45 CFR 164.312(b). Retention reference: 45 CFR 164.316(b)(2)(i).
  • Code examples are Next.js 15 app router with server actions and the pg driver; the pattern applies to any Postgres client and Next.js 14 or 15.
  • Role separation (writer role with INSERT only, reader role with SELECT only) is standard Postgres DBA practice; this pattern is portable across Supabase, Neon, and managed RDS.
  • X-Forwarded-For parsing follows the leftmost-is-origin convention used by Vercel, Cloudflare, and most reverse proxies; verify for your specific hosting provider.
  • All patterns derived from production implementations of regulated member platforms without naming specific clients or systems.

// 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

Tell me what you’re trying to ship.

Send a quick message and I read it within a day, or talk to AI Michael first if you want to feel out your project before you write to me.

By sending this, you agree to the Terms and acknowledge the Privacy Policy.