Skip to content
← ALL WRITING

2026-04-23 / 13 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. 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.

Sequence DiagramIDLE
BROWSER
SERVER ACTION
APP DB (rw)
AUDIT DB (insert)
readMemberProfile(id)
SELECT profile
profile
INSERT audit.event
OK
profile
business txaudit (separate role, separate tx)
The audit insert runs on a dedicated role outside the business transaction so a rollback never erases the record.

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 is not in there. No full request body. No response. No 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.

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.

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();
  const ip = (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || 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();
  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;
  }
}

The audit write is outside the business transaction. This is deliberate. If you wrap them together, a rollback on the business transaction rolls back the audit. The whole point of the audit is to survive errors, including errors the application wants to undo.

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, Next.js sits behind a proxy in production (Vercel, Cloudflare, a load balancer). The client's actual IP is in the X-Forwarded-For header. The pattern in Step 3 takes the leftmost address, which is the origin. Verify your hosting provider's forwarding behavior before trusting this; some providers add their own proxies that shift the header structure.

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.

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.

Wrapping audit writes in the business transaction. A rollback on the business side erases the audit. The fix is the pattern in Step 3: separate connection, separate transaction, always committed.

Relying on application log shipping for audit. Even if your log aggregator offers tamper-evident storage, the write path is mutable up to the moment it ships. Anyone with access to the log forwarder can edit or drop lines. The Postgres append-only path puts the tamper-evidence at the write, not downstream of it.

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.

FAQ

Does Supabase handle HIPAA audit logging out of the box?

Supabase offers a BAA on enterprise plans and provides the Postgres primitives you need, but the append-only audit table is something you build yourself. The default database role Supabase gives your app is not restricted to INSERT-only on a specific schema; you need to create the audit schema and role as described. Supabase's own platform audit logs are separate and cover platform-level events, not application-level PHI access.

Is Vercel's built-in function logs enough for HIPAA?

No. Function logs are application logs, not audit logs. They are mutable up to the retention boundary, they can contain PHI if the application ever logs a request body, and they are not under your control for tamper-evidence. Use them for debugging. Build the audit trail in Postgres.

How long do audit logs need to be retained?

The Security Rule itself does not set a specific retention period for audit logs. HIPAA's documentation requirement at 45 CFR 164.316(b)(2)(i) sets six years for required policies and procedures, and most healthcare organizations apply the same six-year retention to audit logs as a conservative default. State laws may add to this. A healthcare attorney can confirm the retention rule for your specific product and jurisdiction.

Can I use the same Postgres instance for the app and the audit log?

Yes. The separation is by role and schema, not by physical instance. A single Postgres with two schemas and three roles (app user, audit writer, audit reader) is the pattern I use most often. You get the append-only property from the permissions model, not from isolated hardware. For larger deployments or stricter threat models, a separate instance with independent credentials adds defense in depth.

What about audit events for admin actions on the system itself?

Those are arguably more important than user events. Add them to the same table with actor_type = 'admin' and action names like system.role.grant or system.config.update. An admin who changes a role grant is the exact scenario the audit log is designed to make visible. Do not exclude admin actions from the audit path.

How do I test that the audit writer cannot UPDATE or DELETE?

Write a test that connects as the audit_writer role, inserts a row, then tries to UPDATE and DELETE. Both should fail with a permission error. Run this test in CI so a future migration that accidentally grants wider permissions surfaces immediately. It takes about ten lines of test code and catches an entire class of regression.

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