The situation
A financial services platform with three classes of data that need write-once-read-many (WORM) storage.
- Financial transactions, posted transactions on customer accounts. Written at the moment the transaction commits; must be retained for 7 years from the transaction date (regulatory). Read rarely, typically only during audits or customer dispute investigations. Volume: ~50 GB/day, cumulative ~130 TB over 7 years.
- Authenticated audit logs, the platform’s tamper-evident audit log: every user action, every API call, every admin operation. Written continuously, 5 GB/day. Must be retained for 3 years from the log-event date; some entries (admin operations) retained for 7 years.
- Customer contracts, signed PDFs, one per customer engagement. Written at signature time; retained for the life of the relationship plus 7 years. Volume low (~500 MB/day), but each object is critical evidence in the event of dispute.
Today these live in regular S3 buckets with versioning enabled and a bucket policy that denies s3:DeleteObject to all principals except a break-glass role. Which works right up until someone pushes a Terraform change that rewrites the bucket policy, or a pipeline’s KMS key gets disabled, or any of the dozen other ways bucket policies fail in practice.
The requirement is stronger than “hard to delete”: it has to be impossible to delete before retention expires, without a multi-party process that leaves an audit trail.
What actually matters
Before picking a mechanism it’s worth naming what regulatory immutability actually demands, because most “don’t delete this” patterns deliver something weaker.
First: who, exactly, is prevented from deleting? A bucket policy denying delete works until someone modifies the bucket policy; a deny that applies “to everyone except a break-glass role” is a sentence that ends “until the break-glass role is misused”. Regulatory WORM means no one, including the root account, including an attacker who has compromised the root account, can remove the object before its retention expires. Anything weaker is defence-in-depth, not the baseline.
Second: the immutability has to live in the data plane, not in policy. A policy is a mutable object; a policy is what protects nothing once an attacker can write policies. The mechanism that meets the audit ask has to be a service-enforced property of the object itself, evaluated on every delete or overwrite attempt, independent of whatever bucket policy is in effect at the time.
Third: per-object granularity matters. Different record types have different retention clocks, 3 years for routine audit logs, 7 years for transactions, “life of relationship plus 7 years” for contracts. A bucket-level “nothing in this bucket can be deleted” is too coarse; the mechanism has to carry a retention date per object (or a sensible default that applies to every object on write).
Fourth: a hold mechanism that’s separate from the retention clock. Litigation pending means specific objects can’t be touched even when their retention period would otherwise expire during the case. The mechanism needs a way to freeze specific object versions independently of retention, and a way to release that freeze when the case closes.
Fifth: the immutability has to survive cross-Region replication. The destination Region copy is only useful if it’s also immutable; otherwise an attacker who can’t delete the primary just deletes the replica. The mechanism has to replicate retention configurations and the destination has to enforce them the same way.
Sixth: cost shape across 7 years. Retention measured in years on terabytes of data is expensive on hot storage and cheap on cold-archive tiers. The mechanism has to compose with archival storage classes, the same immutability property has to survive the transition from hot to cold, because re-uploading petabytes to “fix” a storage-class change defeats the point.
Seventh: defensive defaults at write time. A writer that has to remember to set retention headers will eventually forget. The mechanism has to support a bucket-level default that applies retention to every new object automatically, so the writer can’t accidentally opt out.
Eighth: the default-retention pitfall. A bucket with default retention applies that retention to every new object, whether the writer intended it or not. If the bucket later serves a workload that expected to overwrite objects, those writes succeed but the objects are stuck for the retention period. Separate buckets for retained data and working data is the defensive pattern; mixing both in one bucket is where operational incidents live.
What we’ll filter on
- Retention guarantee, can anyone shorten or lift the retention before expiry?
- Retention granularity, per-object, per-bucket, or both?
- Override behaviour, is there a privileged-bypass path, or is the retention truly absolute?
- Legal hold, supported?
- Storage class compatibility, does it work with archival tiers?
- Cross-Region replication, do the retention configs replicate?
The immutability landscape
-
S3 Object Lock, compliance mode. Retention cannot be shortened or lifted until expiry, by any principal including root. The absolute WORM guarantee. Paired with default bucket retention so every object inherits it automatically. Right for regulatory compliance workloads where the audit requirement is “no one, ever”.
-
S3 Object Lock, governance mode. Retention can be shortened or lifted by principals with
s3:BypassGovernanceRetention, a separate IAM permission that can be tightly scoped and audited. Right for internal WORM policies where breakglass is a documented process rather than a mistake. -
S3 Versioning + Bucket Policy Deny. The “poor man’s Object Lock”: versioning on, bucket policy denies
s3:DeleteObjectands3:DeleteObjectVersionto everyone. Works until someone modifies the bucket policy. Not a compliance-grade guarantee because the policy is a mutable object, but useful as defence-in-depth on top of Object Lock. -
S3 MFA Delete. Deletion of specific object versions requires MFA from the root account. Adds a hurdle but not an absolute guarantee; root account can still delete with MFA. Legacy feature; Object Lock is the modern answer.
-
AWS Backup with Vault Lock. Different service; covers EBS, RDS, DynamoDB, EFS, FSx, and S3 (S3 Backup variant). Vault Lock in compliance mode is the equivalent guarantee for backup recovery points. Complementary to Object Lock: Object Lock is the source-of-truth immutability, AWS Backup Vault Lock is the durable backup copy.
-
S3 Glacier Vault Lock. Pre-S3-integrated Glacier’s own immutability. Locks a vault’s access policy; once locked, it cannot change. For new workloads, S3 Object Lock with Glacier storage class is simpler than Glacier Vaults directly.
Side by side
| Option | Absolute guarantee | Granularity | Legal hold | Glacier compatible | Replication |
|---|---|---|---|---|---|
| S3 Object Lock, compliance mode | ✓ (no one, not even root) | per-object | ✓ | ✓ (all classes) | ✓ |
| S3 Object Lock, governance mode | ✗ (bypass permission) | per-object | ✓ | ✓ | ✓ |
| Versioning + bucket policy deny | ✗ (policy is mutable) | per-bucket | ✗ | ✓ | ✓ |
| MFA Delete | ✗ (root with MFA) | per-object | ✗ | ✓ | ✗ (no MFA check in CRR) |
| AWS Backup Vault Lock | ✓ (compliance mode) | per-recovery-point | ✓ (Legal Hold) | N/A | cross-Region copy |
| Glacier Vault Lock | ✓ (once locked) | per-vault | ✗ | N/A | cross-vault |
Reading the table: only compliance-mode Object Lock and AWS Backup Vault Lock in compliance mode deliver the “no one, ever” guarantee that regulatory WORM requires. Everything else is defence-in-depth, not baseline compliance.
The Object Lock bucket design
The design, in depth
Three buckets, one per record class. Separate buckets rather than shared, because default retention differs per class and mixing concerns in one bucket is how accidents happen. Each bucket is created with Object Lock enabled and versioning on from the start:
aws s3api create-bucket \
--bucket fin-txn-prod \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1 \
--object-lock-enabled-for-bucket
aws s3api put-object-lock-configuration \
--bucket fin-txn-prod \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": {
"Mode": "COMPLIANCE",
"Days": 2555
}
}
}'
Every subsequent PUT into fin-txn-prod gets a 2,555-day (7-year) retention in compliance mode automatically. Writers don’t need to set headers; they can’t accidentally opt out.
Block Public Access enabled at the account level. aws s3control put-public-access-block with all four flags on: BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets. Compliance-retained data does not belong on the public internet, not even accidentally.
SSE-KMS with a customer-managed key. Each bucket has default encryption with a customer-managed KMS key. The key policy scopes kms:Decrypt to specific IAM roles (the application readers, the audit team) and denies it otherwise. Bucket encryption default means every PUT is encrypted; the key policy controls who can read.
Cross-Region replication to us-east-1. A replication rule copies every object to a destination bucket in us-east-1, also Object Lock-enabled from creation:
aws s3api put-bucket-replication --bucket fin-txn-prod \
--replication-configuration file://replication.json
The replication config specifies a destination bucket, a replication role, and DeleteMarkerReplication: Enabled. Object Lock retention configurations replicate with the objects; the destination bucket’s objects have the same retain-until dates as the source. CRR on Object Lock-enabled buckets requires specific IAM permissions and the destination bucket must also have Object Lock enabled, the replication explicitly checks this.
Lifecycle transition to Glacier Deep Archive. Object Lock retention periods persist across storage class transitions. A lifecycle rule moves objects to Glacier Deep Archive after 90 days:
{
"Rules": [{
"ID": "deep-archive-after-90d",
"Status": "Enabled",
"Filter": {},
"Transitions": [{
"Days": 90,
"StorageClass": "DEEP_ARCHIVE"
}]
}]
}
After 90 days, a 50 GB/day transaction firehose has accumulated ~4.5 TB on Standard at $0.023/GB-month = ~$104/month. Post-transition, those 4.5 TB at Deep Archive’s $0.001/GB-month = ~$4.50/month. 7 years of transactions in Deep Archive ≈ 130 TB × $0.001 = $130/month, compared to ~$3,000/month on Standard. Object Lock retention is preserved; retrieval takes hours when needed but retrieval from a 7-year-retained audit record is not a low-latency use case.
Legal Hold workflow. A specific IAM role (ComplianceLegalHoldAdmin) has s3:PutObjectLegalHold and s3:GetObjectLegalHold. When litigation is pending, a process places legal holds on matching objects:
aws s3api put-object-legal-hold \
--bucket contracts-prod \
--key 2024/customer-42-contract.pdf \
--version-id HkQw... \
--legal-hold Status=ON
The object version is now frozen regardless of retention period. Releasing the hold when litigation closes returns the object to its retention-based lifecycle.
What breaks, and what doesn’t
It’s worth naming what compliance-mode Object Lock is resilient to, because that’s the whole point.
- Bucket policy modification. Object Lock is enforced by the service, not by the bucket policy. A corrupted or deleted bucket policy doesn’t affect retention.
- Root credential compromise. Even the root account cannot delete or shorten compliance-mode retention before the retention date.
- KMS key disablement. Disabling the KMS key prevents reads but doesn’t unlock retention; the object version remains in the bucket, just unreadable until the key is re-enabled.
- Region compromise. The CRR copy in the second Region is independently immutable. If
eu-west-1is unreachable or compromised,us-east-1has the same retained objects. - Account deletion. The retention survives account closure to the extent AWS holds the data; the protection is against active malicious or accidental deletion, not “account nuked via terminal closure process” which has its own AWS-managed timelines.
What does break:
- Writes with the wrong retention. A writer that sets its own retention header to a shorter date overrides the default retention downward, as long as the service permits it (compliance mode allows longer per-object retention than the default, but not shorter for default-applied retentions). The safer pattern is to have the bucket’s default retention be the minimum and disallow writers from setting retention headers via bucket policy.
- Forgetting to enable Object Lock at bucket creation. Object Lock cannot be enabled on an existing bucket without contacting AWS Support. Plan for it at bucket creation; migrating later is painful.
A worked write and delete attempt
A pipeline lands a transaction record and then tries to delete it an hour later (a bug, but a realistic one):
# Write
$ aws s3api put-object --bucket fin-txn-prod --key 2026/04/txn-12345.json \
--body transaction.json
{
"ETag": "\"abc...\"",
"VersionId": "HkQw...",
"ServerSideEncryption": "aws:kms",
"ObjectLockMode": "COMPLIANCE",
"ObjectLockRetainUntilDate": "2033-04-07T00:00:00Z"
}
# Try to delete (within retention)
$ aws s3api delete-object --bucket fin-txn-prod \
--key 2026/04/txn-12345.json --version-id HkQw...
An error occurred (AccessDenied) when calling the DeleteObject operation:
Access Denied (Object is under Object Lock COMPLIANCE retention)
# Try to shorten retention
$ aws s3api put-object-retention --bucket fin-txn-prod \
--key 2026/04/txn-12345.json --version-id HkQw... \
--retention '{"Mode":"COMPLIANCE","RetainUntilDate":"2027-04-07T00:00:00Z"}'
An error occurred (AccessDenied) when calling the PutObjectRetention operation:
Access Denied (cannot decrease compliance retention)
# Try as root (same result)
$ aws s3api delete-object --bucket fin-txn-prod \
--key 2026/04/txn-12345.json --version-id HkQw...
An error occurred (AccessDenied) when calling the DeleteObject operation:
Access Denied (Object is under Object Lock COMPLIANCE retention)
The retention holds. No permission, no role, no credential, no amount of effort removes the object before 2033-04-07. That’s the guarantee.
What’s worth remembering
- Object Lock is enabled at bucket creation and cannot be added later. Plan for it from the start; migrating later requires AWS Support.
- Compliance mode is absolute; governance mode has a breakglass. Compliance retention cannot be shortened or lifted by anyone, including root, before expiry. Governance allows specific IAM-permissioned principals to override; use for internal WORM, not for regulatory compliance.
- Default retention is the safer configuration. Setting default retention on the bucket means writers don’t need to set per-object headers and can’t accidentally write without retention.
- Legal Hold is independent of retention. Freezes a specific object version regardless of retention period; released explicitly. Covers the litigation-pending case.
- Versioning is required. Object Lock only functions on versioned buckets; this is enforced.
- CRR replicates retention configs. Destination bucket must also be Object Lock-enabled; retentions replicate with the objects. The replica is as immutable as the source.
- Storage class transitions preserve Object Lock. Move to Glacier Deep Archive for 7-year retention at $0.001/GB-month; retention survives the transition. Retrieval takes hours, which is acceptable for audit-only data.
- S3 Object Lock, AWS Backup Vault Lock, and Glacier Vault Lock are three separate features. Object Lock is for S3 objects; Vault Lock (Backup) is for backup recovery points; Glacier Vault Lock is the pre-S3-integrated Glacier mechanism. Compliance-mode variants on each give the absolute guarantee; pick the one whose scope matches the data.
Seven-year WORM storage at regulatory grade is a single S3 feature with three thoughtful defaults: compliance mode, default retention, Block Public Access. Add CRR for Region durability and lifecycle transitions for cost. The result is data that cannot be deleted before its time, no matter who asks.