Inspiration

There's a quiet lie at the center of a lot of compliance software, and it bothered me for a long time.

Almost every regulated SaaS company says it keeps an immutable audit log. They write it 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 that has 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 with the record. They ask you to prove it. And "we don't touch it" is not proof. It's a policy. Policies fail audits.

What pushed me from annoyed to building was learning that AWS retired QLDB - its purpose-built ledger database. A lot of teams that relied on a real append-only ledger suddenly had nowhere obvious to go. That felt like a gap worth filling with the databases we do have.

So I kept circling one question:

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

What it does

LedgerLock is a drop-in, tamper-evident audit-trail API for regulated SaaS. A hospital app, a fintech, an insurer drops in one call - ledger.append(event) - and every access (a patient record viewed, a record updated, a break-the-glass emergency override) is written to a cryptographically tamper-evident ledger.

Four guarantees make it actually hold up under a hostile admin:

  1. Append-only at the data layer. Every write is a DynamoDB PutItem with a condition that the record can't already exist. There is no update path and no delete path in the code - and the app's IAM role literally does not have UpdateItem or DeleteItem permission.
  2. A SHA-256 hash chain. Each event's fingerprint includes the previous event's. Alter any past record and its fingerprint changes, which breaks every record after it - tampering cascades, it doesn't hide.
  3. WORM-sealed Merkle checkpoints. Every 10 events, a Lambda triggered by DynamoDB Streams computes a Merkle root and seals it into S3 Object Lock (COMPLIANCE mode) - write-once storage no one can overwrite or delete, not even the AWS root account.
  4. Independent, portable proof. Any single record can produce an O(log n) Merkle inclusion proof - a handful of sibling hashes that prove "this exact record belongs to sealed checkpoint #N," verifiable by a regulator offline, with no access to our app or our AWS account.

The moment that sells it: you tamper with a historical record directly in the DynamoDB console - full admin, bypassing the app entirely - then hit Verify. The integrity score drops, the altered record lights up, every record after it is invalidated, and the live Merkle root visibly diverges from the sealed WORM root it can never match.

How we built it

The whole system is a single DynamoDB table behind a Next.js API on Vercel.

  • Single-table design. PK = TENANT#<id> makes multi-tenant isolation structural - every query is physically scoped to one tenant, so one customer can never read another's events. SK = EVENT#<zero-padded-seq> keeps each tenant's chain strictly linear.
  • Append-only enforcement. PutItem with ConditionExpression: attribute_not_exists(SK), plus a least-privilege IAM role with only PutItem and Query. No update, no delete.
  • Tamper-evidence. SHA-256 hash chain with a canonical JSON serialization so the same logical event always hashes identically.
  • WORM layer. DynamoDB Streams → AWS Lambda → S3 Object Lock (COMPLIANCE) Merkle seals.
  • Scalable verification. A full chain re-walk is O(n) - fine at 60 events, painful at 100k. Verification trusts the newest valid WORM seal and walks only the unsealed tail - bounded hash work, not linear. The WORM checkpoints exist to make that tail small under normal load.
  • Checkpointer lag visibility. GET /api/tenant-stats exposes total events, sealed-through boundary, and pending seal count - surfaced in the dashboard during burst ingest.
  • Inclusion proofs. GET /api/proof?tenantId=&seq= returns the O(log n) Merkle sibling path, validated byte-for-byte against the sealed root.
  • Sparse GSI. Flagged events (break-the-glass, bulk export) carry a GSI1PK, so the compliance "review queue" queries only the sparse index - no scan, no filter.
  • Region: ap-south-1 (Mumbai), so the live demo is fast.

Here's the shape of it:

   Regulated SaaS app                LedgerLock (Vercel / Next.js)              AWS
 ┌────────────────────┐            ┌──────────────────────────────┐
 │  ledger.append(e)  │ ─────────▶ │  POST /api/events  (append)   │ ─────▶  DynamoDB single table
 │  (one line embed)  │            │  POST /api/verify  (since-seal)│         PK=TENANT#  SK=EVENT#seq
 └────────────────────┘            │  GET  /api/proof   (Merkle)    │         append-only · hash chain
                                   │  GET  /api/alerts  (sparse GSI)│              │
                                   └──────────────────────────────┘              │ Streams (NEW_IMAGE)
                                                                                  ▼
                                                                          Lambda - checkpointer
                                                                          Merkle root every 10
                                                                                  │
                                                                                  ▼
                                                                          S3 Object Lock (COMPLIANCE)
                                                                          write-once Merkle seal
                       app IAM role:  ✓ PutItem  ✓ Query  ✗ Update  ✗ Delete

Challenges we ran into

The chain could silently fork - the bug that nearly broke the whole premise. My first design used a random ID in the sort key, so two events written at the same instant both succeeded - and I'd get two records both claiming the same position in the chain. A tamper-evident ledger that quietly breaks itself under normal concurrent load is worse than useless. The fix was to make the sequence number itself the uniqueness constraint: now two simultaneous writes collide on the exact same key, one wins, the other retries. It's optimistic concurrency control with a single conditional write - no locks, no queue - and it's what keeps the chain trustworthy.

Verification didn't scale, and I had to be honest about it. A full chain walk is O(n). At a few thousand events it's already sluggish. The answer was to make the WORM seals load-bearing for verification, not just for proof: verify forward from the last sealed Merkle root, never from genesis. That turned "verify the whole history" into "verify a small bounded tail."

The checkpointer fell behind under burst load - and that turned out to be 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 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 is more convincing than one that pretends bursts never happen.

Accomplishments that we're proud of

  • Immutability enforced by the absence of a permission, not by good intentions - you can watch a delete get denied.
  • Tamper caught even against a database admin, because the proof lives in write-once storage that can't be edited to match.
  • Bounded verification - at 100k events with the checkpointer caught up, verify trusts the WORM seal through #100,000 and skips re-hashing the sealed prefix; when the sealer lags under burst load, only the unsealed tail is walked.
  • Regulator-grade portability - an O(log n) Merkle inclusion proof plus an offline verifier that re-checks an exported ledger with zero access to our app or AWS. Don't trust us - verify it yourself.
  • It ships: live on Vercel, real DynamoDB and S3 behind it, embeddable in one line.

What we learned

I learned how much of "immutability" in the wild is really just a promise, and how different it feels when it's a property of the system instead. I learned to model DynamoDB access-pattern-first - design the keys around the questions before writing app code, and the isolation and the queries fall out for free. And I got a real appreciation for how DynamoDB Streams, Lambda, and S3 Object Lock compose into an audit pipeline that's genuinely hard to forge - and how the right data structure (a Merkle tree) turns "trust our dashboard" into "verify it yourself, offline."

What's next for LedgerLock

  • A published client SDK so any app embeds it in one line.
  • Multi-region reads via DynamoDB Global Tables for low-latency global audit access - while writes stay single-region per tenant, because the hash chain needs one global order per tenant (a deliberate, defensible trade).
  • A hosted public-root endpoint so auditors verify inclusion proofs against our sealed roots without any account at all.
  • A second backend option on Aurora DSQL for teams that want strongly-consistent multi-region writes alongside the audit trail. ```

Built With

Share this project:

Updates