<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: gentlyding</title>
    <description>The latest articles on DEV Community by gentlyding (@gentlyding).</description>
    <link>https://dev.to/gentlyding</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110469%2F8f18dbaa-3fd6-401b-85a3-0de9dd7ea150.jpg</url>
      <title>DEV Community: gentlyding</title>
      <link>https://dev.to/gentlyding</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gentlyding"/>
    <language>en</language>
    <item>
      <title>Read Replicas Promised Scale. They Also Gave You Stale Data.</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:57:02 +0000</pubDate>
      <link>https://dev.to/gentlyding/read-replicas-promised-scale-they-also-gave-you-stale-data-2jhh</link>
      <guid>https://dev.to/gentlyding/read-replicas-promised-scale-they-also-gave-you-stale-data-2jhh</guid>
      <description>&lt;p&gt;You added a read replica to take pressure off the primary. Reads got faster, the primary stopped sweating, everyone was happy — until a user updated their profile, hit refresh, and saw the old value stare back at them. Or an order flipped to "paid" in the admin panel while the customer-facing page still said "pending" for ten seconds. None of that is a bug in your code. It's the shape of asynchronous replication, and almost nobody designs for it up front.&lt;/p&gt;

&lt;p&gt;This isn't a post arguing against replicas. It's a post about the one cost nobody puts on the whiteboard: a read replica gives you throughput, not freshness. Those are different promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "asynchronous replication" actually means
&lt;/h2&gt;

&lt;p&gt;With a typical primary-replica setup (Postgres streaming replica, MySQL read-only replica, a cloud reader endpoint, etc.), the replica applies changes &lt;em&gt;after&lt;/em&gt; they've committed on the primary. There's a lag — usually milliseconds, sometimes seconds, occasionally minutes when something backs up.&lt;/p&gt;

&lt;p&gt;During that window, the replica is serving data that is simply not current. Not wrong, exactly. Just older than the primary by the length of the lag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Ada'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- commits at T0&lt;/span&gt;
&lt;span class="n"&gt;replica&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;still&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Bob'&lt;/span&gt; &lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;redo&lt;/span&gt; &lt;span class="n"&gt;reaches&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;T0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a client reads from the replica in that gap, it gets 'Bob'. The write succeeded; the read just didn't see it yet. That's a &lt;strong&gt;read-after-write (read-your-own-write) violation&lt;/strong&gt;, and it's the single most common surprise people hit after adding a replica.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the lag actually comes from
&lt;/h2&gt;

&lt;p&gt;It's easy to blame "the network," but the sources are more varied:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apply-queue backlog.&lt;/strong&gt; The replica can receive WAL faster than it can apply it, especially under heavy write load or large transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running queries on the replica.&lt;/strong&gt; A reporting query saturating I/O delays replication apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One huge transaction.&lt;/strong&gt; A multi-GB bulk update can stall the apply thread for its full duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups / VACUUM / maintenance windows.&lt;/strong&gt; These compete for the same resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network partition or replica restart.&lt;/strong&gt; Now you're behind by however long it was down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point: lag is not a constant you can subtract away. It's a distribution, and the tail is what bites you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instinct that makes it worse: route &lt;em&gt;everything&lt;/em&gt; to the replica
&lt;/h2&gt;

&lt;p&gt;The tempting setup is "writes to primary, reads to replica" — send all SELECTs to the read endpoint and call it scaling. That's also the setup that produces the profile-refresh bug above, because &lt;em&gt;some reads must be strongly consistent&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The row you just wrote (read-your-own-write).&lt;/li&gt;
&lt;li&gt;Balances, inventory counts, payment/order status — anything where showing a stale value causes a real-world mistake.&lt;/li&gt;
&lt;li&gt;Anything another user just changed that the current user is actively acting on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pushing those through a replica "because it's a read" is how you get a customer who paid twice because the available-balance read lagged, or an admin who approves something they shouldn't because the status hadn't caught up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works (without throwing away the replica)
&lt;/h2&gt;

&lt;p&gt;You don't need synchronous replication (which trades throughput for freshness — often the opposite of why you added a replica). You need to be &lt;em&gt;selective&lt;/em&gt; about which reads need to be current.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read-after-write: pin the session to the primary right after a write.&lt;/strong&gt;&lt;br&gt;
The simplest version: after a mutating request, keep that user's subsequent reads on the primary for a short window (a few seconds). The user sees their own change immediately; everyone else tolerates the lag. Many connection pools support a "primary for this session" flag; in a Spring app you can route through a replication-aware datasource that flips to primary for the remainder of the request or a short TTL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Version / token handoff.&lt;/strong&gt;&lt;br&gt;
When you write, return a version or timestamp to the client. On the next read, the client sends it back; if the replica's applied position is behind that version, read from the primary instead. More precise than a fixed time window, and it survives the "user walked away and came back 20 minutes later" case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# after a write, tell the client how far the data has progressed
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-write-version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# next read: only escalate to primary if the replica hasn't caught up
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;replica_applied_version&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-write-version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="nf"&gt;read_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Route by data criticality, not by SQL verb.&lt;/strong&gt;&lt;br&gt;
Decide per query: is this read allowed to be slightly stale? Analytics dashboards, search indexes, public profiles, "last updated" lists — those are replica-friendly. Account balances, order state, anything transactional — primary. Encode that decision in your data-access layer, not in ad-hoc routing scattered across the codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Don't forget the cache has the same disease.&lt;/strong&gt;&lt;br&gt;
A cache is just a faster, more aggressive replica. "Set a TTL" is not an invalidation strategy; it's a confession that you'll serve stale data for the length of the TTL. For read-after-write correctness, prefer write-through (update the cache on write) or explicit invalidation on the write path over relying on expiry. The same read-your-own-write logic applies: after you write, make sure the next read doesn't hit a stale cache entry.&lt;/p&gt;
&lt;h2&gt;
  
  
  When the replica is exactly the right tool
&lt;/h2&gt;

&lt;p&gt;To be clear, replicas are great — for the right reads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analytical and reporting queries that would otherwise hammer the primary.&lt;/li&gt;
&lt;li&gt;Full-text search indexes.&lt;/li&gt;
&lt;li&gt;Public, non-personalized content.&lt;/li&gt;
&lt;li&gt;Any read where "a few seconds old" is fine, which is most reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake isn't using a replica. It's treating "replica" and "consistent" as the same word.&lt;/p&gt;
&lt;h2&gt;
  
  
  The metric you should be watching
&lt;/h2&gt;

&lt;p&gt;If you run a replica and you're not tracking replication lag, you're flying blind. Every engine exposes it: Postgres via &lt;code&gt;pg_stat_replication&lt;/code&gt; (or comparing &lt;code&gt;pg_last_wal_receive_lsn&lt;/code&gt; / &lt;code&gt;pg_last_wal_replay_lsn&lt;/code&gt;), MySQL via &lt;code&gt;Seconds_Behind_*&lt;/code&gt; / replica-lag metrics. Plot it, alert on the tail, and — crucially — keep it visible where a human decides whether a stale read just caused a problem. A p99 lag of eight seconds tells you exactly how far behind a confused user might be. Concretely, on Postgres you can see the gap directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- how far behind is each replica, in bytes of WAL not yet replayed&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;client_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;replay_lsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bytes_behind&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_replication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Adding a read replica feels like free scale. It's not free — it's a trade where you give up "every read sees the latest write" in exchange for "reads don't load the primary." That trade is almost always worth it. But you have to &lt;em&gt;make the trade consciously&lt;/em&gt;: enumerate the reads that must be current, route only those to the primary (or pin the session, or hand off a version token), and stop assuming &lt;code&gt;SELECT&lt;/code&gt; means &lt;code&gt;consistent&lt;/code&gt;. The replica solved your throughput problem. It quietly created a freshness problem you now own.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>performance</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Merkle Trees vs Hash Chains for Audit Logs: A Practical Decision Guide</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:21:55 +0000</pubDate>
      <link>https://dev.to/gentlyding/merkle-trees-vs-hash-chains-for-audit-logs-a-practical-decision-guide-361l</link>
      <guid>https://dev.to/gentlyding/merkle-trees-vs-hash-chains-for-audit-logs-a-practical-decision-guide-361l</guid>
      <description>&lt;p&gt;If you're building an audit log and want it tamper-evident, you'll hit the same fork I did: do I link records in a sequential hash chain, or build a Merkle tree? Both let a third party detect tampering, but they make different tradeoffs — and one of them will quietly fail your audit if you pick wrong.&lt;/p&gt;

&lt;p&gt;This isn't a "which is better" post. It's a decision guide based on how the log is actually written and who has to verify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two structures, in one paragraph each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hash chain (sequential).&lt;/strong&gt; Each record carries the hash of the previous record's contents. To verify, you walk from the oldest to the newest, recomputing each link. If any record's payload changed, its link breaks — and every link after it breaks too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record[0].hash = H(payload[0])
record[n].hash = H(payload[n] || record[n-1].hash)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Merkle tree.&lt;/strong&gt; Records are leaves; you hash pairs up to a single root. To verify one record, you don't replay the whole log — you present the root plus the small set of sibling hashes on the path from that leaf to the root (a "membership proof"). The root is the only thing you need to trust or anchor externally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;root = H( H(leaf0 || leaf1) || H(leaf2 || leaf3) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to decide
&lt;/h2&gt;

&lt;p&gt;Ask three questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Are records appended strictly in order, by one writer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Audit logs almost always are. Events happen in time; you append as they arrive. A sequential hash chain fits this shape like a glove — the "previous hash" pointer is just "the thing you wrote a moment ago," and there's no bookkeeping.&lt;/p&gt;

&lt;p&gt;A Merkle tree can do ordered appends too, but you pay a cost: every append can change the root, and if you want a stable root to anchor (say, hourly), you're really building a &lt;em&gt;series of&lt;/em&gt; Merkle trees — one per batch. That's a perfectly good design; it's just more machinery than a chain for the common case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Does a verifier ever need to prove one record without replaying everything?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where Merkle wins. Suppose a regulator wants to confirm a single transaction from 2023 without downloading your entire 40 GB log. With a hash chain, they must replay from the beginning (or from the last anchored checkpoint) to reach that record. With a Merkle tree, you hand them the root plus a ~log2(n) proof and they're done.&lt;/p&gt;

&lt;p&gt;If your verifiers are third parties who sample records rather than replay the whole trail, Merkle membership proofs are the difference between "here's a 2 KB proof" and "here's a 40 GB file."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Do you need to prove a record was &lt;em&gt;absent&lt;/em&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Merkle tree gives you a clean answer to "this record is not in the log" (a proof of non-membership, with the right tree design). A sequential hash chain cannot prove absence at all — it can only confirm what &lt;em&gt;is&lt;/em&gt; there. If your compliance requirement includes "demonstrate we never logged X," that's a Merkle property, not a chain property.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hash chain&lt;/th&gt;
&lt;th&gt;Merkle tree&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Append cost&lt;/td&gt;
&lt;td&gt;O(1) per record&lt;/td&gt;
&lt;td&gt;O(1) per leaf, but root changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify one record&lt;/td&gt;
&lt;td&gt;Replay from anchor (O(n))&lt;/td&gt;
&lt;td&gt;Membership proof (O(log n))&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prove absence&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (with design)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External anchor&lt;/td&gt;
&lt;td&gt;Anchor the head hash&lt;/td&gt;
&lt;td&gt;Anchor the root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mental model&lt;/td&gt;
&lt;td&gt;Trivial&lt;/td&gt;
&lt;td&gt;Needs care (ordering, balancing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;Single ordered writer, full-trail verification&lt;/td&gt;
&lt;td&gt;Third-party sampling, large logs, absence proofs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The hybrid I'd actually ship
&lt;/h2&gt;

&lt;p&gt;For most audit-log systems, you don't pick one — you use both, at different layers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;hash chain&lt;/strong&gt; is the write path: every appended record links to the previous, giving you append-only, tamper-evident history with zero extra machinery.&lt;/li&gt;
&lt;li&gt;Periodically (hourly, daily), take the current chain head and build a &lt;strong&gt;Merkle tree over the batch&lt;/strong&gt;, or simply anchor the head hash to an external trusted timestamp (RFC 3161). The anchored root is what a third party trusts; the chain is what they replay to drill into any record.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives you the chain's simplicity on the hot path and the Merkle tree's efficient sampling when a verifier shows up with a specific question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was wrong
&lt;/h2&gt;

&lt;p&gt;In an earlier post I waved this away with "a simple sequential chain beats a full Merkle tree." That's true &lt;em&gt;for the write path of a single ordered writer&lt;/em&gt;. It's wrong if your verifiers sample records, need absence proofs, or can't replay a 40 GB trail. The honest answer: the chain is the default, the tree is the upgrade when verification scale demands it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part neither structure solves
&lt;/h2&gt;

&lt;p&gt;A hash chain and a Merkle root both prove &lt;em&gt;integrity&lt;/em&gt; — that bytes weren't changed. Neither proves &lt;em&gt;when&lt;/em&gt; the bytes were written, and neither survives someone who controls the database rewriting both the record and its links. The fix for both is the same: anchor the head (or root) to an external trusted timestamp (RFC 3161) so "this existed by then" becomes independently provable, and keep the verification logic runnable by a party who isn't you.&lt;/p&gt;

&lt;p&gt;Build the chain. Anchor it. Let someone else replay it. That's the whole game.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>architecture</category>
      <category>computerscience</category>
      <category>security</category>
    </item>
    <item>
      <title>Event Sourcing Gives You an Event Store, Not an Audit Log</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:27:48 +0000</pubDate>
      <link>https://dev.to/gentlyding/event-sourcing-gives-you-an-event-store-not-an-audit-log-42k</link>
      <guid>https://dev.to/gentlyding/event-sourcing-gives-you-an-event-store-not-an-audit-log-42k</guid>
      <description>&lt;p&gt;A lot of teams reach for Event Sourcing and then treat the event store as their audit log. It's an easy mistake: both are append-only-ish streams of "what happened," so they &lt;em&gt;look&lt;/em&gt; like the same thing. They aren't, and the gap shows up exactly when an auditor asks you to prove it.&lt;/p&gt;

&lt;p&gt;This post is about where the two diverge, why an event store fails the audit test on its own, and the minimum you have to add so an event-sourced system can actually produce evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  They solve different problems
&lt;/h2&gt;

&lt;p&gt;Event Sourcing is an &lt;em&gt;application&lt;/em&gt; pattern. Its job is to let you rebuild domain state by replaying events, and to drive read models (projections) from those events. The primary reader of an event store is your own code.&lt;/p&gt;

&lt;p&gt;An audit log is an &lt;em&gt;evidence&lt;/em&gt; pattern. Its job is to let a third party — an auditor, a regulator, a forensic investigator, sometimes a hostile one — prove what happened, when, and that it hasn't been rewritten since. The primary reader of an audit log is someone who does not trust you.&lt;/p&gt;

&lt;p&gt;Those two readers pull the design in opposite directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap, side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Event store (ES)&lt;/th&gt;
&lt;th&gt;Audit log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;Rebuild state; drive projections&lt;/td&gt;
&lt;td&gt;Prove "who did what, when" to a third party&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reader&lt;/td&gt;
&lt;td&gt;Your own services&lt;/td&gt;
&lt;td&gt;An auditor who doesn't trust you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party verifiable?&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;td&gt;Must be yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tamper-evident?&lt;/td&gt;
&lt;td&gt;Not by default&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time source&lt;/td&gt;
&lt;td&gt;App-generated wall-clock&lt;/td&gt;
&lt;td&gt;External, anchored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survival of deletion&lt;/td&gt;
&lt;td&gt;Events get scavenged/compacted&lt;/td&gt;
&lt;td&gt;Records must persist for the retention period&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you've shipped Event Sourcing and someone asks "can you prove these events weren't edited?", the honest answer from a vanilla event store is usually "no."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things a raw event store is missing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Third-party verifiable integrity.&lt;/strong&gt; An event store typically just stores events. Nothing binds event &lt;em&gt;n&lt;/em&gt; to event &lt;em&gt;n-1&lt;/em&gt; in a way an outsider can check. "Our system says the history is intact" is not the same as "anyone can recompute a hash chain and see it matches." You need the chain, and you need to &lt;em&gt;expose&lt;/em&gt; it — the auditor has to be able to replay it themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Immutability that survives operations.&lt;/strong&gt; Event stores are not actually append-forever. EventStoreDB scavenges deleted streams; Kafka drops segments past the retention window; projections are regenerated from scratch whenever you change a handler. All of that is correct for Event Sourcing and fatal for an audit trail. If "compacting old history" is a routine ops task, you don't have an audit log — you have a cache of recent state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An external time anchor.&lt;/strong&gt; Event timestamps in an event store are written by your application, from its own clock. Whoever controls that server can backdate an event or rewrite one and the timestamp will happily agree. A real audit log anchors each batch of records to a trusted timestamp authority (RFC 3161) so "when this was written" stops being something you can quietly edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridging the gap without throwing ES away
&lt;/h2&gt;

&lt;p&gt;You don't have to abandon Event Sourcing. You add a verifiable audit trail &lt;em&gt;alongside&lt;/em&gt; it, fed by the same events:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When an event is appended, compute a &lt;strong&gt;content hash chain&lt;/strong&gt; over the event — each link hashes its own canonical payload plus the previous link's hash.&lt;/li&gt;
&lt;li&gt;Periodically anchor the chain head to an RFC 3161 timestamp authority, so the chain has an external "this existed by then" proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expose a verification report&lt;/strong&gt; — anyone can replay the chain, recompute hashes, and check the timestamps. Proof, not assertion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The crucial part: the audit source is the &lt;em&gt;immutable event stream + chain + anchors&lt;/em&gt;, not the projection. Read models are derived; they can lag, rebuild, or disagree, and none of that should be able to silently rewrite history.&lt;/p&gt;

&lt;p&gt;A minimal append-side snippet (hash chain over a &lt;em&gt;canonical&lt;/em&gt; event payload, not a database row):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.charset.StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.MessageDigest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditAppender&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;headHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// genesis link is empty&lt;/span&gt;

    &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;canonicalEventJson&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canonicalEventJson&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reset&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headHash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// bind this link to the previous one&lt;/span&gt;
        &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;headHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// advance the chain head&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canonicalEventJson&lt;/code&gt; is the point: it must be a &lt;em&gt;stable&lt;/em&gt; serialization of the event — fixed key order, no insignificant whitespace, numbers in a fixed format. Hash the raw object's &lt;code&gt;toString()&lt;/code&gt; and two semantically identical events will produce two different hashes, which defeats the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't treat a projection as evidence.&lt;/strong&gt; A read model is regenerated whenever a handler changes. "The audit says the balance was X" cannot come from a thing you rebuild on a whim. The evidence is the chained event stream; the projection is just a view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonicalize before you hash.&lt;/strong&gt; JSON key ordering, trailing whitespace, and &lt;code&gt;1.0&lt;/code&gt; vs &lt;code&gt;1.00&lt;/code&gt; all change a hash. Define one canonical form for your event payload and hash &lt;em&gt;that&lt;/em&gt;, or your verification step will flag false tampering on perfectly honest events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Event Sourcing gives you a great way to rebuild state and drive projections. It does not, by itself, give you an audit log — because an audit log's whole job is to be &lt;em&gt;provable to someone who doesn't trust you&lt;/em&gt;, and a vanilla event store can't do that. Add a content hash chain, anchor it to external time, and expose verification. Then your event store and your audit trail coexist, and the audit trail actually holds up.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Stop Trusting the App: Enforcing Append-Only at the Database Layer</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:52:50 +0000</pubDate>
      <link>https://dev.to/gentlyding/stop-trusting-the-app-enforcing-append-only-at-the-database-layer-1bkm</link>
      <guid>https://dev.to/gentlyding/stop-trusting-the-app-enforcing-append-only-at-the-database-layer-1bkm</guid>
      <description>&lt;p&gt;Most "audit logs" are append-only by convention, not by enforcement. The application inserts a row and politely promises never to &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; it. That promise holds right up until the moment someone does one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bug in the app calls &lt;code&gt;repo.delete(id)&lt;/code&gt; on the wrong entity.&lt;/li&gt;
&lt;li&gt;An operator connects with &lt;code&gt;psql&lt;/code&gt; and runs a cleanup script against the production table.&lt;/li&gt;
&lt;li&gt;A compromised dependency exfiltrates the credentials and rewrites history to cover its tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a third party is expected to trust your audit trail, "we don't update it" is not a guarantee — it's a hope. The constraint has to live below the application, in the database itself. Here are the patterns that actually work, and where each one still leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1 — An INSERT-only role
&lt;/h2&gt;

&lt;p&gt;The cheapest real guarantee: the application connects with a database role that &lt;em&gt;physically cannot&lt;/em&gt; modify what it already wrote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- a role the app uses at runtime&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_audit&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- the audit table is owned by a separate, higher-privileged role&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;        &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;   &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="n"&gt;BYTEA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cur_hash&lt;/span&gt;  &lt;span class="n"&gt;BYTEA&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;written_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- grant ONLY insert (and select, if the app reads its own log)&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_audit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- explicitly: no UPDATE, no DELETE, no TRUNCATE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now even a code bug that calls &lt;code&gt;delete&lt;/code&gt; resolves to a permission error at the driver, not a silent row removal. This is the single highest-leverage change and almost nobody does it.&lt;/p&gt;

&lt;p&gt;The leak: a superuser or the table owner can still mutate rows. So this stops accidents and low-privilege compromises, not a DBA with full access — which is exactly why you still want a hash chain underneath (see the "why this isn't enough" note at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2 — Triggers as a backstop
&lt;/h2&gt;

&lt;p&gt;Triggers catch mutations made through &lt;em&gt;any&lt;/em&gt; connection, including the owner, unless the session disables them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_audit_mutate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'audit_events is append-only; mutations are forbidden'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;audit_no_update&lt;/span&gt;
  &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;STATEMENT&lt;/span&gt;
  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;reject_audit_mutate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leak: &lt;code&gt;ALTER TABLE ... DISABLE TRIGGER&lt;/code&gt; is available to the table owner and superusers, and &lt;code&gt;session_replication_role = replica&lt;/code&gt; skips triggers entirely in Postgres. Treat triggers as defense-in-depth, not the perimeter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3 — A foreign key that points backward
&lt;/h2&gt;

&lt;p&gt;Make each row reference the &lt;em&gt;previous&lt;/em&gt; row's hash, and put a &lt;code&gt;FOREIGN KEY&lt;/code&gt; on it. Deleting any row except the newest now fails the constraint on the row that follows it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_prev&lt;/span&gt;
  &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur_hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leak: the newest row has no follower, so deleting the tail still works. And healing the chain after a legitimate bulk operation is painful. Useful as one layer, not sufficient alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4 — Tombstones instead of DELETE
&lt;/h2&gt;

&lt;p&gt;The cleanest way to honor "right to erasure" laws without making the log mutable: you never delete. You &lt;em&gt;append&lt;/em&gt; a redaction event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'{"type":"redaction","target_id": 42,"reason":"GDPR Art.17 request"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cur_hash&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="c1"&gt;-- hash of (payload || prev_hash)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original row stays intact and verifiable; the log now &lt;em&gt;records&lt;/em&gt; that it was asked to forget. An auditor sees both the event and the request to erase it. This is the only pattern that satisfies "we must be able to delete" and "the log must stay tamper-evident" at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 5 — WORM at the storage layer
&lt;/h2&gt;

&lt;p&gt;Push the table (or a time partition of it) onto write-once storage: a read-only tablespace, an object-lock bucket, or a partitioned scheme where last month's partition is &lt;code&gt;ALTER TABLE ... SET (read_only = true)&lt;/code&gt; / moved to immutable media.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- freeze a partition&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_events_2026_08&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leak: it's coarse-grained (you freeze whole periods, not individual rows) and it's enforced by the platform, so it shares the same trust boundary as the platform admin. Good for the "we can prove we didn't touch last quarter" claim, weaker for fine-grained guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The combination that holds up
&lt;/h2&gt;

&lt;p&gt;In practice you want layers, not a single trick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;App connects as an &lt;strong&gt;INSERT-only role&lt;/strong&gt; (Pattern 1) — stops the common bug and the low-priv compromise.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;trigger&lt;/strong&gt; (Pattern 2) catches the rest at the SQL level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tombstones&lt;/strong&gt; (Pattern 4) replace every &lt;code&gt;DELETE&lt;/code&gt; so erasure requests are logged, not destructive.&lt;/li&gt;
&lt;li&gt;Period partitions go &lt;strong&gt;read-only&lt;/strong&gt; (Pattern 5) for the long tail.&lt;/li&gt;
&lt;li&gt;And underneath all of it, a &lt;strong&gt;hash chain&lt;/strong&gt; (each row hashes its payload plus the previous row's hash) plus an &lt;strong&gt;external trusted timestamp&lt;/strong&gt;, so that even a database superuser who &lt;em&gt;can&lt;/em&gt; mutate a row cannot do it without breaking verification — and cannot convincingly fake &lt;em&gt;when&lt;/em&gt; it happened.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is the actual perimeter. Database constraints protect you from mistakes and from attackers who lack DB privileges. They do not protect you from someone who controls the database. For that threat, the chain + timestamp has to be verifiable by a party who doesn't trust your infrastructure at all — which is a separate post, but it's the reason none of the patterns above are sufficient on their own.&lt;/p&gt;

&lt;p&gt;The takeaway: append-only is a property you enforce in three places — the role, the table, and the math. Skip any one and "append-only" is just a comment in your codebase that nobody obeys.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>Trusted Timestamps (RFC 3161): How to Anchor a Hash Chain So No One Can Rewrite the Past</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:08:13 +0000</pubDate>
      <link>https://dev.to/gentlyding/trusted-timestamps-rfc-3161-how-to-anchor-a-hash-chain-so-no-one-can-rewrite-the-past-8ce</link>
      <guid>https://dev.to/gentlyding/trusted-timestamps-rfc-3161-how-to-anchor-a-hash-chain-so-no-one-can-rewrite-the-past-8ce</guid>
      <description>&lt;p&gt;A hash chain proves a record was not modified after it was written. It does &lt;em&gt;not&lt;/em&gt; prove &lt;em&gt;when&lt;/em&gt; it was written, and it does nothing against someone who controls the server and can re-sign the whole chain. RFC 3161 trusted timestamps close that gap. This is a practical look at what they are, how to wire them in, and where the footguns are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap a hash chain leaves open
&lt;/h2&gt;

&lt;p&gt;If you hash-chain your records — &lt;code&gt;hash[n] = H(payload[n] || hash[n-1])&lt;/code&gt; — anyone can later walk the chain and prove no entry was altered. Tamper with &lt;code&gt;payload[2]&lt;/code&gt; and every subsequent link breaks. That property is real and useful.&lt;/p&gt;

&lt;p&gt;But it assumes one thing the chain itself cannot guarantee: &lt;strong&gt;trust in &lt;em&gt;when&lt;/em&gt; the signing happened.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can't prove &lt;em&gt;when&lt;/em&gt; a record was created. The whole chain could have been generated yesterday and backfilled.&lt;/li&gt;
&lt;li&gt;It can't stop a server operator with the signing key from regenerating the entire chain from scratch, picking whatever &lt;code&gt;payload&lt;/code&gt; they like, and presenting it as the original.&lt;/li&gt;
&lt;li&gt;It can't defend against "we lost the logs for March, so we rebuilt them" — a story auditors hear more often than they should.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A hash chain is integrity. It is not &lt;em&gt;time&lt;/em&gt; and it is not &lt;em&gt;non-repudiation&lt;/em&gt;. Those need an anchor outside your control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RFC 3161 actually gives you
&lt;/h2&gt;

&lt;p&gt;RFC 3161 defines a protocol for a &lt;strong&gt;Time Stamp Authority (TSA)&lt;/strong&gt;: a third party you trust to vouch for "this exact hash existed at this exact time."&lt;/p&gt;

&lt;p&gt;The flow is small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You compute the hash of whatever you want to anchor (one record, or the head hash of your whole chain).&lt;/li&gt;
&lt;li&gt;You send that hash to the TSA — &lt;strong&gt;not&lt;/strong&gt; the data, just the digest. The TSA never sees your content.&lt;/li&gt;
&lt;li&gt;The TSA returns a &lt;strong&gt;Time Stamp Token&lt;/strong&gt;: a CMS signature (PKCS#7) that binds your hash to a timestamp, signed with the TSA's private key.&lt;/li&gt;
&lt;li&gt;You store the token next to the record (or the chain head).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The token says, in effect: &lt;em&gt;"A hash equal to this one was presented to me at 2026-09-10T14:03:11Z, signed, TSA: Acme-Time."&lt;/em&gt; Because it's signed by the TSA, you can't forge it, and because the hash is inside the signed blob, you can't swap the content after the fact.&lt;/p&gt;

&lt;p&gt;Two consequences worth stating plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The TSA attests to the &lt;strong&gt;hash&lt;/strong&gt;, not the data. You still store the data yourself; the token only proves the data's digest existed at a time.&lt;/li&gt;
&lt;li&gt;The token proves &lt;em&gt;external, third-party&lt;/em&gt; time. Even if an attacker owns your server and your signing keys, they can't produce a valid token for a hash dated before they took over — unless they also compromised the TSA, which is the whole point of outsourcing that trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two anchoring strategies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-record stamping.&lt;/strong&gt; Stamp every record as it arrives. Maximum granularity, maximum TSA calls. Fine for low-volume, high-value events (a compliance audit log, a signing certificate log). You store &lt;code&gt;token[n]&lt;/code&gt; alongside &lt;code&gt;record[n]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Periodic chain-head stamping.&lt;/strong&gt; At a fixed interval (every minute, every flush), take the current head hash of the chain and stamp &lt;em&gt;that&lt;/em&gt;. One token anchors everything written since the previous token. This is usually the right trade-off: a single TSA call amortizes over thousands of records, and it still proves the entire chain up to that head existed at that time.&lt;/p&gt;

&lt;p&gt;For most systems I'd reach for periodic head-stamping, with per-record stamping reserved for the few events where "exactly when" is itself the evidence (a key rotation, a privilege grant).&lt;/p&gt;

&lt;h2&gt;
  
  
  What a request looks like
&lt;/h2&gt;

&lt;p&gt;Using Bouncy Castle (Java), a minimal stamp request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.tsp.TimeStampRequestGenerator&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.tsp.TimeStampRequest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.tsp.TimeStampResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.tsp.TimeStampToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.cms.CMSSignedData&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.io.OutputStream&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.net.HttpURLConnection&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.net.URL&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.MessageDigest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Base64&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;MessageDigest&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the record's hash, or your chain head&lt;/span&gt;

&lt;span class="nc"&gt;TimeStampRequestGenerator&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TimeStampRequestGenerator&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCertReq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ask the TSA to include its cert in the response&lt;/span&gt;
&lt;span class="nc"&gt;TimeStampRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bouncycastle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tsp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TSPAlgorithms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SHA256&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;der&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEncoded&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// DER-encoded ASN.1 request&lt;/span&gt;

&lt;span class="no"&gt;URL&lt;/span&gt; &lt;span class="n"&gt;tsu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://tsa.example.com/tsa"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;HttpURLConnection&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpURLConnection&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;tsu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;openConnection&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRequestMethod&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRequestProperty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/timestamp-query"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDoOutput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutputStream&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOutputStream&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;der&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;respDer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInputStream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;readAllBytes&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;TimeStampResponse&lt;/span&gt; &lt;span class="n"&gt;tsr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TimeStampResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;respDer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;tsr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// throws if the response doesn't match your request&lt;/span&gt;

&lt;span class="nc"&gt;TimeStampToken&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tsr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTimeStampToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;tokenDer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEncoded&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;           &lt;span class="c1"&gt;// store this&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tokenB64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEncoder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;encodeToString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenDer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you persist is &lt;code&gt;tokenB64&lt;/code&gt; (or the raw DER). The TSA URL, the digest algorithm, and the request nonce are configuration, not secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;Verification is the part auditors actually run, so it must be boring and offline-capable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tokenDer = the stored DER; digest = the hash you recompute from the data&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.MessageDigest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.cert.X509Certificate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Collections&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Date&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.cert.X509CertificateHolder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.cert.jcajce.JcaCertStore&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.cms.CMSSignedData&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.tsp.TimeStampToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.bouncycastle.util.Store&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Validate against YOUR pinned TSA root, not the certs bundled in the&lt;/span&gt;
&lt;span class="c1"&gt;// token. Trusting the embedded certs would let a rogue TSA vouch for itself.&lt;/span&gt;
&lt;span class="nc"&gt;X509Certificate&lt;/span&gt; &lt;span class="n"&gt;tsaRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loadTrustedCert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tsa-root.pem"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// pinned, from config&lt;/span&gt;
&lt;span class="nc"&gt;Store&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;X509CertificateHolder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;trust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JcaCertStore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Collections&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;singletonList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tsaRoot&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;TimeStampToken&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TimeStampToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CMSSignedData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenDer&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 1) the token's embedded hash must equal the data's current hash&lt;/span&gt;
&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;signedDigest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTimeStampInfo&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getMessageImprintDigest&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEqual&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signedDigest&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hash mismatch - data was altered"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2) the TSA's signature must validate against your trusted store&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// throws on bad signature or untrusted issuer&lt;/span&gt;

&lt;span class="c1"&gt;// 3) the time must be within the window you expect&lt;/span&gt;
&lt;span class="nc"&gt;Date&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTimeStampInfo&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getGenTime&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// compare t against the record's claimed time / policy window&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks, in order: the imprint matches your data, the TSA signature is valid against a cert you trust, and the time falls where policy says it should. If all three pass, you have third-party proof the data existed at &lt;code&gt;t&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The footguns
&lt;/h2&gt;

&lt;p&gt;This is where most implementations quietly break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TSA availability is now your dependency.&lt;/strong&gt; If the TSA is down, you can't anchor. Run a periodic head-stamp on a schedule with retries and backoff; don't block your write path on the TSA. Stamp asynchronously, store the pending state, reconcile when the TSA comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network egress may be restricted.&lt;/strong&gt; Many compliance environments block outbound traffic by default. The TSA call is outbound HTTPS to a specific host — it needs an explicit allowlist entry, or your anchoring silently stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust the cert, not the URL.&lt;/strong&gt; Validate the token against a TSA certificate you pinned (or a known root), not against the domain you happened to call, and never against the certs returned inside the token. A MITM on the TSA endpoint who returns a self-signed token should fail step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token lifetime outlives the TSA's certificate.&lt;/strong&gt; TSA signing certs expire (typically 1–10 years). A token signed in 2026 with a cert valid to 2030 is fine &lt;em&gt;today&lt;/em&gt;, but in 2035 the cert is expired and naive signature checks may reject it. For long-term evidence you need either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a long-lived, qualified TSA whose root you trust indefinitely, or&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LTANS / evidence-record renewal&lt;/strong&gt;: periodically re-anchor an older token inside a newer one, building a verifiable chain of timestamps so the evidence stays provable after any single cert expires. RFC 4998 (ERS) covers this formally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip this and your "proof it existed in 2026" becomes unverifiable in 2035. For audit evidence with a multi-year retention requirement, this is not optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't stamp the wrong thing.&lt;/strong&gt; Stamping &lt;code&gt;payload&lt;/code&gt; before you've computed the chain hash, or stamping a mutable field, anchors the wrong bytes. Stamp the &lt;em&gt;immutable digest&lt;/em&gt; — the chain head, or the record's canonical hash — never a re-serialized object that might differ byte-for-byte on reload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free vs paid TSAs.&lt;/strong&gt; Public free TSAs exist (some CAs offer one) but have rate limits and no SLA; for production evidence use a commercial or qualified TSA. The cost is per-stamp and usually trivial, but it is a real external bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;After anchoring, tampering requires compromising &lt;em&gt;both&lt;/em&gt; your server &lt;em&gt;and&lt;/em&gt; the TSA — and if you use a qualified TSA, that's a regulated entity with its own audit trail. The chain gives you integrity; the timestamp gives you &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;non-repudiation by an outside party&lt;/em&gt;. Together they answer the three questions an auditor actually asks: was it changed, when was it written, and can you prove that without trusting the person who wrote it.&lt;/p&gt;

&lt;p&gt;The hash chain is the easy part. The timestamp is what makes it hold up in front of someone who doesn't trust you.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>security</category>
    </item>
    <item>
      <title>How We Made Audit Logs Immutable at the Storage Layer</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:22:14 +0000</pubDate>
      <link>https://dev.to/gentlyding/how-we-made-audit-logs-immutable-at-the-storage-layer-5cp7</link>
      <guid>https://dev.to/gentlyding/how-we-made-audit-logs-immutable-at-the-storage-layer-5cp7</guid>
      <description>&lt;p&gt;Everyone talks about the hash chain. It's the sexy part — a tamper-evident linked list of records an auditor can verify independently. But a hash chain only proves the &lt;em&gt;bytes&lt;/em&gt; weren't changed. It does nothing if the storage layer underneath lets you rewrite history more quietly: shift a timestamp, change a value and re-anchor the chain, or have the whole table silently drift when a server's timezone changes.&lt;/p&gt;

&lt;p&gt;Immutability is won or lost at the storage layer. Here's the concrete design we landed on for a self-hosted audit log system (PostgreSQL + Spring Boot + JPA).&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Store absolute instants, never wall-clock strings
&lt;/h2&gt;

&lt;p&gt;The single most important rule: a timestamp in an audit record must be an &lt;em&gt;absolute moment&lt;/em&gt;, not a wall-clock label.&lt;/p&gt;

&lt;p&gt;In Java that means &lt;code&gt;java.time.Instant&lt;/code&gt; — a point on the timeline with no zone attached. In PostgreSQL it means &lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt; (aka &lt;code&gt;timestamptz&lt;/code&gt;), which stores the moment as UTC under the hood.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"audit_log"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NormalizedLog&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;eventTime&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// absolute moment, zone-free&lt;/span&gt;

    &lt;span class="c1"&gt;// Canonical ISO string kept *purely* for hashing (see §3)&lt;/span&gt;
    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"event_time_iso"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventTimeIso&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...prevHash / curHash, actor, action, etc.&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap we deliberately avoid: storing &lt;code&gt;LocalDateTime&lt;/code&gt;. A &lt;code&gt;LocalDateTime&lt;/code&gt; like &lt;code&gt;2024-03-15 09:30:00&lt;/code&gt; carries no zone. The moment it hits the database, its true meaning depends on whatever timezone the connection or JVM happens to be in. Move the server, change a config, restore from a backup taken elsewhere — and every "when" in your audit trail is now ambiguous or simply wrong. For a compliance log, that's fatal.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. UTC at rest, local only at read time
&lt;/h2&gt;

&lt;p&gt;We store everything as UTC. The raw database shows UTC values, and that is &lt;em&gt;expected&lt;/em&gt;, not a bug. The display/analysis timezone is a runtime setting (kept in a config table, editable from the UI, applied live) — it is never nailed to the JVM.&lt;/p&gt;

&lt;p&gt;That last point matters. We explicitly do &lt;strong&gt;not&lt;/strong&gt; do any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-Duser.timezone=...&lt;/code&gt; on the JVM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TimeZone.setDefault(...)&lt;/code&gt; in code&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hibernate.jdbc.time_zone&lt;/code&gt; (a no-op for &lt;code&gt;Instant&lt;/code&gt; anyway)&lt;/li&gt;
&lt;li&gt;a Hikari &lt;code&gt;connection-init-sql&lt;/code&gt; that SETs a session timezone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why? Because those don't just &lt;em&gt;display&lt;/em&gt; in a zone — they can mutate what gets written. The storage baseline must stay UTC, unambiguously.&lt;/p&gt;

&lt;p&gt;A real bug this prevented: "today's events" and daily-trend buckets. If you bucket by the UTC date, an event at 07:30 Asia/Shanghai (which is 23:30 UTC the &lt;em&gt;previous&lt;/em&gt; day) lands on the wrong day. So the day-bucketing query does the conversion in the database, per the user's configured zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM-DD'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And "since when" counts compare the &lt;code&gt;timestamptz&lt;/code&gt; column directly (hitting the &lt;code&gt;ix_al_event_time&lt;/code&gt; index) instead of string-slicing the ISO column — which would reintroduce the UTC/local mismatch.&lt;/p&gt;

&lt;p&gt;![Time storage: UTC at rest, local at read]&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fno5vtnez08ux895ipd8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fno5vtnez08ux895ipd8l.png" alt=" " width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep a canonical string — but only for the hash
&lt;/h2&gt;

&lt;p&gt;One subtlety: &lt;code&gt;Instant&lt;/code&gt; has nanosecond precision, and a DB round-trip can truncate or reformat it. If you hash the &lt;code&gt;Instant&lt;/code&gt; object directly, the hash can change between writes and reads even when nothing was tampered with. So each record also stores a canonical ISO-8601 string (&lt;code&gt;eventTimeIso&lt;/code&gt;) that the hash chain is computed over. The string is stable; the chain stays verifiable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TimeUtil: lenient ISO-8601 parse → Instant (UTC)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OffsetDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LocalDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZoneOffset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ignored&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last branch: an input with no offset is treated as UTC. That's an explicit ingest convention — never infer a source's local zone, or you'll bake ambiguity into the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Erasure vs. immutability: keep the hash, redact the PII
&lt;/h2&gt;

&lt;p&gt;"Immutable" doesn't mean "keep every byte forever." Under GDPR's right-to-erasure you must be able to purge a person's PII. But if you delete or rewrite the record, the hash chain breaks and you lose tamper-evidence for &lt;em&gt;everyone&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Our compromise: redact the PII fields in place (actor, IP, raw message, geo tags) but &lt;strong&gt;leave &lt;code&gt;curHash&lt;/code&gt; untouched&lt;/strong&gt;, and flip an &lt;code&gt;erased&lt;/code&gt; flag. The chain (&lt;code&gt;prevHash → curHash&lt;/code&gt;) stays mathematically intact and still verifies; verification simply skips erased records.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update NormalizedLog l set l.erased = true, l.erasedAt = :now, l.erasedBy = :by, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"l.sourceIp = :mask, l.actor = :mask, l.rawMessage = :mask, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"l.beforeValue = :mask, l.afterValue = :mask, "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"l.geoCountry = null, l.geoRegion = null, l.geoCity = null, l.geoIsp = null, l.geoScope = null "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"where l.erased = false and l.actor = :actor"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;eraseByActor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"actor"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                 &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"by"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mask"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So erasure is itself an audited, non-destructive event — which is exactly what a regulator wants to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The hash chain is the glue
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prevHash&lt;/code&gt; and &lt;code&gt;curHash&lt;/code&gt; make the whole table an append-only, verifiable chain (the subject of &lt;a href="https://dev.to/gentlyding/how-we-built-a-tamper-evident-audit-log-for-soc-2-and-iso-27001-evidence-jl4"&gt;my previous post&lt;/a&gt; on building a tamper-evident audit log). Storage immutability is what makes that chain trustworthy: fixed UTC instants, stable canonical strings, and erasure that preserves hashes instead of rewriting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Audit timestamps = &lt;code&gt;Instant&lt;/code&gt; + &lt;code&gt;timestamptz&lt;/code&gt;. Never &lt;code&gt;LocalDateTime&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Store UTC. Convert to the user's zone only when reading / displaying / bucketing.&lt;/li&gt;
&lt;li&gt;Don't nail the JVM's timezone — it mutates writes, not just reads.&lt;/li&gt;
&lt;li&gt;Keep a canonical string for hashing; hash the &lt;em&gt;string&lt;/em&gt;, not the &lt;code&gt;Instant&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Erasure and immutability aren't opposites: redact PII, keep the hash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building a self-hosted audit log and want the verifiable-chain side of this, I wrote about the hash-chain design here: &lt;a href="https://dev.to/gentlyding/how-we-built-a-tamper-evident-audit-log-for-soc-2-and-iso-27001-evidence-jl4"&gt;https://dev.to/gentlyding/how-we-built-a-tamper-evident-audit-log-for-soc-2-and-iso-27001-evidence-jl4&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>How We Built a Tamper-Evident Audit Log for SOC 2 and ISO 27001 Evidence</title>
      <dc:creator>gentlyding</dc:creator>
      <pubDate>Sat, 05 Sep 2026 01:21:43 +0000</pubDate>
      <link>https://dev.to/gentlyding/how-we-built-a-tamper-evident-audit-log-for-soc-2-and-iso-27001-evidence-jl4</link>
      <guid>https://dev.to/gentlyding/how-we-built-a-tamper-evident-audit-log-for-soc-2-and-iso-27001-evidence-jl4</guid>
      <description>&lt;p&gt;Most teams treat "audit logging" as an afterthought: pipe everything into a SIEM or a big Elastic cluster, then hope the auditor is satisfied. In practice, that's where the pain starts.&lt;/p&gt;

&lt;p&gt;I'm Qin Kang, and I built &lt;strong&gt;Log Audit Platform&lt;/strong&gt; after watching a client burn roughly six engineering-months duct-taping Splunk + spreadsheets together for an ISO 27001 audit. The result was expensive, fragile, and the auditor still asked the one question that sinks most log pipelines:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How do I know these logs weren't edited after the fact?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post walks through how we designed an audit log that an auditor can actually trust — without sending your data to a third party.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Why auditors don't trust raw logs
&lt;/h2&gt;

&lt;p&gt;A raw log file is just text. Even if it's shipped to a "secure" bucket, nothing cryptographically ties one line to the next. An attacker (or a well-meaning operator) who gains write access can quietly rewrite history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Change a &lt;code&gt;DELETE&lt;/code&gt; into a &lt;code&gt;READ&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Backdate an event.&lt;/li&gt;
&lt;li&gt;Remove the record of a privilege escalation entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When an auditor asks "can you prove this wasn't altered?", a raw log answers with &lt;em&gt;trust me&lt;/em&gt;. That's not good enough for SOC 2 (CC7.2 / CC8.1) or ISO 27001 (A.8.15 / A.8.16).&lt;/p&gt;

&lt;p&gt;The fix isn't "more storage". It's &lt;strong&gt;integrity by construction&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. What SOC 2 and ISO 27001 actually want from logging
&lt;/h2&gt;

&lt;p&gt;Stripped of jargon, the control families want three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Completeness&lt;/strong&gt; — you captured the events that matter (auth, admin actions, config changes, data access).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt; — a record, once written, can't be silently changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability for review&lt;/strong&gt; — an auditor (or your own security team) can independently verify both of the above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice the word &lt;em&gt;independently&lt;/em&gt;. SOC 2 and ISO 27001 auditors don't just take your word for it; they want evidence they can re-run. That's the design goal we optimized for.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Hash-chain design: why a sequential hash works for audit logs
&lt;/h2&gt;

&lt;p&gt;Instead of storing events as isolated rows, every record carries the hash of the &lt;strong&gt;previous&lt;/strong&gt; record's hash chain. Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record[0].hash = H(payload[0])
record[n].hash = H(payload[n] || record[n-1].hash)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ksmh7q2svxc4s3yni8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ksmh7q2svxc4s3yni8x.png" alt=" " width="799" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;If an attacker tampers one record, its hash changes and every subsequent link fails verification — the auditor sees exactly where the chain breaks.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Verification walks the chain from the oldest record to the newest. If any payload was altered — even a single character — the recomputed hash at that link no longer matches the stored hash, and every subsequent link breaks too. The verification report marks exactly where the chain was broken.&lt;/p&gt;

&lt;p&gt;Why a simple sequential chain rather than a full Merkle tree? For an audit log, records are append-only and verified in order, so a linear chain is simpler to verify, easier to explain to an auditor, and has no reconstruction complexity. (We're looking at optional RFC 3161 timestamp anchoring as a future external-WORM option, but the in-chain integrity is the core.)&lt;/p&gt;

&lt;p&gt;The practical takeaway: &lt;strong&gt;editing one record is mathematically impossible to hide.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. GDPR right-to-erasure without breaking the chain
&lt;/h2&gt;

&lt;p&gt;GDPR's right to erasure (Art. 17) collides with an immutable log: you can't just &lt;code&gt;DELETE FROM audit_log&lt;/code&gt; a user's rows, because that breaks the chain and the audit trail.&lt;/p&gt;

&lt;p&gt;Our approach is &lt;strong&gt;cryptographic erasure / anonymization&lt;/strong&gt;, not physical deletion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Personal data is stored in a separate, keyed store, referenced by token from the audit record.&lt;/li&gt;
&lt;li&gt;On a valid erasure request, we destroy the key material for that subject. The audit record remains (required for the integrity trail), but the linked identity becomes unrecoverable ciphertext.&lt;/li&gt;
&lt;li&gt;The hash chain stays intact, because we erase &lt;em&gt;the key&lt;/em&gt;, not &lt;em&gt;the log&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This satisfies both sides: the regulator gets erasure; the auditor keeps a verifiable timeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Evidence pack automation: turning logs into auditor-ready ZIPs
&lt;/h2&gt;

&lt;p&gt;The second thing auditors hate is &lt;em&gt;hunting&lt;/em&gt;. They don't want your raw database; they want the control mapped to the evidence.&lt;/p&gt;

&lt;p&gt;So we ship &lt;strong&gt;evidence packs&lt;/strong&gt;: pre-built, exportable bundles that map collected events to specific control IDs (e.g. SOC 2 AC-2, ISO 27001 A.8.16). One click produces a ZIP containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The relevant filtered records.&lt;/li&gt;
&lt;li&gt;A hash-chain verification report (proving the bundle itself is intact).&lt;/li&gt;
&lt;li&gt;A control-to-evidence mapping sheet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The auditor runs the verification themselves. No spreadsheet gymnastics, no "trust me".&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Self-hosted deployment: one JAR
&lt;/h2&gt;

&lt;p&gt;Compliance data shouldn't leave your perimeter. Log Audit Platform runs &lt;strong&gt;self-hosted&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: Spring Boot 3 + Java 17+&lt;/li&gt;
&lt;li&gt;Frontend: Vue 3 admin UI, embedded in the build&lt;/li&gt;
&lt;li&gt;Storage: PostgreSQL&lt;/li&gt;
&lt;li&gt;Shipping: a single runnable JAR + Docker Compose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No vendor backend, no telemetry pipe, no API calls home after activation. It runs in your VPC, on-prem, or air-gapped. For teams that can't use a cloud SIEM for regulatory reasons, that's the whole point.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Pricing: one-time license, not a per-seat SaaS tax
&lt;/h2&gt;

&lt;p&gt;SIEM pricing scales with ingest volume and seats — exactly the metrics that go up when you start taking compliance seriously. We priced it as a &lt;strong&gt;one-time license&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single — $199 (one instance)&lt;/li&gt;
&lt;li&gt;Business — $699 (up to 5)&lt;/li&gt;
&lt;li&gt;Enterprise — $2,499 (full source + white-label + OEM)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional annual maintenance for updates. No per-seat tax, no surprise overage bills right before your audit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you're preparing for a SOC 2, ISO 27001, or GDPR audit and want an audit log that auditors can verify independently, take a look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://logaudit.toolsder.com" rel="noopener noreferrer"&gt;https://logaudit.toolsder.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely like feedback from security engineers and compliance folks — what logging controls have been the biggest pain in your audits? What would make you trust a self-hosted log like this one?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Qin Kang — independent developer, building self-hosted compliance tooling.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>security</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
