Audit Logging in Python Applications

An audit log answers a narrow question with a high standard of proof: who did what, to which resource, when, and with what result. It is evidence rather than diagnostics, and its requirements are nearly the opposite of operational logging — nothing may be sampled or dropped, records must be attributable and trustworthy, and retention is set by someone outside engineering. This page covers building that path in a Python service: the event schema, a dedicated logger, explicit actors, tamper evidence and durable writes. It is a task article under logging security and compliance, part of the Python logging fundamentals and structured data section.

How a chain makes tampering visible Four audit records are drawn in sequence. Each contains its fields — actor, action, target, outcome, timestamp — and a hash computed over those fields together with the previous record's hash. The first record's hash feeds into the second, the second's into the third, and so on. Below, the same chain is shown after someone has deleted the third record. When a verification job recomputes the hashes, the fourth record's stored previous-hash no longer matches the hash of the record now preceding it, and verification fails at exactly that point. The note records that chaining does not prevent alteration — anyone with write access could rewrite the whole chain — but that it makes alteration detectable, and that storing periodic chain checkpoints somewhere the writer cannot modify closes that gap. each record carries the previous record's hash #1 grant role h1 = H(fields) #2 export data h2 = H(fields, h1) #3 revoke key h3 = H(fields, h2) #4 approve h4 = H(…, h3) someone deletes record #3 #1 ok #2 ok missing #4 expects h3 finds h2 — FAIL chaining makes tampering visible; it does not prevent it periodic checkpoints stored where the writer cannot modify them close the rewrite-everything gap
Removing or altering any record breaks every hash after it. Verification finds the break, which is what turns a log into evidence.

Prerequisites

pip install "python-json-logger>=2.0.7,<4.0.0"

Implementation

Step 1 — Define the events and their fields first. An audit log is only as useful as its schema. Before writing code, list the actions that must be recorded — permission changes, data exports, credential rotations, approvals, account deletions — and fix the fields every record carries. Consistency matters more than richness: an auditor querying "every action by this actor last quarter" needs the actor field to have the same name and meaning everywhere.

from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone

@dataclass(frozen=True)
class AuditEvent:
    action: str                       # "permission.grant", "data.export", …
    actor_id: str                     # who — a user or a service identity
    actor_type: str                   # "user" | "service"
    target_type: str                  # "user", "api_key", "dataset", …
    target_id: str
    outcome: str                      # "success" | "denied" | "error"
    reason: str | None = None         # required for some actions
    source_ip: str | None = None
    request_id: str | None = None
    at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

Step 2 — Emit through a dedicated logger that does not propagate. A separate logger with its own handler and propagation disabled keeps audit events out of the operational stream entirely. They cannot be sampled away by a noisy-logger filter, raised above the configured level by an operator quietening things down, or deleted after seven days by the operational retention policy.

import logging

audit_logger = logging.getLogger("audit")
audit_logger.setLevel(logging.INFO)
audit_logger.propagate = False        # 1. never into the operational handlers

Step 3 — Pass the actor explicitly. Ambient context — a context variable set by authentication middleware — is convenient and is also the most common source of misattributed audit records. A record written from a background thread without the context propagated names no actor, or the wrong one. Requiring the actor as an argument makes a missing actor a programming error at the call site rather than a silent gap in the evidence.

def record(event: AuditEvent) -> None:
    if not event.actor_id:
        raise ValueError("audit events must name an actor")
    audit_logger.info(event.action, extra={"audit": asdict(event)})

record(AuditEvent(
    action="permission.grant", actor_id=admin.id, actor_type="user",
    target_type="user", target_id=target.id, outcome="success",
    reason="ticket SUP-4812", source_ip=request.client_ip,
    request_id=request.id))

Step 4 — Chain records so alteration is detectable. Each record includes a hash of its own canonical content and the previous record's hash. Altering, inserting or deleting any record breaks every subsequent hash, which a verification job detects. The chain lives per writer — per process or per stream — so concurrent writers do not interleave and invalidate each other's chains.

import hashlib
import json
import os
import threading

class ChainingAuditHandler(logging.Handler):
    """Writes one JSON line per event, each linked to the previous by hash."""

    def __init__(self, path: str, stream_id: str):
        super().__init__()
        self._fh = open(path, "a", buffering=1, encoding="utf-8")
        self._stream_id = stream_id
        self._prev = "0" * 64
        self._lock = threading.Lock()

    def emit(self, record: logging.LogRecord) -> None:
        body = dict(record.audit, stream=self._stream_id)
        with self._lock:
            canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
            digest = hashlib.sha256((self._prev + canonical).encode()).hexdigest()
            line = json.dumps({**body, "prev_hash": self._prev, "hash": digest},
                              sort_keys=True)
            self._fh.write(line + "\n")
            self._fh.flush()
            os.fsync(self._fh.fileno())        # 2. durable before the action returns
            self._prev = digest

Expected Output: records linked by hash.

{"action": "permission.grant", "actor_id": "u_12", "actor_type": "user", "at": "2026-09-18T10:21:07.412Z", "hash": "9c1f…a2", "outcome": "success", "prev_hash": "4e07…d1", "reason": "ticket SUP-4812", "stream": "api-3", "target_id": "u_88", "target_type": "user"}

Step 5 — Decide which actions must fail without a record. For actions whose audit trail is a hard requirement, the write must succeed before the action completes: performing a permission change without recording it is precisely the gap an audit exists to prevent. Writing synchronously with fsync, as above, and letting a failure propagate to the caller enforces that. For lower-stakes events a durable asynchronous path is acceptable, and the choice should be made per action and written down.

Step 6 — Verify the chain continuously. A chain nobody checks is not evidence. A scheduled job that recomputes hashes over each stream and compares them with the stored values, alerting on any mismatch, turns tamper evidence into tamper detection.

Two paths from the same service A single Python service emits two streams. The operational stream goes through the root logger to standard output, is collected by an agent, may be rate limited or sampled when noisy, is retained for about a week, and is readable by the service's engineers. The audit stream goes through a dedicated logger with propagation disabled to a handler that writes synchronously and durably, with each record chained to the previous one. It is shipped to write-once storage, never sampled, retained for the period policy requires, and readable only by a restricted group whose own access is logged. The note records that the two streams share nothing after the log call, which is the property that lets each meet its own requirements. one service, two streams that share nothing after the log call operational root logger stdout may be sampled ~7 days service engineers audit audit logger fsync + chain never sampled as policy says restricted, audited separation is the whole design an operator quietening a noisy logger cannot suppress audit events a seven-day operational retention cannot delete them · a sampler cannot thin them
The two streams diverge at the log call and never meet again. That separation is what lets operational logging be cheap and audit logging be trustworthy.

What makes an audit trail trustworthy

The technical controls above are necessary and not sufficient. An audit log earns trust through four properties, and each has an organisational side as well as a technical one.

Completeness. Every action in scope produces a record, including failed and denied attempts. A trail that records successful permission grants but not denied ones cannot show that an attacker tried and failed. Recording outcomes other than success is easy to forget and essential.

Attribution. Every record names who acted, reliably. Service accounts acting on behalf of users need both identities recorded — the service and the user it acted for — or an automated action becomes unattributable. This is the reason step 3 insists on explicit actors.

Integrity. Records cannot be altered or removed without detection. Hash chaining provides detection within a stream; storing periodic checkpoints — the latest hash, every hour — in a location the writing service cannot modify prevents the whole chain from being rewritten consistently. Write-once object storage is the usual destination for both.

Separation of duties. The people and systems that perform audited actions should not be able to modify the audit trail. A service that writes audit records and can also delete them has an audit trail that depends on the service's good behaviour. Granting the writer append-only access, and restricting deletion to a lifecycle policy nobody can change unilaterally, removes that dependency.

An audit log that satisfies all four holds up to scrutiny; one that satisfies the technical properties but lets the writing service delete its own records does not, however sophisticated its hashing. The connection to broader retention decisions is covered in log retention and tiering strategy.

Multiple processes and multiple replicas

A real service runs many processes, and the chain design has to accommodate that without making writers contend for a single global sequence.

The approach above gives each writer its own stream identifier and its own chain. A prefork server with four workers across six replicas produces twenty-four independent chains, each internally ordered and each verifiable on its own. This avoids any coordination between writers — no shared lock, no central sequence number — which matters because audit writes happen on the request path and must not become a bottleneck.

The cost is that there is no single global order across streams. Establishing the order of two events recorded by different workers relies on their timestamps, which is adequate for almost every audit question — who changed this permission, and when — and inadequate only for questions about sub-millisecond ordering across processes, which audit trails rarely need to answer.

The stream identifier must be stable for the life of the writer and unique across the fleet, and the verification job must know the full set of streams so it can notice when one disappears entirely. A stream that stops producing records mid-chain is either a process that ended — which should coincide with a termination — or a chain that was truncated, and distinguishing the two is one more thing the verification job can check by comparing stream end times with process lifetimes.

Application log or audit trail A table comparing ordinary application logs with an audit trail on five properties. Purpose: application logs support debugging and operations; the audit trail records who did what for accountability. Retention: application logs are kept for days to weeks; audit records for the period an obligation requires, often years. Sampling and rate limiting: acceptable for application logs, never for audit records. Mutability: application logs may be rotated and deleted freely; audit records are append-only, ideally hash-chained. Access: application logs are broadly readable by engineers; audit trails are restricted and their reads are themselves audited. The note says these differences are why the two should be separate streams. property application logs audit trail purpose debugging, operations accountability retention days to weeks as long as required, often years sampling acceptable never mutability rotated and deleted freely append-only, hash-chained access broad, engineers restricted, reads audited every row differs — which is why they belong in separate streams
An audit trail differs from a log on every property that matters. Mixing them weakens both.

Configuration options

Property Mechanism Note
Separation dedicated logger, propagate=False never mixed with operational output
Schema a frozen dataclass per event fixed names and types
Attribution explicit actor_id argument fails loudly when missing
Integrity hash chain per stream detects alteration and deletion
Checkpoints latest hash to write-once storage prevents consistent rewriting
Durability synchronous write with fsync for actions that require a record
Verification scheduled chain check turns evidence into detection
Retention lifecycle policy on the destination as the governing rule requires

Verification

The verification job recomputes each stream's chain and reports the first break.

import hashlib, json

def verify(path: str) -> int | None:
    prev = "0" * 64
    for lineno, line in enumerate(open(path, encoding="utf-8"), 1):
        rec = json.loads(line)
        stored_hash, stored_prev = rec.pop("hash"), rec.pop("prev_hash")
        canonical = json.dumps(rec, sort_keys=True, separators=(",", ":"))
        if stored_prev != prev or \
           hashlib.sha256((prev + canonical).encode()).hexdigest() != stored_hash:
            return lineno
        prev = stored_hash
    return None

print(verify("/var/log/audit/api-3.jsonl") or "chain intact")

Expected Output: an intact chain, or the line at which it breaks.

chain intact
14207

Common mistakes

Audit events in the operational stream. Error signature: audit records missing because a filter, a level change or a retention policy removed them. Root cause: shared path. Remediation: a dedicated logger with its own destination.

Recording only successes. Error signature: no trace of an attacker's denied attempts. Root cause: audit calls only on the success path. Remediation: record denied and failed outcomes too.

Actors from ambient context. Error signature: records with an empty or wrong actor from background work. Root cause: context not propagated. Remediation: require the actor as an argument.

The writer can delete its own records. Error signature: an audit trail whose integrity depends on the service behaving. Root cause: full write access to the destination. Remediation: append-only access for the writer, deletion by lifecycle policy only.

A chain nobody verifies. Error signature: tampering discovered months later, or never. Root cause: evidence without detection. Remediation: a scheduled verification job with an alert.

Frequently Asked Questions

How is an audit log different from an application log?

An application log helps engineers understand behaviour and can be sampled, dropped under load and deleted after days. An audit log is evidence of who did what, and must be complete, trustworthy and kept for as long as a policy or regulation requires. The two have opposite requirements, which is why they need separate paths.

What should every audit record contain?

The actor who performed the action, the action itself, the target resource, the outcome, and the time — plus enough context to interpret it, such as the source of the request and a reason where one is required. Each field should have a fixed name and type across the whole application.

Should an action fail if its audit record cannot be written?

For actions whose audit trail is a hard requirement — permission changes, data exports, financial approvals — yes. Performing the action without a record creates exactly the gap the audit exists to prevent. For lower-stakes events, recording asynchronously with a durable queue is usually sufficient.

What does hash chaining protect against?

Undetected alteration, insertion or deletion. Each record includes a hash covering its own content and the previous record's hash, so changing or removing any record breaks every hash after it. It does not prevent tampering; it makes tampering visible, which is what an audit trail needs.