Centralise cloud audit logs in a separate tamper-resistant account
For engineers who need audit logs out of the workload account and into a harder-to-tamper destination. This walks through creating a dedicated log archive account, locking storage for retention, granting write-only delivery from source accounts, and proving logs arrive end to end.
TL;DR — Put audit logs in a dedicated log-archive account, not in the same account being audited. The key fix is: use a central object store bucket with object lock/immutability enabled at creation time, allow source accounts to write but not delete, and deny everyone except a break-glass role from changing retention or bucket policy. Reading time: ~5 min
Goal
When you finish, all cloud audit logs from one or more source accounts will be delivered into a dedicated log-archive account’s object storage bucket, retained under immutable/WORM rules, and readable only by your security or break-glass role; source-account admins will be able to write logs but not alter or delete them.
Prerequisites
- A dedicated cloud account/subscription/project to act as the log archive account
- Admin access in the log archive account and enough IAM rights in each source account to change audit-log export settings and create cross-account roles/policies
awsCLI v2.15+ if you are on AWS — check withaws --versionjq1.6+ — check withjq --version- A unique bucket name, for example
org-prod-audit-log-archive-123456 - Your organisation/account IDs for every source account
- A KMS key strategy decided up front: provider-managed keys are simpler; customer-managed keys need extra policy work
Steps
Step 1: Create the dedicated log archive bucket with immutability enabled
⚠️ Object Lock / immutable retention usually must be enabled when the bucket is created and cannot be turned on later. If you create the bucket without it, you will need a new bucket and a migration.
export AWS_REGION=us-east-1
export ARCHIVE_BUCKET=org-prod-audit-log-archive-123456
aws s3api create-bucket \
--bucket "$ARCHIVE_BUCKET" \
--region "$AWS_REGION" \
--object-lock-enabled-for-bucket
aws s3api put-public-access-block \
--bucket "$ARCHIVE_BUCKET" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
aws s3api put-bucket-versioning \
--bucket "$ARCHIVE_BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-object-lock-configuration \
--bucket "$ARCHIVE_BUCKET" \
--object-lock-configuration '{
"ObjectLockEnabled":"Enabled",
"Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":365}}
}'
You should see exit code 0 for all commands; aws s3api get-object-lock-configuration --bucket "$ARCHIVE_BUCKET" should return "Mode": "COMPLIANCE".
Step 2: Create a write-only bucket policy for audit log delivery
Replace the example account IDs with your real source accounts.
cat > bucket-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudTrailAclCheck",
"Effect": "Allow",
"Principal": {"Service": "cloudtrail.amazonaws.com"},
"Action": "s3:GetBucketAcl",
"Resource": "arn:aws:s3:::org-prod-audit-log-archive-123456"
},
{
"Sid": "AllowCloudTrailWriteFromSourceAccounts",
"Effect": "Allow",
"Principal": {"Service": "cloudtrail.amazonaws.com"},
"Action": "s3:PutObject",
"Resource": [
"arn:aws:s3:::org-prod-audit-log-archive-123456/AWSLogs/111111111111/*",
"arn:aws:s3:::org-prod-audit-log-archive-123456/AWSLogs/222222222222/*"
],
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control"
}
}
},
{
"Sid": "DenyDeletesToEveryone",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
"s3:PutObjectRetention",
"s3:BypassGovernanceRetention"
],
"Resource": [
"arn:aws:s3:::org-prod-audit-log-archive-123456",
"arn:aws:s3:::org-prod-audit-log-archive-123456/*"
],
"Condition": {
"StringNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/log-archive-breakglass"
}
}
}
]
}
JSON
aws s3api put-bucket-policy \
--bucket "$ARCHIVE_BUCKET" \
--policy file://bucket-policy.json
You should see no output and exit code 0; aws s3api get-bucket-policy --bucket "$ARCHIVE_BUCKET" | jq .Policy -r | jq .Statement[].Sid should list the three statements.
Step 3: Enable server-side encryption on the archive bucket
If you do not need customer-managed keys, use AES256. It avoids the common KMS policy failure where delivery can write to S3 but not encrypt.
aws s3api put-bucket-encryption \
--bucket "$ARCHIVE_BUCKET" \
--server-side-encryption-configuration '{
"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]
}'
You should see exit code 0; aws s3api get-bucket-encryption --bucket "$ARCHIVE_BUCKET" should return "SSEAlgorithm": "AES256".
Step 4: Create or update the trail in each source account to write to the archive bucket
Run this once per source account after authenticating to that account. This example creates a multi-region trail and includes global service events and management events.
export AWS_REGION=us-east-1
export ARCHIVE_BUCKET=org-prod-audit-log-archive-123456
export TRAIL_NAME=org-central-audit
aws cloudtrail create-trail \
--name "$TRAIL_NAME" \
--s3-bucket-name "$ARCHIVE_BUCKET" \
--is-multi-region-trail \
--include-global-service-events \
--enable-log-file-validation \
--region "$AWS_REGION" || true
aws cloudtrail update-trail \
--name "$TRAIL_NAME" \
--s3-bucket-name "$ARCHIVE_BUCKET" \
--is-multi-region-trail \
--include-global-service-events \
--enable-log-file-validation \
--region "$AWS_REGION"
aws cloudtrail start-logging \
--name "$TRAIL_NAME" \
--region "$AWS_REGION"
You should see JSON from create-trail or update-trail; start-logging returns no output and exit code 0.
Step 5: Lock down read access to the archive account only
Create a read role in the archive account for security staff and keep source accounts out of it.
cat > trust-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::999999999999:root"},
"Action": "sts:AssumeRole"
}]
}
JSON
aws iam create-role \
--role-name log-archive-reader \
--assume-role-policy-document file://trust-policy.json
cat > read-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject","s3:ListBucket"],
"Resource": [
"arn:aws:s3:::org-prod-audit-log-archive-123456",
"arn:aws:s3:::org-prod-audit-log-archive-123456/*"
]
}]
}
JSON
aws iam put-role-policy \
--role-name log-archive-reader \
--policy-name log-archive-read-s3 \
--policy-document file://read-policy.json
You should see role metadata JSON from create-role; put-role-policy returns no output and exit code 0.
Step 6: Wait for first delivery and inspect the object layout
Cloud audit delivery is not instant. On AWS CloudTrail, first files commonly take several minutes.
aws s3 ls "s3://$ARCHIVE_BUCKET/AWSLogs/" --recursive | tail -n 10
You should see keys shaped like AWSLogs/111111111111/CloudTrail/us-east-1/2026/10/30/...json.gz and matching .digest.gz files if log file validation is on.
Verify it works
Run these checks after 5-15 minutes.
aws cloudtrail get-trail-status \
--name org-central-audit \
--region us-east-1
aws s3 ls "s3://$ARCHIVE_BUCKET/AWSLogs/111111111111/CloudTrail/" --recursive | tail -n 5
aws s3api get-object-lock-configuration \
--bucket "$ARCHIVE_BUCKET"
Expected output shape:
{
"IsLogging": true,
"LatestDeliveryTime": "2026-10-30T12:34:56+00:00",
"LatestDigestDeliveryTime": "2026-10-30T12:35:20+00:00",
"StartLoggingTime": "2026-10-30T12:30:01+00:00"
}
And object listing like:
2026-10-30 12:34:58 8421 AWSLogs/111111111111/CloudTrail/us-east-1/2026/10/30/111111111111_CloudTrail_us-east-1_20261030T1230Z_abc123.json.gz
2026-10-30 12:35:20 1720 AWSLogs/111111111111/CloudTrail-Digest/us-east-1/2026/10/30/111111111111_CloudTrail-Digest_us-east-1_20261030T1230Z_def456.json.gz
If IsLogging is true but no objects arrive after 15 minutes, the bucket policy or encryption policy is wrong.
Common pitfalls
Bucket created without Object Lock
Mistake: creating the archive bucket first, then trying to enable immutability later.
Symptom: put-object-lock-configuration fails with an error shaped like:
An error occurred (InvalidBucketState) when calling the PutObjectLockConfiguration operation: Object Lock configuration cannot be enabled on existing buckets
Fix: create a new bucket with --object-lock-enabled-for-bucket and repoint the trails.
KMS encryption blocks delivery
Mistake: using a customer-managed KMS key on the bucket without granting the audit-log delivery service permission to use it.
Symptom: trail status shows logging enabled, but no objects arrive; event history may show AccessDenied on kms:GenerateDataKey or kms:Encrypt.
Fix: switch bucket encryption to AES256, or add the exact KMS key policy entries for the delivery service and source accounts.
Wrong bucket policy resource path
Mistake: allowing s3:PutObject only on the bucket ARN and not on bucket/AWSLogs/<account-id>/*.
Symptom: create-trail or update-trail returns an S3 policy error, or delivery silently fails.
Fix: set Resource to the full object prefix ARN, for example arn:aws:s3:::org-prod-audit-log-archive-123456/AWSLogs/111111111111/*.
Deny statement locks out your own admin role
Mistake: adding a blanket Deny on policy changes and retention changes without exempting a break-glass role.
Symptom: even archive-account admins get AccessDenied on PutBucketPolicy or retention operations.
Fix: add a StringNotLike exception for exactly one controlled role ARN, then test with aws sts get-caller-identity before applying.
Looking too early
Mistake: checking the bucket immediately after start-logging and assuming it is broken.
Symptom: get-trail-status shows IsLogging: true but LatestDeliveryTime is absent for a few minutes.
Fix: wait 5-15 minutes, then check both the trail status and the bucket prefix again.
Centralising logs in the same account you are auditing
Mistake: sending audit logs to a bucket in the workload account because it is faster to set up.
Symptom: any admin in that account can often weaken retention, alter bucket policy, or delete the trail before incident review.
Fix: move the destination bucket and read roles into a separate log archive account and keep source-account permissions write-only.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI