
If you've been creating S3 buckets in an AWS account for a few years, across a few teams, there's a good chance at least one of them doesn't have default encryption configured. Nobody notices until an audit asks for proof. This post gives you a real, runnable Python script using boto3 that walks every bucket in an account and reports which ones are missing server-side encryption — not a conceptual sketch, something you can run against a real account today.
1. What "unencrypted" actually means for an S3 bucket
S3 has supported default bucket-level encryption for a long time, and since January 2023, AWS automatically applies SSE-S3 (AES256) encryption by default to every new bucket, with no configuration required. That default matters for this audit: a bucket created recently is almost certainly encrypted by default, but a bucket created before that change, or one where someone explicitly disabled default encryption, can still have no server-side encryption configuration at all.
The API call that tells you a bucket's encryption configuration is get_bucket_encryption. If a bucket has no default encryption configured, this call doesn't return an empty result — it raises a ServerSideEncryptionConfigurationNotFoundError client error. That's your signal, and it's the detail that trips up a lot of first-draft scripts that only handle the "happy path" response.
2. The core script
Here's a complete, runnable script. It lists every bucket in the account, checks each one's encryption configuration, and prints a report of which buckets have no default encryption.
import boto3
from botocore.exceptions import ClientError
s3 = boto3.client("s3")
def list_all_buckets():
response = s3.list_buckets()
return [b["Name"] for b in response["Buckets"]]
def get_encryption_status(bucket_name):
try:
response = s3.get_bucket_encryption(Bucket=bucket_name)
rules = response["ServerSideEncryptionConfiguration"]["Rules"]
algos = [
rule["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
for rule in rules
if "ApplyServerSideEncryptionByDefault" in rule
]
return {"encrypted": True, "algorithms": algos}
except ClientError as e:
error_code = e.response["Error"]["Code"]
if error_code == "ServerSideEncryptionConfigurationNotFoundError":
return {"encrypted": False, "algorithms": []}
# Anything else (access denied, bucket in a different region
# under some auth setups, etc.) — surface it, don't swallow it.
raise
def audit_all_buckets():
unencrypted = []
encrypted = []
for bucket in list_all_buckets():
status = get_encryption_status(bucket)
if status["encrypted"]:
encrypted.append((bucket, status["algorithms"]))
else:
unencrypted.append(bucket)
return encrypted, unencrypted
if __name__ == "__main__":
encrypted, unencrypted = audit_all_buckets()
print(f"Checked {len(encrypted) + len(unencrypted)} buckets total\n")
print(f"UNENCRYPTED ({len(unencrypted)}):")
for b in unencrypted:
print(f" - {b}")
print(f"\nEncrypted ({len(encrypted)}):")
for b, algos in encrypted:
print(f" - {b}: {', '.join(algos)}")Run it with python3 audit_s3_encryption.py against an account where your credentials have at least s3:ListAllMyBuckets and s3:GetEncryptionConfiguration. It needs no arguments — it discovers every bucket in the account on its own.
3. Handling buckets in other regions
list_buckets returns every bucket in the account regardless of region, but a plain boto3.client("s3") defaults to whatever region your session is configured for. For most S3 API calls this doesn't matter — S3 routes requests correctly — but if you're running in an account with buckets using regional-only features or strict regional endpoints, it's safer to resolve each bucket's actual region first and call from there:
def get_bucket_region(bucket_name):
resp = s3.get_bucket_location(Bucket=bucket_name)
region = resp.get("LocationConstraint")
# us-east-1 is returned as None/empty by this API, annoyingly
return region or "us-east-1"
def get_encryption_status_regional(bucket_name):
region = get_bucket_region(bucket_name)
regional_client = boto3.client("s3", region_name=region)
try:
response = regional_client.get_bucket_encryption(Bucket=bucket_name)
rules = response["ServerSideEncryptionConfiguration"]["Rules"]
algos = [
r["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
for r in rules if "ApplyServerSideEncryptionByDefault" in r
]
return {"encrypted": True, "algorithms": algos}
except ClientError as e:
if e.response["Error"]["Code"] == "ServerSideEncryptionConfigurationNotFoundError":
return {"encrypted": False, "algorithms": []}
raiseFor most accounts this extra step is unnecessary — the default client handles cross-region S3 calls fine — but it's worth knowing about if you hit unexpected PermanentRedirect errors on a multi-region account.
4. Distinguishing SSE-S3, SSE-KMS, and which key
"Encrypted" isn't one setting — S3 supports a few server-side encryption modes, and for a real security review you usually want to know which one, not just yes/no:
- SSE-S3 (
AES256) — AWS manages the encryption key entirely. This is the default applied automatically to new buckets. - SSE-KMS (
aws:kms) — encryption keys come from AWS KMS, which gives you key rotation policies, access logging via CloudTrail, and the ability to restrict who can use the key independently of who can access the bucket. - DSSE-KMS (
aws:kms:dsse) — dual-layer encryption with KMS, for workloads with compliance requirements that call for defense in depth on the encryption layer itself.
Extend the script to capture the KMS key ID when relevant, which matters a lot if you're auditing for "is this bucket using our organization's customer-managed key" rather than just "is this bucket encrypted at all":
def get_encryption_detail(bucket_name):
try:
response = s3.get_bucket_encryption(Bucket=bucket_name)
rules = response["ServerSideEncryptionConfiguration"]["Rules"]
details = []
for rule in rules:
sse = rule.get("ApplyServerSideEncryptionByDefault", {})
details.append({
"algorithm": sse.get("SSEAlgorithm"),
"kms_key_id": sse.get("KMSMasterKeyID"), # only present for aws:kms / aws:kms:dsse
"bucket_key_enabled": rule.get("BucketKeyEnabled", False),
})
return {"encrypted": True, "rules": details}
except ClientError as e:
if e.response["Error"]["Code"] == "ServerSideEncryptionConfigurationNotFoundError":
return {"encrypted": False, "rules": []}
raise5. Exporting a CSV report
For a real audit, you want a file you can hand to a security team or attach to a ticket, not console output someone has to copy by hand.
import csv
import boto3
from botocore.exceptions import ClientError
s3 = boto3.client("s3")
def audit_to_csv(output_path="s3_encryption_audit.csv"):
buckets = [b["Name"] for b in s3.list_buckets()["Buckets"]]
rows = []
for bucket in buckets:
try:
resp = s3.get_bucket_encryption(Bucket=bucket)
rules = resp["ServerSideEncryptionConfiguration"]["Rules"]
sse = rules[0].get("ApplyServerSideEncryptionByDefault", {}) if rules else {}
rows.append({
"bucket": bucket,
"encrypted": True,
"algorithm": sse.get("SSEAlgorithm", ""),
"kms_key_id": sse.get("KMSMasterKeyID", ""),
})
except ClientError as e:
if e.response["Error"]["Code"] == "ServerSideEncryptionConfigurationNotFoundError":
rows.append({"bucket": bucket, "encrypted": False, "algorithm": "", "kms_key_id": ""})
else:
rows.append({"bucket": bucket, "encrypted": f"ERROR: {e.response['Error']['Code']}",
"algorithm": "", "kms_key_id": ""})
with open(output_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["bucket", "encrypted", "algorithm", "kms_key_id"])
writer.writeheader()
writer.writerows(rows)
unencrypted_count = sum(1 for r in rows if r["encrypted"] is False)
print(f"Wrote {len(rows)} rows to {output_path} ({unencrypted_count} unencrypted)")
if __name__ == "__main__":
audit_to_csv()6. Fixing what you find
Once you've identified an unencrypted bucket, enabling default encryption is a single API call — this doesn't retroactively encrypt existing objects, only new ones written after the setting is applied:
def enable_default_encryption(bucket_name, kms_key_id=None):
if kms_key_id:
rule = {
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": kms_key_id,
},
"BucketKeyEnabled": True,
}
else:
rule = {"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}
s3.put_bucket_encryption(
Bucket=bucket_name,
ServerSideEncryptionConfiguration={"Rules": [rule]},
)To cover existing, already-unencrypted objects in the bucket, you need a separate re-encryption pass — typically an S3 Batch Operations job that copies each object onto itself, which triggers S3 to apply the bucket's current encryption settings. That's a bigger job than this script, but it's the necessary second step; flipping on default encryption alone only protects what gets written from now on.
Common pitfalls
- Treating the exception as a failure instead of a signal. The whole audit hinges on catching
ServerSideEncryptionConfigurationNotFoundErrorspecifically and treating it as "unencrypted," not as an error to log and skip. A broadexcept Exception: continuewill silently miss every unencrypted bucket. - Assuming all buckets in the account are visible to your credentials.
list_bucketsonly returns buckets owned by the account your credentials belong to. In an AWS Organization with many accounts, you need to run this per-account (or via a cross-account role) to get full coverage. - Confusing bucket-level default encryption with object-level encryption. A bucket can have default encryption enabled today while still containing older objects that were uploaded unencrypted before that setting existed. Checking
get_bucket_encryptionalone doesn't tell you the encryption state of every object already in the bucket. - Rate limiting on large accounts. An account with thousands of buckets calling
get_bucket_encryptionin a tight loop can hit S3 request throttling. Add a small delay or use exponential backoff (boto3's default retry config handles basic throttling, but a very large account may still need explicit pacing). - Forgetting
BucketKeyEnabled. When using SSE-KMS, leaving S3 Bucket Keys off means every object PUT and GET generates a separate KMS API call, which is slower and can add unnecessary KMS request volume. It's a performance and cost detail, not a security gap, but worth fixing alongside an encryption audit.
Troubleshooting
If the script raises AccessDenied on list_buckets, your IAM identity needs s3:ListAllMyBuckets at minimum — this is an account-wide permission, not a per-bucket one. If it raises AccessDenied on individual buckets during get_bucket_encryption, you likely have account-level list access but not s3:GetEncryptionConfiguration on every bucket — common in an account where buckets have restrictive bucket policies from other teams; catch that case explicitly and report it as "unknown" rather than crashing the whole run. If a bucket you can see clearly has default encryption configured in the console but the script reports it unencrypted, double-check you're hitting the right partition and region — a bucket in an opt-in region (like some newer AWS regions) can need an explicit region_name on the client even for metadata calls like this one.
Where to go next
This script covers default bucket encryption, which is the right first pass. From there, extend the same pattern to check get_public_access_block for buckets missing public-access restrictions, and get_bucket_versioning for buckets without versioning enabled — both are common companions to an encryption audit and use the exact same list-buckets-then-check-each-one shape. AWS Config's s3-bucket-server-side-encryption-enabled managed rule can also run this check continuously once you've validated the one-off script gives you confidence in what "good" looks like for your account.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


