Nov 12, 202611 min read

PHI Access Audit Log: Record Who Viewed Which Patient Record

Design the audit log behind a healthcare API: log every read of patient data in the same transaction that returns it, include denied and break-glass access, make the table append-only in Postgres, answer access-history questions and purge only after the retention period, with the data in your own AWS account.

Illustration for PHI Access Audit Log: Record Who Viewed Which Patient Record

When a patient asks who looked at their chart, or a privacy officer investigates a staff member who opened a celebrity's record, the answer comes from the access log. A log that only records changes cannot answer either question, since viewing a record changes nothing. A log written after the response is sent can miss the one access that matters, if the write fails after the data has already left the API.

This guide designs the access audit log for a healthcare API: one row for every view of patient data, denied and emergency access included, written in the same transaction as the read, protected against updates in Postgres and queryable by patient. It starts with what the log has to record and how to build it by hand on AWS, then builds it in Go with Encore so the database stays in your own AWS account.

The PHI access log at a glance

JobPatternHand-built on AWSWith Encore in your AWS account
Record each accessInsert in the same transaction as the readRDS for PostgreSQLEncore database on RDS
Denied and emergency accessoutcome column with denied and break_glassSame tableSame table
No edits to historyRow and truncate triggers that raiseMigrationMigration
Who viewed this patientIndex on (patient_id, at)API endpointAuth endpoint for privacy officers
RetentionDaily purge of rows past the periodScheduled taskCron job, Encore platform managed
PHI in request tracesRedact payload fieldsLogging configencore:"sensitive" field tags

What the audit log has to record

The HIPAA Security Rule's audit controls standard, 45 CFR 164.312(b), asks for mechanisms that record and examine activity in systems that contain or use electronic PHI, and does not list fields. The HHS audit controls newsletter says the Security Rule does not identify what an audit log should collect or how often it should be reviewed, and describes application audit trails as covering the creating, reading, editing and deleting of records with ePHI. For a patient-records API, each row needs:

  • Actor and role: the authenticated user ID and the role used for the decision.
  • Patient and action: which patient, and whether it was a record view, a search, an export or a view of the log itself.
  • Outcome: allowed, denied, or break-glass access outside the care team, with the stated reason.
  • Time and request ID: so a log row can be tied to the trace of the request that produced it.

Keep the PHI itself out of the log, so the row says who opened patient 1042, not what the record contained.

How do you build it by hand on AWS?

  1. Database: an encrypted RDS for PostgreSQL instance in private subnets holds the patient tables and phi_access_log. Putting the log in the same database lets the read and the log row commit together.
  2. Write path: every handler that returns patient data opens a transaction, decides access, inserts the log row and only then selects the record. A failed insert aborts the request before any PHI is read.
  3. Protection: row and statement triggers reject updates, deletes and truncation. Application credentials do not own the table if your setup allows it, and a nightly export copies the log to a separate account or bucket that the application cannot write to.
  4. Review: a privacy-officer endpoint or report lists access by patient and by user. The information system activity review procedure in 164.308(a)(1)(ii)(D) expects someone to read these regularly.
  5. Retention: a scheduled task deletes rows past the retention period through the one path the trigger allows.

How do you build it with Encore?

Encore

Encore turns infrastructure declared in Go or TypeScript code into resources in your own cloud account. The records database and the retention schedule are declared in the records service, and deploying creates the database on RDS in your own AWS account, encrypted at rest, as listed in the AWS infrastructure docs. The patient data and the access log stay in that account, while the BAA with AWS, access reviews and written policies stay with your team.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create clinic --example=hello-world$ cd clinic && mkdir -p records/migrations auth

The migration creates the log and the triggers that keep it append-only. The PL/pgSQL trigger docs cover TG_OP and why a statement-level trigger is needed for TRUNCATE, which row triggers never see:

-- records/migrations/1_records.up.sql CREATE TABLE patients ( id BIGSERIAL PRIMARY KEY, full_name TEXT NOT NULL, dob DATE NOT NULL ); -- Which staff members treat which patients. CREATE TABLE care_team ( patient_id BIGINT NOT NULL REFERENCES patients (id), user_id TEXT NOT NULL, PRIMARY KEY (patient_id, user_id) ); CREATE TABLE phi_access_log ( id BIGSERIAL PRIMARY KEY, at TIMESTAMPTZ NOT NULL DEFAULT now(), actor_id TEXT NOT NULL, actor_role TEXT NOT NULL, patient_id BIGINT NOT NULL, action TEXT NOT NULL, -- view_record, view_access_log, ... outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'break_glass')), reason TEXT, request_id TEXT NOT NULL ); CREATE INDEX phi_access_log_patient ON phi_access_log (patient_id, at); CREATE INDEX phi_access_log_actor ON phi_access_log (actor_id, at); -- Append-only: no updates, no truncation, and deletes only from the -- retention job, for rows past the retention period. CREATE FUNCTION phi_access_log_guard() RETURNS trigger AS $$ BEGIN IF TG_OP = 'DELETE' AND current_setting('audit.retention_purge', true) = 'on' AND OLD.at < now() - interval '6 years' THEN RETURN OLD; END IF; RAISE EXCEPTION 'phi_access_log is append-only (%)', TG_OP; END; $$ LANGUAGE plpgsql; CREATE TRIGGER phi_access_log_no_change BEFORE UPDATE OR DELETE ON phi_access_log FOR EACH ROW EXECUTE FUNCTION phi_access_log_guard(); CREATE TRIGGER phi_access_log_no_truncate BEFORE TRUNCATE ON phi_access_log FOR EACH STATEMENT EXECUTE FUNCTION phi_access_log_guard(); -- The application role can insert and select, but cannot delete. -- Purge runs as a dedicated role through a definer function, so -- SET LOCAL from an application connection cannot authorize a delete. CREATE ROLE phi_audit_purge NOLOGIN; REVOKE DELETE, UPDATE, TRUNCATE ON phi_access_log FROM PUBLIC; GRANT SELECT, INSERT ON phi_access_log TO CURRENT_USER; GRANT DELETE ON phi_access_log TO phi_audit_purge; CREATE FUNCTION purge_expired_phi_access_log() RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE n integer; BEGIN PERFORM set_config('audit.retention_purge', 'on', true); DELETE FROM phi_access_log WHERE at < now() - interval '6 years'; GET DIAGNOSTICS n = ROW_COUNT; RETURN n; END; $$; ALTER FUNCTION purge_expired_phi_access_log() OWNER TO phi_audit_purge; REVOKE ALL ON FUNCTION purge_expired_phi_access_log() FROM PUBLIC; GRANT EXECUTE ON FUNCTION purge_expired_phi_access_log() TO CURRENT_USER;

Tested against Postgres, UPDATE, DELETE and TRUNCATE on the log from the application role all fail with the trigger's error. Calling purge_expired_phi_access_log() removes a row from seven years ago, and the same call leaves recent rows in place even if an application connection has set the session flag.

Log the read in the same transaction

Get decides access, writes the log row and reads the patient inside one transaction. If the insert fails, the handler returns an error before the SELECT on patients runs. Denied attempts are committed before the error is returned, so they stay in the log. The auth docs cover the auth handler that supplies the user ID and role:

// records/records.go package records import ( "context" "errors" "time" "encore.app/auth" "encore.dev" encauth "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("records", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type GetParams struct { // Set only for emergency access outside the care team. BreakGlassReason string `query:"break_glass_reason" encore:"sensitive"` } type Patient struct { ID int64 `json:"id"` FullName string `json:"full_name" encore:"sensitive"` DOB time.Time `json:"dob" encore:"sensitive"` } // Get returns a patient record and logs the access in the same // transaction. If the log row cannot be written, no PHI is returned. // //encore:api auth method=GET path=/patients/:id func Get(ctx context.Context, id int64, p *GetParams) (*Patient, error) { actor, _ := encauth.UserID() role := encauth.Data().(*auth.Data).Role tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() var onTeam bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM care_team WHERE patient_id = $1 AND user_id = $2) `, id, string(actor)).Scan(&onTeam); err != nil { return nil, err } outcome := "allowed" switch { case role != "clinician": outcome = "denied" case !onTeam && p.BreakGlassReason == "": outcome = "denied" case !onTeam: outcome = "break_glass" } if err := logAccess(ctx, tx, string(actor), role, id, "view_record", outcome, p.BreakGlassReason); err != nil { return nil, err } if outcome == "denied" { // Commit so the denied attempt stays in the log. if err := tx.Commit(); err != nil { return nil, err } return nil, &errs.Error{Code: errs.PermissionDenied, Message: "not on this patient's care team"} } var pt Patient err = tx.QueryRow(ctx, ` SELECT id, full_name, dob FROM patients WHERE id = $1 `, id).Scan(&pt.ID, &pt.FullName, &pt.DOB) if errors.Is(err, sqldb.ErrNoRows) { if err := tx.Commit(); err != nil { return nil, err } return nil, &errs.Error{Code: errs.NotFound, Message: "patient not found"} } else if err != nil { return nil, err } return &pt, tx.Commit() } func logAccess(ctx context.Context, tx *sqldb.Tx, actor, role string, patientID int64, action, outcome, reason string) error { requestID := "" if t := encore.CurrentRequest().Trace; t != nil { requestID = t.TraceID } _, err := tx.Exec(ctx, ` INSERT INTO phi_access_log (actor_id, actor_role, patient_id, action, outcome, reason, request_id) VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7) `, actor, role, patientID, action, outcome, reason, requestID) return err }

The patient fields carry encore:"sensitive", which per the sensitive data docs redacts them from Encore's request traces, so names and birth dates do not appear in tracing while the request ID in the log still links to the trace. The tag is ignored in local development, so use synthetic patients under encore run.

Access history and retention

Privacy officers query by patient, and reading the log is logged too. The retention job is the only code that sets the flag the trigger checks:

// records/accesslog.go package records import ( "context" "time" "encore.app/auth" encauth "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/cron" ) type Entry struct { At time.Time `json:"at"` ActorID string `json:"actor_id"` Action string `json:"action"` Outcome string `json:"outcome"` Reason *string `json:"reason" encore:"sensitive"` } type History struct { Entries []Entry `json:"entries"` } // AccessHistory answers "who looked at this patient's record". Reading // the log is itself logged. // //encore:api auth method=GET path=/patients/:id/access-log func AccessHistory(ctx context.Context, id int64) (*History, error) { actor, _ := encauth.UserID() role := encauth.Data().(*auth.Data).Role tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() outcome := "allowed" if role != "privacy_officer" { outcome = "denied" } if err := logAccess(ctx, tx, string(actor), role, id, "view_access_log", outcome, ""); err != nil { return nil, err } if outcome == "denied" { if err := tx.Commit(); err != nil { return nil, err } return nil, &errs.Error{Code: errs.PermissionDenied, Message: "privacy officers only"} } rows, err := tx.Query(ctx, ` SELECT at, actor_id, action, outcome, reason FROM phi_access_log WHERE patient_id = $1 ORDER BY at DESC LIMIT 500 `, id) if err != nil { return nil, err } defer rows.Close() h := &History{} for rows.Next() { var e Entry if err := rows.Scan(&e.At, &e.ActorID, &e.Action, &e.Outcome, &e.Reason); err != nil { return nil, err } h.Entries = append(h.Entries, e) } if err := rows.Err(); err != nil { return nil, err } rows.Close() return h, tx.Commit() } var _ = cron.NewJob("phi-access-log-retention", cron.JobConfig{ Title: "Delete access log rows past the retention period", Every: 24 * cron.Hour, Endpoint: PurgeExpired, }) // PurgeExpired is the only application entry point that can delete rows. // The definer function, not this connection, sets the session flag. // //encore:api private func PurgeExpired(ctx context.Context) error { _, err := db.Exec(ctx, `SELECT purge_expired_phi_access_log()`) return err }

The database docs cover migrations and transactions, and the cron job docs cover schedules. encore run starts Postgres locally, so Get with Authorization: Bearer dr-lee and AccessHistory with privacy-1 can be tried against seeded test patients. Cron jobs do not run locally or in preview environments, and in deployed environments the purge is Encore platform managed.

When a coding agent later adds a patient search endpoint, the review question is whether it calls logAccess for each patient it returns, and the trigger guarantees that whatever it logs cannot be edited afterwards by application code.

Best fit: Healthcare teams running a Go API in their own AWS or GCP account who want access logging, its retention job and PHI redaction in traces to live in the same service as the records.

Things to decide early

  • Same transaction or a separate log service. Writing the log in the records database makes a missing log row impossible for a successful read. The audit log for internal tools guide takes the other route, publishing events to a separate audit service, which separates credentials but means a failed publish can leave an access unrecorded. For PHI reads, the same-transaction write plus a copy elsewhere covers both concerns.
  • Who can bypass the trigger. The triggers and the definer function stop application bugs and accidental edits, including a connection that tries to set audit.retention_purge itself. A database owner can still drop them, so restrict who holds those credentials and export the log regularly to storage the application cannot change.
  • Break-glass review. Every break_glass row needs a person to read it within days. Build the report before the feature ships.
  • Retention period. Six years appears in code twice, in the trigger and in the purge. Keep both in sync, or move the period into a table the trigger reads, and confirm the number with your compliance team. The documentation retention rule in 164.316(b)(2) sets six years for required documentation, and applying the same period to audit logs leaves one number to defend.
  • Volume. A busy clinic generates many rows per patient per day. Partition the table by month once it grows, and the retention purge becomes dropping old partitions.

Frequently asked questions

What does HIPAA require for audit logs?

The audit controls standard in 45 CFR 164.312(b) requires mechanisms that record and examine activity in systems that contain or use electronic protected health information. HHS guidance notes that the Security Rule does not say which fields to collect or how often to review them, so those choices follow from your risk analysis. Regular review of activity records is a separate required procedure in 164.308(a)(1)(ii)(D).

Should a PHI audit log record reads or only changes?

It should record reads as well, since most questions an audit log has to answer are about access, such as which staff member opened a patient's record without being on the care team. Record every view, search and export of patient data, including denied attempts, with the actor, the patient, the action, the outcome and a request ID.

How do you make an audit log table append-only in Postgres?

Add a BEFORE UPDATE OR DELETE row trigger and a BEFORE TRUNCATE statement trigger that raise an exception. If old rows must be purged after the retention period, let the trigger allow deletes only of rows older than that period and only when a SECURITY DEFINER function owned by a dedicated role sets the session flag. The application role can execute that function and cannot delete from the table directly. Database owners can still drop triggers, so keep a copy outside the application database too.

How long should PHI audit logs be kept?

164.312(b) does not name a period, and six years is a defensible starting point, since it matches the HIPAA documentation retention rule in 164.316(b)(2), and state law or contracts can require longer. Decide the period with your compliance team and encode it in one place.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders