-
Enumerate auditable events first — code to a closed list, not "log everything." An audit log with too much noise is as useless as one with gaps. Audit exactly the security-relevant control points:
| Category | Examples | SOC2 (TSC) | HIPAA |
|---|
| Authentication | login success/fail, MFA, logout, password/key change, session revoke | CC6.1 | §164.312(b) |
| Authorization decisions | access denied, privilege grant/revoke, role change, impersonation start/stop | CC6.3 | §164.308(a)(4) |
| Sensitive data access | read/export/print of PII/PHI/financial records, bulk query, report download | CC6.1 / CC7.2 | §164.312(b) audit controls |
| Config / security changes | feature flag, retention policy, encryption setting, integration/webhook, IAM policy | CC8.1 | §164.308(a)(1) |
| Admin / break-glass ops | user delete, data purge, override, prod DB access, support impersonation | CC6.1 | §164.308(a)(3) |
Define this list with security/compliance, not ad hoc per feature. Each event gets a stable action constant (e.g. user.role.granted, record.exported) — never a free-text string you can't query or version.
-
Fix one structured schema and emit it everywhere. Required fields, machine-parseable (JSON), one shape across services:
{
"id": "01J8...ULID",
"ts": "2026-06-15T09:41:02.117Z",
"action": "record.exported",
"actor": { "type": "user", "id": "u_8821", "auth": "session", "on_behalf_of": "support_agent_31" },
"target": { "type": "patient_record", "id": "p_5567" },
"result": "allow",
"reason": "policy:export.phi.granted",
"source_ip": "203.0.113.7",
"user_agent": "...",
"request_id": "req_9f3c",
"tenant": "org_204",
"meta": { "row_count": 1420, "format": "csv" }
}
Use ULID/UUIDv7 for id (sortable + a natural dedup key for at-least-once emitters). on_behalf_of is mandatory whenever an admin/support acts as another user — impersonation without it is an audit gap auditors will flag.
-
Keep the audit log physically separate from application logs. Different store, different write credentials, different retention. App logs are mutable, sampled, debug-grade; audit logs are not. Mixing them means a developer with log-write access can forge or delete audit history. Ship audit events to a dedicated append-only sink (dedicated Postgres table with revoked UPDATE/DELETE, a WORM object store, or a managed audit service) — never the same index/bucket as console.log output.
-
Make tamper-evidence structural, not a promise. Pick by threat model:
| Mechanism | Detects | Use when | Cost |
|---|
Hash chain (each row stores hash(prev_hash + entry)) | any edit/delete/reorder of past rows | default — works in any DB, cheap, verifiable offline | 1 hash/write + periodic verify job |
| WORM / object-lock (S3 Object Lock COMPLIANCE, GCS retention lock) | deletion/overwrite before retention expiry, even by root | regulated retention, untrusted operators | storage + immutable retention window |
| Per-entry digital signature (HSM/KMS sign each batch) | forgery + proves origin/non-repudiation | strict non-repudiation, third-party verifier | KMS calls, key mgmt |
| External anchoring (periodic chain-head to a notary/transparency log) | insider with full DB+app access rewriting the whole chain | high-value targets, hostile-insider model | scheduled external write |
Default: hash chain + WORM storage. The chain proves no row was altered; object-lock proves no row was deleted. Hash chain alone doesn't stop a truncate-and-rebuild by someone with full write access — pair it with object-lock or external anchoring for that threat. Restrict write access to an append-only path (DB role with INSERT only; bucket policy allowing PutObject but not DeleteObject/overwrite); nobody — including the app service account — gets row-level update/delete.
-
Never log secrets or raw PII/PHI — log references and minimized metadata. The audit log is high-value, long-retention, and widely readable by auditors; a raw payload in it is a second copy of your most sensitive data with the worst blast radius. Log the id of the record touched, not its contents. For changes, log a field-name diff (changed: ["role","status"]) or hashed before/after, never the literal old/new PII values. Run a serializer allowlist + a secret/PII scrubber on the meta object before write; drop anything not on the allowlist. Tokens, passwords, full card/SSN, message bodies, query result rows → never.
-
Set retention per regulation, then enforce it in the store — don't rely on a cron DELETE. Map each event category to its longest applicable requirement and configure the immutable window so deletion can't happen early and does happen on schedule:
| Regime | Typical minimum | Enforce via |
|---|
| HIPAA | 6 years | object-lock retention 6y + lifecycle expiry |
| SOC2 | 1 year (often 7 for evidence) | partition + lifecycle policy |
| PCI-DSS | 1 year (3 months hot) | hot tier + cold archive |
Use time-partitioned tables or object lifecycle rules so expiry is declarative and audited, not a script someone can disable. Don't over-retain past the requirement (that's its own liability under privacy law — see map-privacy-data-gdpr).
-
Make it queryable for investigations. A trail you can't search is forensically useless. Index actor.id, target.id, action, ts, tenant, request_id. The two queries every investigation needs: "everything actor X did in window T" and "everyone who touched target Y." Tie request_id back to operational traces (observability-instrument owns those) so an investigator can pivot from an audit entry to the full request. Provide a read-only investigator role separate from the write path.
-
Emit exactly once, synchronously to the decision, fail-closed on sensitive actions. Write the audit record in the same transaction/critical path as the action it records (or via a transactional outbox) so an action can never succeed without its record. For sensitive control points (data export, permission grant, break-glass), if the audit write fails, deny the action — an unlogged privileged action is worse than a blocked one. Dedup downstream consumers on id. Never fire-and-forget an audit write for a security-critical event.
Done = every event in the closed list emits exactly one complete record on a physically separate, INSERT-only, retention-locked store; the chain verifier detects any edit/delete; no secret or raw PII/PHI appears in any entry; and the two core investigation queries return complete, correct results mapped to their SOC2/HIPAA controls.