Tamper-Evidence / Hash Chain
AuditStash can link persisted audit rows into a SHA-256 hash chain. Verification checks that stored payloads and links agree with their hashes.
A payload edit without updating its hash is detectable. Someone with write access to the entire database can recompute hashes and forge a consistent chain. Tail truncation also leaves the remaining chain internally consistent. Anchor hashes outside the database if you need evidence independent of database write access.
The feature does not establish regulatory compliance or prevent modification.
Enabling the chain
The feature is opt-in and off by default; existing installs need do nothing.
1. Run the migration
bin/cake migrations migrate -p AuditStashThis adds two nullable columns (prev_hash and hash) and an index on hash. Rows written before the migration will have NULL in both columns. The verifier skips those legacy rows until it reaches the first row with a stored hash (see Backfilling below).
2. Turn on hashChain on the persister
In config/app.php:
'AuditStash' => [
'persister' => \AuditStash\Persister\TablePersister::class,
'persisterConfig' => [
'hashChain' => true,
],
],From the next audit row onwards, every write goes through the chain.
How the hash is computed
For each row, the persister computes:
hash = sha256( prev_hash || "|" || canonical_json(payload) )Where:
prev_hashis thehashof the previous row in primary-key order, or the empty string for the very first row.payloadis all row fields exceptid,hash, andprev_hash.prev_hashcontributes via the separate chain-link argument rather than as a payload field so both writer and verifier agree on the input. Schema-owned fields missing from the event payload are normalized tonull, so nullable columns still hash consistently after a DB round-trip.canonical_jsonsorts associative-array keys recursively before JSON- encoding withJSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES. List arrays keep their order because order is semantically meaningful there.DateTimeInterfacevalues are normalized to second-precision strings, and PHP enums are normalized to their scalar value/name, so the hash round-trips cleanly across column-type precision and ORM type conversion.
Canonicalization makes the hash deterministic across PHP versions, machines and insertion orders: two semantically equivalent rows always produce the same hash. This matters for cross-environment verifiability (developer laptop vs. production vs. a third-party auditor's re-run).
Concurrency
Concurrent writers are a classic trap for naive hash chains: two requests both read the same "last hash" and one of them produces an orphaned link. AuditStash handles this by serializing chain writers per table on MySQL/Postgres and then running the full logEvents() batch inside a single transaction. Within that transaction it still reads the last chain link with SELECT ... FOR UPDATE:
SELECT hash FROM audit_logs ORDER BY id DESC LIMIT 1 FOR UPDATE;On MySQL/Postgres, the advisory lock closes the empty-table bootstrap race where there is no tail row yet to lock. A second writer waits until the first writer commits, then reads the freshly-written tail. SQLite serializes writes at the database level already.
IMPORTANT
hashChain is currently only supported by TablePersister. The Elastic Search persister cannot offer the same ordering guarantee and will ignore the flag. If you need tamper-evidence on Elastic, route your audit events through TablePersister (SQL) and replicate to Elastic downstream.
Verifying the chain
Command line
bin/cake audit_stash verify_chain
bin/cake audit_stash verify_chain --table=AuditStash.AuditLogs --chunk=1000Exits 0 if the chain is intact, 1 if a break is found. Schedule it:
# Daily integrity check at 03:15
15 3 * * * cd /var/www/app && bin/cake audit_stash verify_chainor wire it into CI so a broken chain fails the build.
Programmatically
use AuditStash\Service\ChainVerifier;
$result = (new ChainVerifier())->verify(
$this->fetchTable('AuditStash.AuditLogs'),
);
if (!$result->intact) {
$this->logger->critical(sprintf(
'Audit chain broken at row %d (position %d): %s',
$result->brokenRowId,
$result->rowsChecked,
$result->reason,
));
}The verifier streams rows in chunks (default 500) so very long chains verify in bounded memory. Verification is read-only: it never writes to the table it inspects.
Detecting specific tampering patterns
The verifier catches the three common tamper modes:
| Tamper pattern | Detected via |
|---|---|
| Row payload silently edited | Recomputed hash mismatches stored hash |
| Row deleted from the middle | Next row's prev_hash no longer matches |
| Row inserted with an invalid hash | Recomputed hash mismatches stored hash |
| Rows reordered / swapped | prev_hash pointers no longer form a valid line |
It does not catch a truncation at the tail: if an attacker deletes the last N rows and stops there, the remaining chain is still internally consistent. This is the fundamental limitation of every append-only hash chain. Mitigations:
- Anchor the tail periodically. Write
last_hashto an external system (a second database, S3 with object-lock, a trusted timestamping service, a blockchain, a signed email tocompliance@) so you have an offsite pointer to compare against. - Count rows. If you also track row counts offsite, a truncation shows up as a row-count mismatch.
- Use per-day heartbeats. Emit a daily "heartbeat" audit event; missing heartbeats reveal truncated ranges.
Retention cleanup and GDPR erasure
Some operations have to remove or rewrite rows that are already part of the chain:
audit_stash cleanupdeletes rows past their retention window. With--tablethat leaves gaps in the middle of the chain.audit_stash gdpr deleteremoves a user's rows, wherever they are.audit_stash gdpr anonymizerewrites columns that are part of the hash.
Each of them breaks links that cannot be repaired without rewriting every later hash. So after such an operation the plugin appends a seal: an audit row of type chain_seal with source AuditStash. Its changed payload holds:
| Key | Meaning |
|---|---|
operation | cleanup, gdpr.delete or gdpr.anonymize |
sealed_through_id | highest row id that existed when the seal was written |
sealed_rows | number of rows up to that id |
sealed_digest | SHA-256 over the id, stored hash and content of each of those rows |
broken_before | only present if the chain was already broken before the operation: the row and the reason |
The seal row is chained like any other row. verify_chain then works in two parts:
- Rows up to
sealed_through_idof the newest seal are checked againstsealed_digest. An edit, a deletion or an insertion in that range after the seal gives a different digest, and the verifier reportssealed segment mismatch. - Rows after it are verified link by link, as before.
The chain is verified before the operation runs. If it is already broken, the operation still runs, because an erasure request cannot wait for a forensic review, but the seal records the break in broken_before and verify_chain keeps reporting it. Maintenance never turns a broken chain into a clean one.
Only one maintenance run happens at a time. The operation and the seal are one database transaction: if the seal cannot be written, the operation is rolled back, so the chain is never left changed without a seal.
That transaction holds the chain write lock. Audit writes wait for it, and a write that waits longer than the lock timeout (10 seconds) fails the way it does under any other lock contention. The verification before the operation does not hold that lock, but the row changes and the digest for the seal do, and the digest reads the whole table. On a large table, run cleanup and GDPR operations when the application is quiet.
Only the newest seal counts. Verifying before and sealing after each read every row once, so a cleanup on a large table takes about as long as two verify_chain runs, the second of them under the write lock.
A seal records that the maintenance happened and what the table looked like afterwards. It cannot show what the removed rows contained. The limits described above apply to it as well: someone with write access to the table can forge a seal the same way they can rebuild the chain, which is why the tail should be anchored outside the database.
No seal is written while hashChain is off.
Backfilling an existing table
Rows written before enabling the chain have NULL in prev_hash and hash. You have three options:
- Leave them as-is. The verifier skips legacy rows with
NULLhashes and starts at the first row with a non-nullhash. Historic rows are not protected, but new rows are. - Seed the chain at the current tail. Write a single synthetic row before enabling
hashChainwith a hand-pickedprev_hash(typically the SHA-256 of a signed statement from the data controller: "as of 2026-04-15, the audit table contained 12,347 rows; no prior tamper evidence is asserted"). All subsequent rows chain off that. - Compute hashes for existing rows offline. Only valid if you can assert those rows were never edited; otherwise you're laundering potentially-tampered data into a "verified" chain. Usually this is not what you want.
For most deployments option 2 is the sensible choice: it draws a clean line between the pre-chain past and the chain-protected future, with an auditable attestation for the transition.
Impact on performance
Per row, hashChain adds:
- One extra
SELECT ... FOR UPDATEper batch (not per row: the tail is read once, then carried forward in memory for the rest of the batch). - One SHA-256 hash computation per row (~microseconds).
- One transaction wrap per
logEvents()call (a single transaction covering the whole batch).
Measure verification and write latency with your own data and concurrency. Chain writers serialize under a lock, so write contention can increase latency.
Example verification output
$ bin/cake audit_stash verify_chain
Verifying hash chain on AuditStash.AuditLogs…
Chain intact — 12483 row(s) verified.$ bin/cake audit_stash verify_chain
Verifying hash chain on AuditStash.AuditLogs…
Chain broken at row id=8421 (position 8421): hash mismatch: stored 7f4a…, recomputed 9c18…The position is the ordinal index of the first broken row in the walk, useful when you want to correlate a break to a specific backup or deployment window. The id is the primary key you'd look up in the table.
Further reading
src/Service/HashChain.php: the pure-function hasher and verifier core.src/Service/ChainVerifier.php: the streaming table verifier.src/Command/VerifyChainCommand.php: the CLI wrapper.tests/TestCase/Service/HashChainTest.php: unit tests.tests/TestCase/Service/ChainVerifierTest.php: integration tests.