Design an audit log system
The interviewer asks
“Design an audit log system for a fintech or B2B SaaS company. Every security-relevant action (logins, permission changes, money movements, data exports, admin actions, AI agent tool calls) must be recorded, cannot be altered later, can be searched by customers and auditors, and must be kept for years.”
What a strong answer looks like
A strong answer first separates audit logs from application logs: audit events are a product and compliance record with a fixed schema (who, what, on which resource, when, from where, outcome, why), guaranteed delivery and retention measured in years, not debug text that may be sampled. Capture events reliably with the transactional outbox, so an action and its audit record commit together. Ship them through Kafka to an append-only store, with tamper evidence by hash chaining per stream and periodic signed checkpoints (Merkle roots) anchored in WORM storage (S3 Object Lock). Serve customer queries from a per-tenant search index (ClickHouse or OpenSearch) and archive to Parquet in S3 for 7-10 years. Cover PII (redaction, crypto-shredding for erasure requests versus immutability), access control on the audit log itself, export/SIEM streaming, and AI agent actions. The trickiest trade-offs: completeness vs availability (fail closed or not), and immutability vs right to erasure.
Concepts to mention
1 Requirements
Functional
- Record events with a standard schema: actor, action, target resource, tenant, timestamp, source IP/device, outcome, request/trace id, before/after values where relevant
- Cover user actions, admin and support-staff actions, API key usage, system jobs and AI agent tool calls
- Tenants search and filter their own audit trail in the dashboard (by actor, action, resource, time)
- Export to CSV/JSON and stream to customer SIEM (Splunk, Datadog) via webhook or S3
- Prove that records were not changed or deleted (tamper evidence) for auditors
- Retention per policy: e.g. 1 year searchable, 8-10 years archived for financial records
- Legal hold: stop deletion of specific tenants' records during investigations
Non-functional
- No silent loss: every committed business action has its audit event (at-least-once, deduped by event id)
- Event searchable within 60 seconds of the action
- Tenant searches over 90 days p95 under 2 seconds
- Ingest ~20k events/s peak; ~1B events/month
- Audit log access itself is audited and limited to need-to-know roles
- Storage in-region for regulated data (e.g. RBI data localisation for payment data in India)
Out of scope (say it out loud)
- Debug/application logging (separate logging platform)
- Real-time fraud detection on these events (a consumer, not part of this system)
- Full SIEM product features such as correlation rules
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
gRPCAuditClient.Record(event) (library, writes to outbox) | Used by services in the same DB transaction as the business change; assigns event_id (UUIDv7). |
POST/internal/v1/audit/events | For services without an outbox (edge, legacy); synchronous ack after Kafka write with acks=all. |
GET/v1/audit-logs?actor=&action=&resource=&from=&to=&cursor= | Tenant-scoped search for customers and auditors; cursor pagination by (ts, event_id). |
POST/v1/audit-logs/exports | Async export job for a time range; returns a job id, result is a signed S3 URL. |
GET/v1/audit-logs/proofs?event_id= | Return the event, its hash chain position and the signed checkpoint, so an auditor can verify integrity. |
PUT/admin/v1/tenants/{id}/legal-hold | Place or release a legal hold; itself audited and requires two approvers. |
4 Data model
audit_outboxEach service's Postgresevent_id (PK)payload jsonbcreated_atpublished_atWritten in the same transaction as the business change; a relay or Debezium CDC publishes and marks rows, which are pruned after a few days.
audit.events topicKafkakey = tenant_idvalue = canonical event (Protobuf with schema version)Key by tenant so one tenant's events are ordered within a partition; replication factor 3, min.insync.replicas 2, retention 7-14 days for replay.
audit_eventsClickHouse (MergeTree)tenant_idtsevent_idactor_typeactor_idactionresource_typeresource_idoutcomeiptrace_iddetails (JSON)seqhashORDER BY (tenant_id, ts) so tenant + time range queries read little data; ReplacingMergeTree on event_id to absorb duplicates; partition by month for retention.
chain_checkpointsPostgres + S3 Object Lock (compliance mode)stream_idseq_fromseq_tomerkle_rootprev_checkpoint_hashsignature (KMS)tsSigned and written to WORM storage that even admins cannot delete until retention ends; optionally published externally (e.g. emailed to customers or a transparency log).
archiveS3 Parquet (Object Lock), lifecycle to Glaciertenant_id/yyyy/mm/dd/part-*.parquetLong retention at low cost; queried with Athena/Trino for rare old investigations.
tenant_keysKMStenant_id or subject_id → data keyPersonal fields encrypted per data subject; deleting the key makes them unreadable (crypto-shredding) without breaking the hash chain.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Admin changes a user's role
- 1A tenant admin promotes user U to 'payouts approver'. The IAM service updates the role row and, in the same Postgres transaction, inserts an audit_outbox row: actor=admin A, action=role.granted, target=U, before=viewer, after=payouts_approver, ip, trace_id, event_id (UUIDv7).
- 2If the transaction rolls back, neither the change nor the audit event exists; if it commits, both do. No dual-write gap.
- 3The outbox relay (or Debezium reading the WAL) publishes the event to Kafka keyed by tenant_id with acks=all and idempotent producer, then marks it published.
- 4The sealer consumes per partition, assigns a sequence number per tenant stream, computes hash = SHA-256(prev_hash + canonical event bytes), and every minute signs a Merkle root of the batch with a KMS key and writes the checkpoint to S3 Object Lock.
- 5The indexer batch-inserts into ClickHouse (seq and hash included); duplicates from at-least-once delivery collapse on event_id.
- 6The archiver writes hourly Parquet files to the WORM bucket; the SIEM forwarder sends the event to the tenant's Splunk endpoint with retries.
- 7Within ~30 seconds the tenant admin sees the entry in the audit log page.
Auditor verifies nothing was deleted
- 1An auditor requests proofs for tenant T for March.
- 2The query service returns events with their sequence numbers and hashes, plus the signed checkpoints for that range.
- 3A verifier tool recomputes the chain: sequence numbers must be gap-free, each hash must match, and each Merkle root must match a checkpoint whose KMS signature is valid.
- 4Any edited or missing event breaks the chain at a precise point, which is reported.
7 Deep dives
Never lose an audit event: outbox, not dual writes
The naive approach is: update the database, then call the audit service. If the process crashes between the two, or the audit call times out, the action happened with no record, which is exactly the gap an auditor or attacker cares about. Calling audit first is also wrong: the business transaction may then roll back, leaving a record of something that never happened. The transactional outbox fixes both: the business row and the audit row are written in one local transaction, and a separate relay publishes outbox rows to Kafka. Delivery is at-least-once, so the event_id (generated once at write time) is the dedupe key downstream. For actions that do not touch a database (a read of sensitive data, a data export, a support agent viewing a customer's KYC document), the service must record the audit event synchronously before returning the data and fail the request if it cannot, which is a deliberate fail-closed choice for high-risk reads. For low-risk events you may choose fail-open with a local disk spool. Also cover the edges: actions done directly in the database by engineers (break-glass access) must go through a bastion or proxy that records sessions, otherwise the most powerful actions escape the audit trail.
Tamper evidence with hash chains and signed checkpoints
Append-only tables are not tamper-proof: a DBA or an attacker with admin access can still edit rows. The goal is tamper evidence: any change is detectable. Give each stream (e.g. one per tenant per partition) a strict sequence number, and store hash_n = SHA-256(hash_(n-1) || canonical_bytes(event_n)). Editing or deleting any event changes every later hash and leaves a gap in sequence numbers. A chain alone is not enough, because someone with full access could recompute the whole chain. So periodically (every minute) compute a Merkle root over new events, sign it with a key held in KMS/HSM that application admins cannot export, and write the checkpoint to S3 with Object Lock in compliance mode, which even the root account cannot delete before the retention date. For stronger guarantees, publish checkpoints outside your control (send daily roots to customers or an external timestamping service). Merkle trees also give small inclusion proofs: you can prove one event is in a checkpoint without revealing others. Canonical serialisation matters: hash a fixed byte encoding (e.g. deterministic Protobuf or sorted JSON), not whatever the JSON library outputs today.
Immutability vs privacy and the right to erasure
Audit logs contain personal data: names, emails, IPs, sometimes PAN or account numbers. Privacy laws (GDPR, India's DPDP Act 2023) give people rights to erasure, while financial regulations require keeping transaction records for years. These conflict with an immutable log. The approach: minimise first, store references (user_id, resource_id) rather than copies of personal data, and redact secrets and full document contents at capture time. For the personal fields that must be stored, encrypt them with a per-data-subject key held in KMS, and include only the ciphertext in the hashed record. When an erasure request is valid and no legal retention applies, delete the key (crypto-shredding): the data becomes unreadable but the hash chain still verifies, because the ciphertext did not change. Legal holds override deletion. Access to the audit log itself is a sensitive action: restrict it by role, scope customers strictly to their tenant (tenant_id filter enforced by the query service, not the UI), and audit every search and export of audit data.
8 Trade-offs
ClickHouse vs Elasticsearch vs Postgres for search
Postgres is fine up to a few hundred million rows with partitioning by month and an index on (tenant_id, ts). ClickHouse handles billions of rows cheaply with columnar compression and fast filtered scans on (tenant_id, ts). Elasticsearch gives flexible full-text search on details but costs more in storage and operations. Audit queries are mostly structured filters, so ClickHouse is a good default; keep the search store as a rebuildable projection, with Kafka plus the archive as the source of truth.
Synchronous fail-closed vs async fail-open capture
Fail-closed (refuse the action if audit cannot be written) gives completeness but makes the audit pipeline a hard dependency for the product. The outbox gives both for DB-backed actions: the write is local and always succeeds with the transaction. Use fail-closed only for sensitive reads and exports; fail-open with a durable local spool and alerting for low-risk events.
One global chain vs many chains
One global chain is simplest to verify but serialises all writes through one sequencer. A chain per tenant (or per partition) parallelises and lets you hand one tenant its own proofs. A global Merkle root per minute over all per-tenant chain heads ties them together.
9 Bottlenecks
Huge tenant
One enterprise tenant generating 50% of events makes its Kafka partition and chain hot. Split its stream into several sub-chains (tenant#0..7) by hashing the resource id, each with its own sequence; the minute Merkle root covers all of them.
Long-range investigations
A 5-year query cannot run on the hot store. Route old ranges to Athena/Trino over Parquet in S3 as an async job with results delivered by link, and tell users upfront that archive queries take minutes.
10 Failure handling
Kafka unavailable
Outbox rows accumulate in each service's database; nothing is lost. Alert on outbox lag; when Kafka returns, relays publish the backlog in order. For services using the direct API, a local disk spool buffers events.
Indexer bug corrupts the search store
The search store is a projection. Fix the bug, drop the affected partitions and replay from Kafka (recent) or the Parquet archive (older). The chain verification also detects silent corruption.
Sealer crashes mid-batch
On restart it reads its last committed checkpoint (sequence and hash) and continues from the next offset; events are hashed deterministically, so reprocessing yields the same hashes and duplicate checkpoints are idempotent by (stream, seq range).
11 What impresses
- Explains why 'append-only table' is not tamper-proof and adds externally anchored signed checkpoints
- Uses the outbox so the action and its audit record commit atomically
- Resolves immutability vs DPDP/GDPR erasure with per-subject keys and crypto-shredding
- Includes support staff, break-glass DB access and AI agent tool calls in scope
- Separates source of truth (Kafka + archive) from the search projection
- Mentions canonical serialisation for stable hashes
12 Mistakes
- Treating audit events like debug logs that can be sampled or dropped
- Writing the business change and the audit event as two separate network calls
- Calling a database table immutable without tamper evidence
- Storing full personal data and secrets in audit records
- Letting the UI enforce tenant filtering instead of the query service
- Ignoring retention costs and legal holds
13 Follow-ups
Lessons this design uses
Practice questions
- An auditor finds that a few customer wallet balances do not equal the sum of their ledger entries. How would you investigate, and how should a ledger be designed so this is prevented and caught early?
- Regulators require your fintech to keep an audit log of every admin action, such as changing a user's KYC status or limits, and to prove the log was not altered. How would you design it?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.