<?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: Shailesh Hawale</title>
    <description>The latest articles on DEV Community by Shailesh Hawale (@hawaleshailesh).</description>
    <link>https://dev.to/hawaleshailesh</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%2F4008582%2Fd8fca283-30b7-474a-b9c2-b9ed7e4a53ca.jpg</url>
      <title>DEV Community: Shailesh Hawale</title>
      <link>https://dev.to/hawaleshailesh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hawaleshailesh"/>
    <language>en</language>
    <item>
      <title>I built an audit log that catches its own tampering - and proves it at scale, offline</title>
      <dc:creator>Shailesh Hawale</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:05:09 +0000</pubDate>
      <link>https://dev.to/hawaleshailesh/i-built-an-audit-log-that-catches-its-own-tampering-and-proves-it-at-scale-offline-2d5c</link>
      <guid>https://dev.to/hawaleshailesh/i-built-an-audit-log-that-catches-its-own-tampering-and-proves-it-at-scale-offline-2d5c</guid>
      <description>&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%2Fqtmmaiecvdc650eusim7.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%2Fqtmmaiecvdc650eusim7.png" alt=" " width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built this project for the &lt;strong&gt;#H0Hackathon&lt;/strong&gt; (Hack the Zero Stack with Vercel and AWS Databases). This post covers how I built it using Amazon DynamoDB and Vercel.&lt;/p&gt;

&lt;p&gt;There's a quiet lie at the center of a lot of compliance software.&lt;/p&gt;

&lt;p&gt;Almost every regulated SaaS company says it keeps an &lt;em&gt;immutable&lt;/em&gt; audit log. It's in their SOC 2 report. They tell their healthcare and finance customers the access logs can't be tampered with. And then they store those logs in a normal database table - one with an &lt;code&gt;UPDATE&lt;/code&gt; statement and a &lt;code&gt;DELETE&lt;/code&gt; statement, and an engineer with admin access who could quietly change a row at 2 a.m. and leave no trace.&lt;/p&gt;

&lt;p&gt;HIPAA, SOC 2, and SEC Rule 17a-4 don't ask you to &lt;em&gt;promise&lt;/em&gt; you didn't tamper. They ask you to &lt;em&gt;prove&lt;/em&gt; it. "We don't touch it" is not proof. It's a policy. Policies fail audits.&lt;/p&gt;

&lt;p&gt;What pushed me from annoyed to building was learning AWS retired QLDB, its purpose-built ledger database. Teams that relied on a real append-only ledger suddenly had nowhere obvious to go. So I asked one stubborn question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What if immutability wasn't a rule you follow, but a permission you don't have?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;LedgerLock is the answer. This is how I built it - and the bugs and scale problems that taught me the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core: immutability as an absent permission
&lt;/h2&gt;

&lt;p&gt;LedgerLock is a drop-in audit API. A hospital app, a fintech, an insurer calls one line - &lt;code&gt;ledger.append(event)&lt;/code&gt; - and every access is written to a tamper-evident ledger on DynamoDB.&lt;/p&gt;

&lt;p&gt;The data model is a single table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PK = TENANT#&amp;lt;tenantId&amp;gt;        // partition key = the tenant
SK = EVENT#&amp;lt;zero-padded-seq&amp;gt;  // sort key = a strict sequence number
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partition key does something quietly powerful: every query is scoped to one partition key, so &lt;strong&gt;multi-tenant isolation is structural&lt;/strong&gt; - one tenant's query physically cannot return another's events.&lt;/p&gt;

&lt;p&gt;The append is one conditional write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ConditionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attribute_not_exists(SK)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// append-only guard&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the part the whole thesis rests on - the IAM policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:PutItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:Query"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:dynamodb:ap-south-1:...:table/LedgerLock"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;UpdateItem&lt;/code&gt;. No &lt;code&gt;DeleteItem&lt;/code&gt;. When the app tries to delete, AWS refuses with &lt;code&gt;AccessDeniedException&lt;/code&gt;. You can't misuse a capability you were never granted. &lt;em&gt;Immutability isn't a rule we follow - it's a permission we don't have.&lt;/em&gt;&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%2Fmnku84y7e4oai16ilicr.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%2Fmnku84y7e4oai16ilicr.png" alt=" " width="800" height="141"&gt;&lt;/a&gt;&lt;br&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%2Fdi617nodwbdi4d56m28a.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%2Fdi617nodwbdi4d56m28a.png" alt=" " width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each event also stores &lt;code&gt;hash = SHA256(canonical(event) + prevHash)&lt;/code&gt;, so altering any past record breaks every record after it. Tampering cascades; it doesn't hide.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug #1: the chain could silently fork
&lt;/h2&gt;

&lt;p&gt;This is the bug that almost killed the project, and it passed every casual test.&lt;/p&gt;

&lt;p&gt;My first design used a random ID in the sort key. Two users trigger an event at the same millisecond. Both read "the last event is #5." Both write #6 - but with &lt;em&gt;different&lt;/em&gt; random IDs, so both &lt;code&gt;attribute_not_exists(SK)&lt;/code&gt; conditions pass. &lt;strong&gt;Both writes succeed.&lt;/strong&gt; Now two records both claim to be #6. The chain forked, and the verifier would flag the ledger as broken under completely normal load.&lt;/p&gt;

&lt;p&gt;A tamper-evident ledger that breaks itself is worse than useless. The fix: make the sequence number &lt;em&gt;itself&lt;/em&gt; the uniqueness constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`EVENT#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now two concurrent writes target the &lt;em&gt;same key&lt;/em&gt;, &lt;code&gt;EVENT#0000000006&lt;/code&gt;. DynamoDB's conditional write lets exactly one win; the loser retries, re-reads the new tail, and writes #7. That's optimistic concurrency control with a single conditional write - no locks, no queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WORM layer: catching a tampering admin
&lt;/h2&gt;

&lt;p&gt;Here's the scenario the hash chain alone can't handle: what if the attacker has full DynamoDB admin? They alter a record &lt;em&gt;and&lt;/em&gt; rewrite every later hash to make the chain internally consistent. It would verify clean.&lt;/p&gt;

&lt;p&gt;So every &lt;strong&gt;10 events&lt;/strong&gt;, DynamoDB Streams trigger a Lambda that computes a &lt;strong&gt;Merkle root&lt;/strong&gt; over the sealed range and writes it to &lt;strong&gt;S3 Object Lock in COMPLIANCE mode&lt;/strong&gt; - write-once storage no one can overwrite or delete, not even the AWS root account, until retention expires.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DynamoDB Streams → Lambda → S3 Object Lock (COMPLIANCE)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now even a full rewrite of the live chain won't match the independent Merkle root sealed in S3. The forgery is caught against a record the attacker could never touch. (This is the same Object Lock mechanism AWS has had assessed for SEC 17a-4 and FINRA recordkeeping.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: verification didn't scale, and I had to be honest
&lt;/h2&gt;

&lt;p&gt;A full chain walk is &lt;code&gt;O(n)&lt;/code&gt;. At 60 events, instant. At the "millions of events" I was claiming as production, infeasible. A database engineer knows this immediately.&lt;/p&gt;

&lt;p&gt;The fix made the WORM seals &lt;em&gt;load-bearing for verification&lt;/em&gt;, not just for proof. You don't re-verify from genesis - you trust the newest valid sealed Merkle root and walk only the &lt;strong&gt;tail since that seal&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verifyChainSinceSeal:  trust newest valid S3 seal → walk tail only → O(tail), not O(n)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full verify of a large tenant re-hashes every event from genesis. Since-seal verify trusts the newest valid WORM seal and walks &lt;strong&gt;only the unsealed tail&lt;/strong&gt; - when the checkpointer is caught up, the hash walk is skipped entirely for the sealed prefix.&lt;/p&gt;

&lt;p&gt;At 100k events on our bench (fully sealed, tail = 0), both modes take ~22–23s because &lt;strong&gt;loading 100k rows from DynamoDB&lt;/strong&gt; dominates the time; the hash-walk savings are real but small once the chain is fully sealed. The dramatic win shows up when the sealer &lt;strong&gt;lags under burst load&lt;/strong&gt;: e.g. 62k events pending seal - since-seal walks ~59k hashes while full verify walks 100k, and the dashboard surfaces the lag honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: the checkpointer fell behind - and that became a feature
&lt;/h2&gt;

&lt;p&gt;When I bulk-seeded to stress-test scale, I generated writes faster than the Streams→Lambda checkpointer could seal them. For a while the ledger had thousands of valid events not yet covered by a WORM seal.&lt;/p&gt;

&lt;p&gt;My first instinct was to hide it. Then I realized: &lt;strong&gt;that is exactly what a real audit pipeline does under a write burst.&lt;/strong&gt; It stays correct, the unsealed tail is clearly bounded and marked, and the checkpointer catches up and self-heals. So instead of hiding it, LedgerLock surfaces it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;sealed through #N · M events pending seal · catching up&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A system that degrades safely and recovers under load is far more convincing than one that pretends bursts never happen. The accident became one of the most production-credible parts of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't trust me - verify it yourself
&lt;/h2&gt;

&lt;p&gt;The last piece is what makes it regulator-grade rather than just clever. The Merkle tree I already had for the WORM seals can produce, for any single event, an &lt;strong&gt;O(log n) inclusion proof&lt;/strong&gt; - the handful of sibling hashes that prove "this exact record belongs to sealed checkpoint #N," without revealing any other record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/proof?tenantId=&amp;amp;seq=  →  O(log n) sibling path, validated against the sealed root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A customer can hand a regulator one record + a short proof + the public sealed root, and the regulator verifies it belongs - &lt;strong&gt;offline, with no access to my app or my AWS account.&lt;/strong&gt; I built a standalone verifier (&lt;code&gt;verify-export.mjs&lt;/code&gt;) that does exactly this with zero AWS credentials. The guarantee doesn't depend on trusting LedgerLock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building on DynamoDB
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model access patterns first.&lt;/strong&gt; Design the keys around the questions; isolation and queries fall out for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional writes are more powerful than they look&lt;/strong&gt; - &lt;code&gt;attribute_not_exists&lt;/code&gt; gave me append-only &lt;em&gt;and&lt;/em&gt; optimistic concurrency control from one expression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your checkpoints load-bearing.&lt;/strong&gt; The Merkle seals weren't just proof - they're what made verification bounded and made inclusion proofs possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the failure modes show.&lt;/strong&gt; The self-healing-under-burst behavior became a strength precisely because I stopped hiding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM is architecture.&lt;/strong&gt; The most convincing security property in the whole project is something the app &lt;em&gt;can't do.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A published one-line client SDK; multi-region reads via Global Tables (writes stay single-region per tenant, because the chain needs one global order - a deliberate trade); and a hosted public-root endpoint so auditors verify inclusion proofs against the sealed roots with no account at all.&lt;/p&gt;

&lt;p&gt;But the core lesson is the one I started with. There's a real difference between &lt;em&gt;promising&lt;/em&gt; your audit log is immutable and &lt;em&gt;building a system where altering it is detectable&lt;/em&gt; - even by the people who run it, provable at scale, verifiable by a stranger offline. That difference is the whole product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://ledgerlock-vert.vercel.app" rel="noopener noreferrer"&gt;https://ledgerlock-vert.vercel.app&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Dashboard:&lt;/strong&gt; &lt;a href="https://ledgerlock-vert.vercel.app/dashboard" rel="noopener noreferrer"&gt;https://ledgerlock-vert.vercel.app/dashboard&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/HawaleShailesh004/ledgerlock" rel="noopener noreferrer"&gt;https://github.com/HawaleShailesh004/ledgerlock&lt;/a&gt; &lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built for the #H0Hackathon with Amazon DynamoDB, AWS Lambda, S3 Object Lock, and Vercel. Thanks for reading.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>h0hackathon</category>
      <category>dynamodb</category>
      <category>vercel</category>
      <category>database</category>
    </item>
  </channel>
</rss>
