Compliance
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 },
)
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:
- Keep audit rows immutable.
- Encrypt PII fields with a per-actor key (held outside the audit store).
- 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:
| Sink | Retention mechanism |
|---|---|
| FS | Combine createFsDrain({ maxFiles }) with a daily compactor. |
| Postgres | Schedule DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'. |
| Axiom / Datadog / Loki | Set the dataset retention policy in the platform. |
| S3 Object Lock | Configure 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()withlog.audit.deny()on the negative branch of every authorisation check. - Leaking PII through
changes.auditDiff()runs through yourRedactConfig, but only if the field paths are listed. Addpassword,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.idwith the session id.actor.idis the stable user id (or system identity). Correlate sessions viacontext.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) orwithAudit()to keep coverage parity with your HTTP routes. - Skipping
await: trueon 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.