Audit Logs

Compliance

Integrity, redact presets, GDPR vs append-only, retention windows, and the most common pitfalls when shipping audit logs to production.

Compliance frameworks (SOC2, HIPAA, GDPR, PCI) ask the same five questions of every audit log: who, what, when, from where, with which outcome, plus how do we know it wasn't tampered with. evlog answers each one through composition of the existing primitives.

Integrity

Hash-chain the audit log so any tampering is detectable. Each event's hash includes the previous hash, so deleting a row breaks the chain forward of that point.

auditOnly(
  signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }),
  { await: true },
)
Rotate secret for HMAC-signed audits annually. When you rotate, embed a key id alongside the signature (e.g. extend AuditFields with keyId via declare module) so old events stay verifiable against the previous secret. Verifiers should look up the key by id, not assume a single global secret.

See Drains & Integrity for the difference between HMAC and hash-chain.

Redact

Audit events run through your existing RedactConfig. Compose with the strict audit preset to harden PII handling:

import { auditRedactPreset } from 'evlog'

initLogger({
  redact: {
    paths: [
      ...(auditRedactPreset.paths ?? []),
      'user.password',
    ],
  },
})

The preset drops Authorization / Cookie headers and common credential field names (password, token, apiKey, cardNumber, cvv, ssn) wherever they appear inside audit.changes.before and audit.changes.after.

GDPR vs append-only

Append-only audit logs collide with GDPR's right to be forgotten. Recommended pattern today:

  1. Keep audit rows immutable.
  2. Encrypt PII fields with a per-actor key (held outside the audit store).
  3. To "forget" a user, delete their key — the audit row stays, the chain stays valid, the PII becomes unreadable.

A built-in cryptoShredding helper is on the follow-up roadmap.

Retention

Retention is a storage-layer concern by design. evlog's audit layer doesn't enforce retention windows because every supported sink already has a stronger, audited mechanism for it. Pick the one matching your sink:

SinkRetention mechanism
FSCombine createFsDrain({ maxFiles }) with a daily compactor.
PostgresSchedule DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'.
Axiom / Datadog / LokiSet the dataset retention policy in the platform.
S3 Object LockConfigure lifecycle rules + Object Lock retention period.

Document the chosen window in your security policy. Auditors care about the written rule, not the enforcing component.

Common Pitfalls

  • Logging only successes. Auditors care most about denials. Always pair log.audit() with log.audit.deny() on the negative branch of every authorisation check.
  • Leaking PII through changes. auditDiff() runs through your RedactConfig, but only if the field paths are listed. Add password, token, apiKey, etc. once globally so you never have to think about it again.
  • Treating audits as observability. Don't sample, downsample, or summarise audit events. Force-keep is on by default — don't disable it.
  • Conflating actor.id with the session id. actor.id is the stable user id (or system identity). Correlate sessions via context.requestId / context.traceId, never via the actor.
  • Forgetting standalone jobs. Cron tasks, queue workers, and CLIs trigger audit-worthy actions too. Use audit() (no request) or withAudit() to keep coverage parity with your HTTP routes.
  • Skipping await: true on the audit drain. Without it, audits are fire-and-forget — a crash between the event being emitted and the drain flushing means the action happened but no audit row exists.