Skip to content

    System Design

    Audit Logging: Tamper-Evident Records, What to Log, and Retention

    Design an audit log that works as evidence: what to record and never record, how hash chains and external anchors expose tampering, and how long to keep it.

    By Published 9 min read

    Share

    Focused lessonintermediateBackend System Design

    Before this: Authentication vs Authorization, Sessions vs Tokens

    What you'll learn: By the end of this lesson you will be able to separate an audit log from an application log, choose what to record and what never to record, explain why a hash chain alone misses tail truncation and full rewrites, add an external anchor and a verifier, and set retention with NIST SP 800-92 and the OWASP Logging Cheat Sheet in hand.

    A bank vault keeps a visitor book with numbered pages, filled in ink. If someone tears out a page, the missing number shows. If someone tears out the last three pages and the book simply ends, nothing shows, unless the manager photographed the last page each evening and keeps the photo at home. An audit log has the same two weaknesses, and the same fix.

    ShopStream Stage 6 shows a JSON audit record and a hash chain, and Security for Distributed Systems has an audit paragraph. This lesson covers what they leave open: completeness, anchoring, what to leave out, and retention.

    Verified against the OWASP Logging Cheat Sheet, NIST SP 800-92 (2006), the AWS CloudTrail log file integrity documentation and RFC 9162, on 2026-11-08.

    The core idea

    An application log helps you debug. An audit log has to survive an argument. It records who did what to which resource, with what result, so an investigator or auditor can rely on it. That needs three properties an ordinary log lacks: complete (important actions cannot happen without a record), attributable (the actor comes from the authenticated session, never the request body) and tamper-evident (changes and deletions can be detected). NIST SP 800-92 notes that many rootkits are designed to alter logs, and that keeping original logs unaltered supports their evidentiary use.

    Three examples run through the lesson: a minimal example (a Java hash chain with an anchor and verifier), a realistic example (ShopStream's fee-change audit) and a failure example (an insider deletes the last records).

    What to log, and what never to log

    The OWASP Logging Cheat Sheet says each event must record "when, where, who and what": a timestamp and an interaction identifier linking related events, the application and host, the source address and user identity, and the event type, severity and description. Among the events it says to log where possible: authentication successes and failures, authorisation failures, use of higher-risk functions (user administration, administrative privilege, default, shared or "break-glass" accounts), access to sensitive data, and data import or export. For ShopStream: refunds, fee changes, role grants, bulk exports, support access to payment history, and denied attempts at these.

    What stays out: the cheat sheet says to remove, mask, hash or encrypt session identifiers, access tokens, passwords, connection strings, encryption keys, payment cardholder data and sensitive personal data. Store ids, not names. Sanitise event data against log injection, prefer structured formats such as JSON, and limit the size of user-supplied values; in Java, parameterised logging alone does not prevent CR/LF injection, so use an encoder that escapes values.

    {
      "seq": 4812,
      "occurred_at": "2026-11-08T09:41:12Z",
      "actor": { "type": "staff", "id": "agent_204" },
      "action": "refund.issue",
      "resource": { "type": "payment", "id": "pay_7731" },
      "details": { "amount_minor": 1000, "currency": "USD" },
      "outcome": "success",
      "request_id": "req_5c1e"
    }

    Identifiers are examples. The gap-free seq counter is new compared with the Stage 6 record.

    Making it tamper-evident

    In a hash chain each entry's hash covers the previous hash, a sequence number and the payload, so editing an entry breaks every later hash. NIST SP 800-92 describes the general technique, log file integrity checking: compute a message digest per file, store it securely, compare later, and protect the original digests (for example on read-only media). Its 2006 text names MD5 and SHA-1 as the most common algorithms; use SHA-256, as the ledger lesson and CloudTrail do.

    flowchart LR accTitle: A hash chain with an external anchor accDescr: Four entries are linked, each hash covering the previous hash, the sequence number and the payload. The latest sequence number and hash are copied to a separate store that the log operators cannot write. G["genesis"] --> E1["#1 hash1"] E1 --> E2["#2 hash2"] E2 --> E3["#3 hash3"] E3 --> E4["#4 hash4"] E4 -. "hourly copy of seq and hash" .-> A[("Anchor store, separate account, write-once")]

    Figure 1. A hash chain plus an anchor. How to read this: solid arrows are the chain. The dashed arrow is the anchor, a copy of the latest sequence number and hash kept where the log's operators cannot change it. The chain proves consistency; the anchor proves it is the same chain as before.

    The code is short. The verifier re-derives every link, checks the sequence is gap-free, and compares against the anchor:

    static String link(long seq, String payload, String prev) throws Exception {
        return sha256(prev + "|" + seq + "|" + payload);
    }
    
    static String verifyAgainst(List<Entry> log, Anchor a) throws Exception {
        String chain = verifyChain(log);                 // re-derives every link, checks seq is gap-free
        if (!chain.equals("chain ok")) return chain;
        Entry last = log.isEmpty() ? null : log.get(log.size() - 1);
        if (last == null || last.seq() < a.seq()) return "chain ok but TRUNCATED: anchor says seq " + a.seq();
        Entry at = log.get((int) a.seq() - 1);
        return at.hash().equals(a.hash()) ? "chain ok and matches anchor" : "chain ok but REWRITTEN: differs from anchor at seq " + a.seq();
    }

    Run on JDK 26.0.1 against a four-entry log whose head was anchored, five attacks print:

    1 intact                     : chain ok and matches anchor
    2 edit one record            : broken at seq 2
    3 delete the last two        : chain alone says 'chain ok'; with anchor: chain ok but TRUNCATED: anchor says seq 4
    4 edit and recompute chain   : chain alone says 'chain ok'; with anchor: chain ok but REWRITTEN: differs from anchor at seq 4
    5 remove a middle record     : gap before seq 4

    Rows 3 and 4 are the lesson. Deleting the tail leaves a shorter, valid chain, and someone with write access to the whole store can edit a record and recompute every later hash. Neither is visible to the chain; both are visible to a copy of the head kept elsewhere. In row 5 the message names the first entry seen after the gap.

    Real systems use the same idea. AWS CloudTrail's log file integrity validation hashes every delivered log file; every hour it delivers a digest file referencing the last hour's files with a hash of each, signed with a private key, including the previous digest's signature, in a separate folder so security policies can differ (SHA-256 for hashing, SHA-256 with RSA for signing). Transparency logs go further: RFC 9162 defines a log as a single append-only Merkle tree, with inclusion proofs that an entry is in the tree and consistency proofs that an older tree head is a prefix of a newer one, so a verifier can check that the log only ever grew.

    Completeness and retention

    Tamper-evidence covers what was written. Completeness covers what should have been. If the audit write is a best-effort call after the business action, a crash leaves an action with no record. The stronger design inserts the audit event in the same database transaction as the change (an outbox, as in the order pipeline) and ships it onward asynchronously; the sequence number then exposes any gap. Where an action cannot be wrapped in a transaction, document the window.

    sequenceDiagram accTitle: Writing an audit event in the same transaction as the change accDescr: The service applies the business change and inserts the audit event in one transaction. A shipper later copies unshipped events to the append-only audit store, which a verifier checks against hourly anchors. participant S as Payments service participant D as Service database participant H as Shipper participant A as Audit store S->>D: BEGIN: post refund + insert audit event D-->>S: COMMIT (both or neither) H->>D: read unshipped events H->>A: append (append-only login) Note over A: verifier job checks chain and anchor hourly

    Figure 2. No action without a record. How to read this: the refund and its audit event commit together; the shipper moves events to a store the application cannot edit.

    Retention is a policy decision. The OWASP cheat sheet says logs must not be destroyed before the required period or kept beyond it, and that legal, regulatory and contractual obligations may set the periods. NIST SP 800-92 distinguishes log retention (regular archiving as normal operations) from log preservation (keeping logs that would normally be discarded because they record activity of particular interest, typically for investigations). Keep periods as configuration, store ids rather than personal details so erasure requests do not force a chain rewrite, restrict and review read access, and record all access to the logs. Synchronise time across servers, or record a confidence level where you cannot.

    Trade-offs

    Choice Gain Cost Wrong when
    Same-transaction audit write No action without a record Slightly slower writes The action has no transactional store
    Hash chain only Cheap detection of edits Misses truncation and rewrites It is the only control
    Chain plus external anchor Detects truncation and rewrites A second store and a verifier Nobody will check the anchor

    In practice: "who changed the fee rate?"

    This is the realistic example, an educational scenario on ShopStream that describes no real company.

    This is a realistic scenario, not a case history.

    The situation

    A store disputes a fee. Finance asks who changed the fee rate for that store's category, and when. The service logs show the deploy, not the person.

    Starting architecture

    ShopStream audit path after this lessonAdmin console to Fees service (change fee); Fees service to Shipper; Shipper to Audit store (append); Audit store to Anchor (hourly head); Verifier to Audit store (verify)Admin consoleAdmin consoleFees service: change + audit event, one commitFees servicechange + auditevent, one commitShipper: unshipped eventsShipperunshipped eventsAudit store: insert-only loginAudit storeinsert-only loginAnchor: other account, write-onceAnchorother account,write-onceVerifier: chain + anchorVerifierchain + anchorchange feeappendhourly headverify

    Figure 3. The audit path. How to read this: events reach the insert-only store only through the shipper. The head goes hourly to an account the store's operators cannot write, and a verifier checks the chain against it.

    Options considered

    Option Behaviour Risk
    A. Application log lines Searchable, no guarantees Rotated, editable, no gap detection
    B. Audit table in the service database Same transaction Anyone with database write access can edit it
    C. B plus shipping to an insert-only store with an anchor Evidence-grade A second store and a verifier to run

    The decision

    Option C. The fee service writes the event with its change; the actor comes from the authenticated session; the shipper appends to the audit store; a verifier runs hourly and pages on broken, gap or TRUNCATED.

    Rollout and verification

    Ship the transactional write first and compare event counts with change counts for a week; add the shipper and store; enable the anchor and verifier last. Verify with a drill: edit a copy, truncate a copy, and confirm the verifier flags each.

    The 2 a.m. failure

    The verifier pages: chain ok but TRUNCATED: anchor says seq 4812, and the log's last entry is 4810.

    sequenceDiagram accTitle: A truncation detected by the anchor accDescr: An insider with store access deletes the last two audit entries. The chain still verifies, but the hourly anchor records a higher sequence number, so the verifier reports truncation. participant I as Insider participant A as Audit store participant N as Anchor participant V as Verifier I->>A: delete entries 4811 and 4812 Note over A: chain from 1 to 4810 is still valid V->>A: read log head V->>N: read anchored head (4812) Note over V: log ends at 4810, anchor says 4812: raise TRUNCATED

    Figure 4. Truncation is invisible to the chain and visible to the anchor. How to read this: the insider changed the store but could not change the anchor.

    First three checks: confirm the anchor is intact, check who had delete rights on the store, and compare with the service database's unshipped events for 4811 and 4812. If the source still holds them, re-ship; if not, record the lost-evidence window. Revoke the delete path and preserve the logs (NIST's term) for the investigation.

    Architect's notes

    • Separate duties: the people who run the audit store must not control the anchor.
    • Test the verifier with a tamper drill.
    • Log denials, not only successes; repeated denied admin attempts are the early signal.

    Common mistakes

    • Letting the request choose the actor. Take identity from the authenticated session.
    • Logging secrets or full personal data because "it is only the audit log".
    • A chain with no anchor and no sequence numbers, which misses truncation, rewrites and gaps.
    • Keeping everything forever. Retention must not run beyond what is required.

    Key takeaways

    • An audit log is evidence: complete, attributable and tamper-evident.
    • Use the OWASP "when, where, who, what" fields; log privileged and break-glass use, exports and denials; exclude secrets, tokens and card data.
    • A hash chain detects edits and gaps, not truncation or a full recompute; an external anchor detects both.
    • Write the audit event in the same transaction as the action, and ship it to an insert-only store.
    • Retention is a policy: not shorter than required, not longer than allowed, with ids instead of personal data.

    Practice exercises

    Exercise 1 (30 minutes): List ten ShopStream actions that need an audit record and the fields of each entry.

    Exercise 2 (2 hours): Extend the chain demo to sign the anchor with a key and verify it, and add a sixth attack: replacing the anchor with a forged one.

    Exercise 3 (half a day): Design the audit pipeline for three ShopStream services: transactional write, shipping, store permissions, anchoring cadence, verifier alerts and retention configuration.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments