Immutable CloudTrail Auditing via AWS QLDB & S3 Object Lock
one event, two independent proofs it wasn't altered
CloudTrail
event delivered
S3 Object Lock
Compliance mode, 7yr
QLDB ledger
chained digest
"Immutable" gets used loosely. CloudTrail logs to S3 are durable and, with the right settings, tamper-evident, but tamper-evident and tamper-proof are different guarantees, and which one you need depends on what you're defending against: an external attacker, or an insider with admin credentials on the logging account itself. This is the architecture for the second case, which is what most financial and enterprise compliance regimes actually require, plus where a simpler setup is genuinely enough.
What CloudTrail already gives you
Start with what's built in, because a lot of implementations skip it and reach straight for custom infrastructure they didn't need yet. CloudTrail log file validation, once enabled, generates a digest file every hour containing SHA-256 hashes of every log file delivered in that period, and the digest itself is signed with an RSA key AWS controls:
aws cloudtrail update-trail \
--name org-trail \
--enable-log-file-validation
# later, to verify a range hasn't been tampered with:
aws cloudtrail validate-logs \
--trail-arn arn:aws:cloudtrail:us-east-1:111122223333:trail/org-trail \
--start-time 2026-08-01T00:00:00Z \
--end-time 2026-08-02T00:00:00ZIf someone modifies or deletes a delivered log file, validate-logs will say so: the digest chain breaks. This alone satisfies a meaningful chunk of audit requirements. It does not stop an attacker with s3:DeleteObject on the log bucket from deleting the files entirely, digest included; it only guarantees you'll *know* if that happened, assuming the digest files themselves survive.
Layer one: S3 Object Lock in Compliance mode
This is the actual tamper-*proof* layer, and it's simpler than most teams expect. Object Lock in Compliance mode means nobody (including the account root user) can delete or overwrite an object before its retention period expires. Not "can't without special permission." Cannot, full stop, even via AWS Support.
aws s3api create-bucket --bucket org-cloudtrail-logs --object-lock-enabled-for-bucket
aws s3api put-object-lock-configuration \
--bucket org-cloudtrail-logs \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": { "DefaultRetention": { "Mode": "COMPLIANCE", "Years": 7 } }
}'Point CloudTrail's log delivery at this bucket and you're done with the "can a compromised admin delete the evidence" problem: they structurally cannot, for 7 years, no exceptions. This is frequently sufficient on its own. If your requirement is "prove logs weren't altered or deleted," Object Lock plus log file validation covers it, and you can stop reading here for most use cases.
Layer two: when you actually need QLDB
QLDB earns its complexity when the requirement isn't just "logs weren't tampered with" but "here is a specific, independently queryable, cryptographically verifiable ledger of a defined set of critical actions": think financial transaction approvals, access grants to a specific regulated dataset, or key-rotation events. QLDB's journal is an append-only, cryptographically chained ledger with a built-in digest you can request and verify against any document's revision history.
The pattern: CloudTrail keeps flowing to the Object-Locked S3 bucket as the complete record. In parallel, an EventBridge rule filters for the specific high-value actions (say, AssumeRole into a production-admin role, or any kms:ScheduleKeyDeletion) and writes a structured entry into QLDB:
import boto3, hashlib, json
from datetime import datetime, timezone
qldb = boto3.client("qldb-session")
def record_critical_action(event: dict) -> None:
entry = {
"event_id": event["detail"]["eventID"],
"action": event["detail"]["eventName"],
"actor": event["detail"]["userIdentity"]["arn"],
"timestamp": datetime.now(timezone.utc).isoformat(),
"source_ip": event["detail"]["sourceIPAddress"],
}
# QLDB PartiQL insert: the ledger itself maintains the hash
# chain and Merkle tree internally; you don't compute it by hand.
statement = "INSERT INTO CriticalActions ?"
qldb.execute_statement(Statement=statement, Parameters=[entry])The reason this beats "just write to a normal DynamoDB table with a hash column": QLDB's digest is independently verifiable without trusting QLDB itself at query time. get_digest returns a Merkle root you can store externally (print it, email it, whatever, as long as it leaves AWS), and get_revision returns a proof you can verify locally against that stored root, so even if an attacker somehow modified data inside QLDB, the stored digest from before the modification would no longer match, and you'd catch it without needing to trust the live system to tell you the truth about itself.
digest_response = qldb_client.get_digest(Name="compliance-ledger")
digest = digest_response["Digest"]
# Store this digest somewhere outside AWS's control: a separate
# organization's system, a physical printout for the board, a
# different cloud provider entirely. The verification is only as
# strong as the independence of where the digest is archived.The honest trade-off
QLDB adds real operational cost: it's a service your team now has to understand, monitor, and query correctly, and AWS's own PartiQL query patterns for revision history aren't intuitive on the first pass. Before adding it, answer one question honestly: is there a specific auditor, regulator, or contract clause that requires cryptographic non-repudiation of individual actions, separate from "prove the log files weren't altered"? If the answer is "no, we just want to be thorough," Object Lock plus log file validation is the correct amount of infrastructure. Add QLDB when a specific requirement names it, not because the architecture looks more serious with it in the diagram.
Building something like this?
Tell us what you're working on, we'll scope it together.
More from the blog
Mapping CUI Data Flows in AWS Multi-VPC Environments
Boundary isolation, KMS envelope encryption, GuardDuty, and how the pieces line up against NIST SP 800-171 and CMMC.
Writing Automated Scanners to Audit Network Topology Diagrams
Catching open ports, unencrypted transit routes, and single points of failure in exported topology diagrams before a single resource gets provisioned.
