Tamper-evident audit logsHow chains work. What they prove. What they don't.
An audit log nobody can verify isn't evidence. It's a file. The difference between the two is a hash chain and the discipline to check it.
-
01
Action
Agent accesses secret
Timestamp, actor, target, outcome recorded
entry · audit.jsonl -
02
Mechanism
HMAC chains the entry
MAC covers prev_mac || entry bytes
append-only · one key -
03
Outcome
Verify detects tamper
Any modified entry breaks all later MACs
hasp audit --verify
TL;DR· the answer, in twenty seconds
What: Tamper-evident logs detect whether a past entry was changed. They do not prevent the change. The detection mechanism is a hash chain: each entry's MAC covers the previous MAC, so any modification breaks every subsequent link.
Fix: Add a verification step to your incident workflow. A chain you never verify is just a log. Run hasp audit --verify (or the equivalent for your own chain) before you cite log output in a postmortem.
Lesson: The right question in a compliance conversation is not "do we have logs?" but "can we prove those logs haven't changed since the event?" Those are different questions with different answers.
A compliance auditor asks whether your AI coding agent accessed the production database credential between 14:00 and 16:00 on Tuesday. You open your log file. It says the agent did not. The auditor asks how you know the log wasn't modified after the fact.
If you can't answer that question, you don't have evidence. You have a file.
Tamper-evident audit logs exist to close that gap. They don't lock the file. They don't require an HSM or a notary. They make after-the-fact modification detectable, so that when you hand a compliance team a log excerpt, "we can verify this hasn't changed" is a statement you can back up.
AI coding agents make this concrete. GitGuardian's 2026 State of Secrets Sprawl found that AI-assisted repos leak credentials at roughly twice the baseline rate. When something goes wrong, the first question is always: "What did the agent actually do?" Without a verifiable log, the answer is reconstruction from memory and indirect evidence. With one, it's a command.
What 'tamper-evident' actually means
The term is precise. Tamper-evident means the system can detect that a change occurred. Tamper-proof means the change cannot occur. No software log is tamper-proof. An attacker with write access to the file can change it. Tamper-evident logs don't stop that. They make it visible.
The analogy is a wax seal on an envelope. Breaking it proves someone opened the envelope. It didn't stop them.
This distinction matters when you're writing compliance documentation. "We maintain tamper-evident audit logs" is a claim about detection capability. "Our logs cannot be modified" is a claim most systems can't honestly make. Use the right phrase and know what you're committing to.
Short version
- HMAC chaining is the cheapest pattern that works: each entry's MAC covers the previous MAC plus the new entry, so modifying any entry breaks every MAC that follows.
- Merkle logs extend that guarantee to third-party verifiability. More infrastructure, stronger property.
- Append-only WORM storage moves the guarantee to the storage layer. Strong when the storage is actually WORM, which you should verify with the vendor.
- A chain you never verify is just a log with extra computation.
- What belongs in the log: timestamp, actor, action, target, outcome. Not the secret value. Not the cleartext command.
Three patterns
HMAC chaining
Each log entry includes a message authentication code computed over the bytes of the previous MAC concatenated with the bytes of the current entry. One key. Append-only. The first entry uses a fixed initialization vector or a key-derived seed as its "previous MAC."
# Pseudocode for writing one entry
entry = { ts, actor, action, target, outcome }
entry.mac = hmac_sha256(key, prev_mac || json_encode(entry))
append_to_log(entry)
prev_mac = entry.mac
Verification replays the chain:
# Pseudocode for verifying the chain
prev_mac = INIT_MAC
for entry in log:
expected = hmac_sha256(key, prev_mac || json_encode_without_mac(entry))
assert entry.mac == expected, f"chain broken at {entry.ts}"
prev_mac = entry.mac
Any modified entry fails its own MAC check. Every entry after it also fails because the chain fed forward a different MAC. You can't forge a valid chain without the key. You can detect a broken chain without the key (failure reveals itself; you only need the key for fresh writes).
Git uses this pattern for commit integrity. Certificate Transparency logs (RFC 6962) use it for TLS certificate auditing at internet scale. The pattern is old and well-understood.
The limit: the key is the trust root. If an attacker who can write the log also has the key, they can recompute a valid chain over modified entries. Rotate the HMAC key when the auditor changes or when a team member with key access leaves. Keep the key out of the log directory.
Merkle log / transparency log
A Merkle log organizes entries as leaves in a hash tree. Periodic signed root hashes are published externally (to a gossip network, a transparency server, or a public endpoint). Verifying that a specific entry was included requires only an O(log n) proof, not a replay of the full chain.
Certificate Transparency (Google's Sunlight, trillian) and Sigstore's Rekor use this pattern. The property it adds over HMAC chaining: a third party who received the published root can verify inclusion without trusting the log operator. The log operator can't silently remove or modify past entries without the published roots becoming inconsistent.
This is the right pattern for audit logs that need to be verified by parties who don't trust you. A compliance auditor at a customer who holds a published root hash can verify your log themselves.
The cost is infrastructure: you need a publication endpoint, a gossip or consistency check mechanism, and clients that actually check inclusion proofs. For internal audit logs, HMAC chaining is usually sufficient. For external commitments ("we certifiably did not modify this log after the event"), Merkle transparency is worth the overhead.
Append-only WORM storage
WORM (write-once, read-many) storage layers provide tamper evidence at the storage level rather than the log level. AWS S3 Object Lock in compliance mode, Azure Immutable Blob Storage, and physical WORM tape all fall here.
The advantage: the log format itself can be simple JSON Lines with no chain computation. The storage layer refuses modifications and deletions for the retention period.
The catch: "promises immutability" and "is immutable" are different claims. S3 Object Lock in governance mode can be overridden by a privileged IAM role. Compliance mode cannot, but your vendor agreement defines what Anthropic, Amazon, or Microsoft actually commit to. Read it before you cite WORM storage in a compliance document. If a storage vendor gets compromised, they may be able to roll back objects. If you're writing internal audit logs, also verify that no service account in your environment has the s3:PutObjectLegalHold permission that could lift the lock.
WORM storage works well as a second layer under HMAC chaining. The chain proves integrity independently of the storage guarantee. If the storage fails (vendor incident, misconfigured policy), the chain still lets you detect modifications.
What goes in the log
Log what happened, not what the agent saw.
A good entry:
{
"ts": "2026-04-01T14:03:27Z",
"actor": "claude-code",
"action": "secret_access",
"target": "prod/stripe_secret_key",
"outcome": "granted",
"session": "sess_a3f1b2",
"mac": "c7d2e9..."
}
A bad entry:
{
"ts": "2026-04-01T14:03:27Z",
"value": "sk_live_4xBk...",
"command": "curl -H 'Authorization: Bearer sk_live_4xBk...' ..."
}
The secret value doesn't belong in the log. The cleartext command doesn't either. If the log file is compromised, you don't want to have handed the attacker every credential that ever moved through the system.
Log the identity of what was accessed (the secret name or path), not its content. Log the session identifier so you can correlate with other events. Log the outcome: granted, denied, expired.
For AI agent operations specifically, the actor field should be specific enough to distinguish "Claude Code running in my terminal" from "automated CI pipeline running Claude Code." Session identifiers help here. So does logging the parent process name and PID, if you can get them reliably.
What gets missed
JSON Lines without a chain is not tamper-evident. It's a log format. Plenty of teams write structured logs to a file and call them "audit logs." Splunk ingestion, CloudWatch Logs, Datadog are not tamper-evident by default. The logs might be write-protected at the access-control level. That's different from cryptographic tamper evidence. Access control says "you shouldn't modify this." A hash chain says "if you did, I'll know."
A chain you never verify is still just a log. The verification step is not optional. A certificate chain no browser checks is not a security mechanism. The same logic applies here. Build the verification step into your incident runbooks. Run it before you cite log output in a postmortem. Run it before you hand excerpts to a compliance team. Build a periodic automated check that fails loudly if the chain is broken.
# Minimal periodic check, cron or CI
hasp audit --verify || alert "audit chain broken"
Trusting the logging service without verifying the guarantee. Several SaaS observability platforms offer "immutable logs" as a feature tier. Read what they actually commit to. Most mean "we won't delete your logs for 90 days." That's retention, not tamper evidence. A determined attacker (including an insider at the vendor) may still be able to modify log content. If you're citing a third-party logging service in compliance documents, verify what the contract says about the immutability guarantee and whether the vendor publishes verification proofs you can check.
Forgetting key rotation. An HMAC chain is only as trustworthy as the secrecy of the key. When an auditor leaves the organization, rotate the key. When a team member who had key access leaves, rotate it. Seal the old chain at the rotation point: record the rotation event as the last entry under the old key, then start a new chain under the new key. You can still verify each segment independently.
Checklist for your audit log implementation
Audit log review
- [ ] Each log entry includes a MAC over (prev_mac || entry bytes)
- [ ] Initialization vector / seed for first entry is documented
- [ ] HMAC key stored outside the log directory
- [ ] Key rotation procedure documented with responsible party named
- [ ] Verification command (or script) exists and is tested
- [ ] Verification runs in CI or on a scheduled job, not only on demand
- [ ] Log entries include: ts, actor, action, target, outcome, session id
- [ ] Log entries exclude: secret values, cleartext credentials, raw commands
- [ ] If using WORM storage: confirmed compliance mode (not governance mode)
- [ ] If using WORM storage: verified no privileged role can override the lock
- [ ] If citing logs in compliance docs: most recent verify run is dated and recorded
- [ ] Key rotation triggered by auditor/team-member departure from the project
What this means for your stack
If you're running AI coding agents against production credentials, the audit log question comes up fast. "Did the agent see my STRIPE_KEY today?" should take seconds to answer, not a grep through shell history and a conversation about whether your logging was on.
The HMAC-chained pattern above is what hasp uses for its local audit log at ~/.hasp/audit.jsonl. Each grant (secret name, agent session, timestamp, outcome) gets chained into the log at access time. hasp audit --verify replays the chain and exits non-zero if any entry has changed since it was written. The log holds identifiers, not values, so a compromised log file doesn't hand an attacker credentials.
Whatever implementation you use, the property to aim for is the same: a log where modification is detectable, verification is automated, and the answer to "did X happen?" doesn't depend on trusting the log operator's honesty about whether they touched the file.
Sources· cited above, in one place
Stop handing the agent your real keys.
hasp keeps secrets in one local encrypted vault, brokers them into the child process at exec, and never lets the agent read the value.
- Local, encrypted vault — no account, no cloud, no telemetry by default.
- Brokered run — agent gets a reference, the child process gets the value.
- Pre-commit + pre-push hooks catch managed values before they ship.
- Append-only HMAC audit log answers "did the agent touch the prod token?" in seconds.
macOS & Linux. Source-available (FCL-1.0, converts to Apache 2.0). No account.