auditchain
Tamper-evident, hash-chained audit logging for Python.
Async-first, zero runtime dependencies, mypy --strict clean, 91% branch coverage
(203 tests).
Every record commits to the hash of the previous one. Anyone who edits, inserts,
removes or reorders records later breaks the chain — and verify tells you exactly
where. Built for compliance (SOC 2, ISO 27001, PCI) and for any system where "who
did what" must be provable after the fact.
How it works
record[0].hash = SHA-256(prev_hash(genesis) || payload[0])
record[1].hash = SHA-256(record[0].hash || payload[1])
...
With a seal_key, records are signed with HMAC-SHA256 instead, so a party that
does not hold the key cannot silently rewrite the log at all. Verification recomputes
the whole chain in O(n) and reports the first broken link.
Features
- Async-first API (
AuditLog) plus thin sync wrappers (SyncAuditLog) - Backends:
SqliteBackend,JsonlBackend,MemoryBackend,PostgresBackend - HMAC-SHA256 sealing with key rotation (per-record
key_id), or plain SHA-256 integrity without a key - Ed25519 signatures (
auditchain[ed25519]): an auditor verifies with a public key only, so they can check the log without being able to forge it - Merkle inclusion proofs: prove that one record belongs to the log using ~log₂n hashes and a root you already trust — without revealing the other records
- Ed25519-signed checkpoints: the anchor itself is verifiable with the public key, so an outsider never needs the secret that could re-seal the log
logging.Handler: drop it on an existing logger and the calls you already write become auditable records — written off the application's thread- Multi-process writers: an advisory file lock (
lock_path=) plus a tail re-read keeps workers, web processes and cron jobs on one valid chain - Checkpoints: signed anchors that detect tail truncation and prove a chain's state at a point in time
- Batch appends (
append_many) — one write for many records verify()API and averify/checkpointCLI (exit code 1 on failure — CI friendly)- Zero runtime dependencies (the
postgresextra addsasyncpg), Python 3.10+, fully typed (py.typed)
Install
pip install auditchain # sqlite / jsonl / memory backends
pip install "auditchain[postgres]" # + PostgreSQL backend (asyncpg)
pip install "auditchain[ed25519]" # + Ed25519 record signatures (cryptography)
Quickstart (async)
import asyncio
from auditchain import AuditLog, SqliteBackend
async def main():
async with AuditLog(SqliteBackend("audit.sqlite")) as log:
await log.append("sara", "login", "admin", metadata={"ip": "10.0.0.1"})
await log.append("jawad", "payment.approve", "invoice:12", metadata={"amount": 1200})
report = await log.verify()
print(report) # OK: 2 record(s) verified
asyncio.run(main())
Sealing records (HMAC)
from auditchain import AuditLog, SqliteBackend
key = secrets.token_bytes(32)
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key)
Without a seal_key, tampering is still detected — but only by integrity; anyone who
can write the log can rewrite it and re-seal it. Use a key when attackers might have
write access. Keep the key outside the log (env var, secret manager, file).
Signing records (Ed25519)
HMAC answers "was this written by someone holding the seal key?" — and anyone who can verify also holds the forging secret. Ed25519 splits those apart.
from auditchain import AuditLog, SqliteBackend, generate_keypair
seed, public_key = generate_keypair() # or: auditchain keygen
log = AuditLog(SqliteBackend("audit.sqlite"), signing_key=seed)
await log.append("alice", "invoice.approve", "inv-1")
Keep the private key; hand the public key to whoever verifies:
auditchain keygen --private-out signing.key --public-out signing.pub
auditchain verify audit.sqlite --signer s0=signing.pub
In code, pass signers to verify:
report = await log.verify(signers={"s0": public_key})
report.signed_records # how many records were checked against a public key
What it adds over HMAC
Someone who holds the seal key can rewrite a record, recompute every hash after it, and leave the chain perfectly consistent — HMAC cannot tell you. A signature can:
verifying a log an insider rewrote after recomputing the hashes
with the HMAC key → OK: 2 record(s) verified (undetected)
with the public key → FAILED at seq 0: signature mismatch: the record was
rewritten without the signing key
Design notes:
- The signature covers
seq:hash:signer_id. The hash already commits to the previous hash and the payload, so a signature cannot move to another record or another chain position. - Signature fields are stored next to the record but are not part of the hashed
payload, exactly like
key_id. Logs written before signing existed keep verifying, and unsigned JSONL lines are byte-for-byte unchanged. - Passing
signersmakes verification strict: every record must carry a valid signature. Without that rule an attacker holding the seal key could simply delete the signatures and still pass the hash checks. - Signatures are independent of sealing, so you can use either or both.
Key rotation
Rotate the seal key and the chain records the rotation itself (the marker is sealed with the old key, so it documents the decision under the key that was in effect):
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key0, key_id="k0")
await log.append("sara", "login")
await log.rotate(new_key, "k1") # appends a "key.rotate" marker, switches key
await log.append("jawad", "logout")
report = await log.verify() # uses the keyring (retired keys) automatically
Pass retired keys explicitly (or to verify) when reopening outside the same object:
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=new_key, key_id="k1",
keyring={"k0": key0})
key_id is stored next to each record but is not part of the hashed payload, so
logs written by v0.1 (which has no key ids) still verify — and records from 0.1 that
were sealed keep working with the same key.
Checkpoints (anchors)
The chain alone cannot detect someone deleting the last records — the remaining chain still links cleanly. A checkpoint is a signed anchor ("at seq N, the chain hash was H") that you store outside the log's trust boundary and verify against later:
cp = await log.checkpoint() # anchor the current tail
save_checkpoint(cp, "anchors/audit.checkpoint") # e.g. other machine, object storage
# later, on a possibly-tampered copy:
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=key)
report = await log.verify(checkpoint=load_checkpoint("anchors/audit.checkpoint", key))
# FAILED: chain ends before the checkpoint: tail truncation
Checkpoints with a seal key are signed (HMAC-SHA256), so a forged or edited checkpoint file is rejected. Without a key, the checkpoint is unsigned and only as trustworthy as the place you store it.
A checkpoint also carries the Merkle root of every record behind it, and the signature covers that root — which is what makes single-record proofs possible:
auditchain checkpoint audit.sqlite --signing-key signing.key # anchors seq + merkle root
auditchain proof audit.sqlite --seq 42 --output 42.proof # ~log2(n) hashes
auditchain verify-proof 42.proof --checkpoint audit.sqlite.checkpoint --public-key signing.pub
With --signing-key the anchor is signed with Ed25519 instead of the HMAC seal key, and
the verifier opens it with the public key only — no secret on their side. A signed
checkpoint refuses to load unverified, so "the anchor was checked" is never an assumption.
proof = await log.inclusion_proof(42)
proof.verify(trusted_root) # True
verify-proof exits 1 on failure, so a third party can gate on it in CI. Publish the
checkpoint somewhere you cannot rewrite it, hand out proofs, and an auditor confirms any
single record without seeing the rest of the log — and without holding your seal key.
The tree is the RFC 6962 construction: leaves are SHA256(0x00 || record_hash),
interior nodes are SHA256(0x01 || left || right), and a node with an odd number of
children is promoted instead of duplicated. The prefix stops a leaf hash from passing as
an interior node, and promoting removes the classic "duplicate the last hash" ambiguity,
so a proof cannot be replayed at a different size. Proofs commit to (seq, size).
Sync API
from auditchain import JsonlBackend, SyncAuditLog
log = SyncAuditLog(JsonlBackend("audit.jsonl"))
log.append("sara", "login")
assert log.verify().ok
log.close()
SyncAuditLog runs its own event loop per call; use the async API from inside an
already-running loop.
Audit the logs you already write
Application logs are verbose and disposable; audit logs are ordered and kept. One handler serves both, so the call site does not change:
import logging
from auditchain import AuditLog, AuditLogHandler, SqliteBackend
log = AuditLog(SqliteBackend("audit.sqlite"), seal_key=secrets.token_bytes(32))
logging.getLogger("billing").addHandler(AuditLogHandler(log))
logger.info("invoice.approve %s", invoice_id) # ← now also an audit record
{"actor": "billing", "action": "invoice.approve inv-7", "subject": "",
"metadata": {"logger": "billing", "level": "INFO", "module": "app", "line": 42,
"func": "approve", "amount": 1200}}
Details worth knowing:
- Nothing blocks the caller. Records are prepared on the emitting thread (the
LogRecordis not valid afterwards) and written by a daemon worker thread, so it is safe from async code, sync code and thread pools alike.handler.flush()waits until everything queued is on disk;handler.close()drains, stops the worker and (withclose_log=True) closes the log. - One writer, in order. The chain stays serialized no matter how many threads log.
- Attribution is explicit. The actor defaults to the logger name; override it per
record with
extra={"audit_actor": ..., "audit_action": ..., "audit_subject": ..., "audit_metadata": {...}}, or per handler withactor=(string or callable) andmetadata=. - Unrelated
extrakeys are not copied, so request ids and process ids do not leak into the audit metadata. Exceptions are captured as a formatted traceback inmetadata["exception"](turn off withinclude_exception=False). - A broken log never breaks the application: failures go to
handleErrorand are counted inhandler.error_count/handler.last_error. - Set
background=Falseto write through synchronously (useful in scripts and tests); it refuses to run inside an event loop, where you want the default instead.
Many processes, one log
Workers, web processes and cron jobs can append to the same log. Pass a lock and every writer re-reads the tail of the log before building its next record, so it chains onto what is really stored instead of what it last saw:
log = AuditLog(SqliteBackend("audit.sqlite"), lock_path="audit.lock")
lock = FileLock("/var/lib/myapp/audit.lock") # or share one object
log = AuditLog(SqliteBackend("audit.sqlite"), lock=lock)
What that buys, and what it does not:
- The lock is an advisory
flock(POSIX) ormsvcrt.locking(Windows) on a lock file next to the log. A crashed process cannot leave the log locked — the OS drops the lock with the process.timeout=turns a long wait intoLockTimeout. - It serializes the writers that use it, on one machine. It is not a distributed lock and it does not stop a rogue process from writing to the storage directly.
- Several machines need a lock that lives with the data: pass your own
lock=object (Postgres advisory lock, Redis, etc.). Anything withacquire()/release()works, and the tail re-read happens while it is held. - Readers and
verify()never take the lock. - Without the lock, two long-lived writers that both saw an empty log write sequence 0
twice: SQLite refuses the second insert with an
IntegrityError, while a JSONL file ends up with a broken chain that only verification notices.
Honest data point: four processes appending 15 records each to one SQLite log →
seq 0..59, contiguous prev_hash links, valid chain, every record sealed. That is
tests/test_locks.py, not a claim.
Backends
| Backend | Used for |
|---|---|
SqliteBackend |
Real applications (durable, queryable) |
PostgresBackend |
Multi-service setups, shared/remote storage |
JsonlBackend |
Simple logs, git-friendly, streaming-friendly |
MemoryBackend |
Short-lived processes, tests |
PostgresBackend takes a DSN (asyncpg) and stores the same record shape; v0.1
SQLite databases are migrated in place on first open (the key_id column is added).
Verify from the CLI
# format is auto-detected from the extension
python -m auditchain verify audit.sqlite
auditchain verify audit.jsonl --seal-key-file seal.key --expected-count 1000
auditchain verify audit.sqlite --checkpoint anchors/audit.checkpoint
# write an anchor after each batch (e.g. in CI/cron)
auditchain checkpoint audit.sqlite --output anchors/audit.checkpoint --seal-key-file seal.key
Example output when the log was tampered with:
$ python -m auditchain verify audit.jsonl
FAILED at seq 1: hash mismatch: the record was modified
Exit code 0 on success, 1 when the chain is broken, 2 on usage/file errors —
so it drops straight into CI.
Security model — be honest about limits
- Detected: modification of any record, insertion, reordering, removal of middle
records, sequence gaps, unknown key ids, count mismatches (with
--expected-count), and tail truncation (with a--checkpointanchor orexpected_count). - Not detectable from the chain alone, without an anchor: removal of the last
records. Keep a
checkpointoutside the log's trust boundary, or passexpected_counttoverify(). - A checkpoint's Merkle root pins the content of everything behind it, not just the
tail hash: a log that was rewritten, extended or truncated afterwards fails with
merkle root mismatch, even if every hash in it was recomputed with the real key. - An Ed25519-signed checkpoint is verified with the public key; its HMAC (when the log is also sealed) is not asked for, because it covers the same message and is the weaker signature.
- Inclusion proofs prove membership, not freshness. A proof only says "this record is in the log with this root". An old root stays valid forever — the anchor's timestamp and what you do with it are yours to manage.
- Single writer at a time: the chain must be serialized.
logging.Handlerwithbackground=Truefunnels a process's threads through one worker; for several processes passlock_path=/lock=(advisory, one machine) or a lock that lives with the data (Postgres advisory lock, Redis) across machines. - Without a
seal_key, records are integrity-protected, not authenticated — an attacker who can rewrite the log can re-seal it. - HMAC also fails against an attacker who holds the seal key: they can rewrite records
and recompute the chain. Only Ed25519 signatures (
signing_key) survive that, because verification needs just the public key. - Key rotation only helps if you control the keyring. Store retired keys safely; losing a key means the records sealed with it fail verification.
Beyond the basics
Read the full argument — threat model, honest limits, and when to anchor digests — in Why your audit log needs a hash chain or on DEV Community.
Handing this to an auditor? Verifying an audit log you do not own is the operator/auditor workflow end to end: what to publish, what a failure means, and what none of it proves.
خلاصهٔ فارسی
auditchain یک کتابخانهٔ پایتونی برای لاگ حسابرسیِ ضدتغییر است. هر رکورد با هشِ
رکورد قبلی زنجیر میشود (و در صورت دادن seal_key با HMAC-SHA256 امضا میگردد)،
بنابراین هر تغییر بعدی — ویرایش، جابهجایی، حذف یا درج — زنجیره را میشکند و
verify دقیقاً نشان میدهد کجا. بدون وابستگی، async-first؛ بکاندهای
SQLite/JSONL/Postgres؛ چرخش کلید HMAC با keyring؛ لنگر امضاشده (checkpoint) برای
تشخیص بریدهشدن انتهای زنجیره؛ امضای Ed25519 روی رکوردها (اختیاری، auditchain[ed25519])
تا حسابرس فقط با کلید عمومی بتواند لاگ را تایید کند و خودش قادر به جعل نباشد؛ و اثبات
مرکل: با یک ریشهٔ مورد اعتماد و ~log₂n هش میتوان ثابت کرد یک رکورد مشخص عضو همین
لاگ است، بدون افشای بقیهٔ رکوردها؛ لنگر (checkpoint) را هم میتوان با Ed25519 امضا کرد
تا حسابرس بیرونی فقط با کلید عمومی، خودِ لنگر را تایید کند؛ و یک logging.Handler آماده که با اضافهکردنش به
لاگرهای موجود، همان logger.info(...)هایی که از قبل مینویسید به رکورد حسابرسی
تبدیل میشوند (نوشتن در ترد جداگانه، پس مسیر درخواست کند نمیشود)؛ و نوشتن امن از چند
پروسه با یک قفل فایل (lock_path=) که پیش از هر append، انتهای زنجیره را از استوریج
دوباره میخواند. checkpoint ریشهٔ مرکل را هم امضا میکند و CLI با
کد خروج مناسب CI کار میکند (کد ۱ یعنی زنجیره شکسته یا اثبات نامعتبر).
License
MIT — see LICENSE.
Made ❤️ by Mohammad — @llllxyz
Release files for auditchain 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| auditchain-0.3.0.tar.gz | 66.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| auditchain-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 112.5 kB
Release files / auditchain-0.3.0.tar.gz
| Download URL | auditchain-0.3.0.tar.gz |
|---|---|
| Size | 66.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cb437e5a5f09325a6d654e5c13a0f4a0cbcdad523323d9466e7b71771a3baef1
|
|
BLAKE2b-256 checksum How to use checksums |
fd7fcd9c903df7196a088df159c1d55c0a08832bee5f8bf606a41780baa56bf5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / auditchain-0.3.0-py3-none-any.whl
| Download URL | auditchain-0.3.0-py3-none-any.whl |
|---|---|
| Size | 45.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7cafc7cba6584df4f33b3a4428bdf8dc7d330c5a3689e143a475b22a4e15cea3
|
|
BLAKE2b-256 checksum How to use checksums |
14199dbee11198f883dd33c26581d53191ee426f69f107dd66466bff289b60aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log