Every B2B SaaS product eventually needs an audit log. The first version is always the same: an audit_events table with user_id, action, timestamp, and metadata. It works fine until an enterprise customer asks, "How do we know these logs haven't been modified?"
That question changes everything. A regular database table can be quietly altered by anyone with write access. An UPDATE statement leaves no trace. A deleted row simply vanishes. For compliance frameworks like SOC 2, HIPAA, and GDPR, "trust us" isn't an answer. You need logs that are provably unmodified.
This article walks through the architecture of tamper-evident audit logs using SHA-256 hash chaining, the same fundamental approach used in certificate transparency logs, git commits, and blockchain ledgers.
The Core Idea: Hash Chains
A hash chain links each event to every event that came before it. Each record includes a hash computed from its own data combined with the hash of the previous record. If any record is altered, its hash changes, which breaks the chain for every subsequent record.
Here's the minimal version:
const crypto = require('crypto');
function computeEventHash(event, previousHash) {
const payload = JSON.stringify({
actor: event.actor,
action: event.action,
target: event.target,
metadata: event.metadata,
timestamp: event.timestamp,
previousHash: previousHash
});
return crypto.createHash('sha256').update(payload).digest('hex');
}
The first event in a chain uses a known seed value (often a hash of the tenant ID or a fixed genesis string) as its previousHash. Every subsequent event references the hash of the event before it.
What Tamper-Evidence Actually Means
Let's be precise about the guarantee. Hash chaining does not prevent tampering. Nothing can prevent someone with database access from running an UPDATE. What it does is make tampering detectable.
If an attacker modifies event #47 in a chain of 10,000 events, the verification algorithm will fail at event #48, because #48's stored previousHash no longer matches the recomputed hash of the modified #47. Every event after the tampered one also fails verification.
def verify_chain(events):
"""Verify the integrity of an audit log chain."""
previous_hash = events[0].genesis_hash
for event in events:
expected_hash = compute_hash(event, previous_hash)
if expected_hash != event.stored_hash:
return {
"valid": False,
"broken_at": event.sequence_number,
"expected": expected_hash,
"found": event.stored_hash
}
previous_hash = event.stored_hash
return {"valid": True, "events_verified": len(events)}
This is a O(n) verification. You must walk the full chain to prove integrity. For performance at scale, you can checkpoint ranges and verify segments independently.
Canonical Serialization: The Hard Part
The trickiest engineering problem isn't the hashing. It's ensuring deterministic serialization. The same event data must always produce the same hash, regardless of which SDK generated it, which language the verification runs in, or when the verification happens.
This means you need:
- Sorted object keys - JSON key ordering varies by language and library. Always sort keys alphabetically before hashing.
- Normalized timestamps - Use ISO 8601 in UTC with millisecond precision. Never rely on locale-dependent formatting.
-
Stable number formatting -
1.0vs1vs1.00will produce different hashes. Define a canonical representation. - UTF-8 encoding - Hash the bytes, not the string. Specify encoding explicitly.
// Go example: canonical serialization
func canonicalize(event AuditEvent, previousHash string) ([]byte, error) {
canonical := map[string]interface{}{
"actor": event.Actor,
"action": event.Action,
"metadata": event.Metadata,
"previousHash": previousHash,
"target": event.Target,
"timestamp": event.Timestamp.UTC().Format("2006-01-02T15:04:05.000Z"),
}
// json.Marshal in Go sorts map keys alphabetically
return json.Marshal(canonical)
}
Getting this wrong means your Node SDK and your Go SDK will compute different hashes for the same event, and your chain will appear broken when it isn't. Write cross-language integration tests early.
Storage Considerations
The storage layer needs three properties:
- Append-only writes - New events are inserted; existing events are never updated. Enforce this at the application layer and, if your database supports it, at the storage layer (e.g., Postgres row-level security policies, or an append-only file).
- Monotonic sequencing - Each event gets a sequence number. Use a database sequence or auto-increment, not application-generated IDs. Gaps in sequence numbers are suspicious and should be flagged during verification.
- Separate hash storage - Store the computed hash alongside the event. During verification, recompute the hash from the event data and compare it to the stored hash. If they differ, the event data was modified after ingestion.
For multi-tenant systems, each tenant gets an independent chain. Cross-tenant verification is meaningless and adds complexity for no benefit.
Strengthening the Guarantee: External Witnesses
Hash chains are verifiable, but the chain operator could theoretically rewrite the entire chain from scratch with new, consistent hashes. To defend against this, periodically publish chain checkpoints to an external witness, a service or medium outside your control.
Options include:
- Public transparency logs (like Certificate Transparency)
- Timestamping authorities (RFC 3161)
- A simple tweet or GitHub commit containing the latest chain hash
If the published checkpoint doesn't match the current chain state, something was rewritten between checkpoints.
Putting It Together
The full write path for an audit event looks like this:
- Receive event payload from SDK
- Validate required fields and normalize data types
- Retrieve the hash of the most recent event in the tenant's chain
- Canonicalize the new event + previous hash
- Compute SHA-256 hash
- Write event + hash to storage in a single atomic operation
- Return the event ID and hash to the caller
# Ruby SDK usage
AuditKit.log(
actor: { id: "user_123", email: "jane@example.com" },
action: "document.updated",
target: { type: "document", id: "doc_456" },
metadata: { ip: request.remote_ip, changes: diff }
)
The verification path is independent: fetch all events for a tenant, walk the chain, recompute every hash, report any breaks.
Why This Matters Beyond Compliance
Tamper-evident logs aren't just a checkbox for auditors. They're a building block for:
- Incident investigation - When a security event occurs, you need logs you can trust. If the attacker had database access, conventional logs are suspect. Hash-chained logs reveal if they were altered.
- Customer trust - Enterprise customers can independently verify their audit trail without trusting your infrastructure.
- Legal defensibility - Tamper-evident logs carry more weight as evidence because their integrity is mathematically verifiable.
If you want to implement this without building from scratch, AuditKit is the open-source implementation I built based on this architecture. It handles the serialization edge cases, ships SDKs for Node/Python/Go/Ruby, and includes a React viewer component. But the core algorithm is simple enough to build yourself. The hard part is the serialization consistency and the operational details, not the cryptography.
Top comments (0)