DEV Community

Shailesh Hawale
Shailesh Hawale

Posted on

I built an audit log that catches its own tampering - and proves it at scale, offline

I built this project for the #H0Hackathon (Hack the Zero Stack with Vercel and AWS Databases). This post covers how I built it using Amazon DynamoDB and Vercel.

There's a quiet lie at the center of a lot of compliance software.

Almost every regulated SaaS company says it keeps an immutable 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 UPDATE statement and a DELETE statement, and an engineer with admin access who could quietly change a row at 2 a.m. and leave no trace.

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

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:

What if immutability wasn't a rule you follow, but a permission you don't have?

LedgerLock is the answer. This is how I built it - and the bugs and scale problems that taught me the most.

The core: immutability as an absent permission

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

The data model is a single table:

PK = TENANT#<tenantId>        // partition key = the tenant
SK = EVENT#<zero-padded-seq>  // sort key = a strict sequence number
Enter fullscreen mode Exit fullscreen mode

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

The append is one conditional write:

await ddb.send(
  new PutCommand({
    TableName: TABLE,
    Item: item,
    ConditionExpression: "attribute_not_exists(SK)", // append-only guard
  }),
);
Enter fullscreen mode Exit fullscreen mode

And the part the whole thesis rests on - the IAM policy:

{
  "Effect": "Allow",
  "Action": ["dynamodb:PutItem", "dynamodb:Query"],
  "Resource": "arn:aws:dynamodb:ap-south-1:...:table/LedgerLock"
}
Enter fullscreen mode Exit fullscreen mode

No UpdateItem. No DeleteItem. When the app tries to delete, AWS refuses with AccessDeniedException. You can't misuse a capability you were never granted. Immutability isn't a rule we follow - it's a permission we don't have.


Each event also stores hash = SHA256(canonical(event) + prevHash), so altering any past record breaks every record after it. Tampering cascades; it doesn't hide.

Bug #1: the chain could silently fork

This is the bug that almost killed the project, and it passed every casual test.

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 different random IDs, so both attribute_not_exists(SK) conditions pass. Both writes succeed. Now two records both claim to be #6. The chain forked, and the verifier would flag the ledger as broken under completely normal load.

A tamper-evident ledger that breaks itself is worse than useless. The fix: make the sequence number itself the uniqueness constraint:

const seq = prev ? prev.seq + 1 : 0;
const sk = `EVENT#${String(seq).padStart(10, "0")}`;
Enter fullscreen mode Exit fullscreen mode

Now two concurrent writes target the same key, EVENT#0000000006. 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.

The WORM layer: catching a tampering admin

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

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

DynamoDB Streams → Lambda → S3 Object Lock (COMPLIANCE)
Enter fullscreen mode Exit fullscreen mode

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.)

Bug #2: verification didn't scale, and I had to be honest

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

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

verifyChainSinceSeal:  trust newest valid S3 seal → walk tail only → O(tail), not O(n)
Enter fullscreen mode Exit fullscreen mode

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

At 100k events on our bench (fully sealed, tail = 0), both modes take ~22–23s because loading 100k rows from DynamoDB 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 lags under burst load: e.g. 62k events pending seal - since-seal walks ~59k hashes while full verify walks 100k, and the dashboard surfaces the lag honestly.

Bug #3: the checkpointer fell behind - and that became a feature

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.

My first instinct was to hide it. Then I realized: that is exactly what a real audit pipeline does under a write burst. 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:

sealed through #N · M events pending seal · catching up

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.

Don't trust me - verify it yourself

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 O(log n) inclusion proof - the handful of sibling hashes that prove "this exact record belongs to sealed checkpoint #N," without revealing any other record.

GET /api/proof?tenantId=&seq=  →  O(log n) sibling path, validated against the sealed root
Enter fullscreen mode Exit fullscreen mode

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

What I'd tell someone building on DynamoDB

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

What's next

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.

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

Live demo: https://ledgerlock-vert.vercel.app

Dashboard: https://ledgerlock-vert.vercel.app/dashboard

Source code: https://github.com/HawaleShailesh004/ledgerlock


Built for the #H0Hackathon with Amazon DynamoDB, AWS Lambda, S3 Object Lock, and Vercel. Thanks for reading.

Top comments (0)