AWS Security Audit Checklist: 40 Checks With CLI Commands
You do not need a CSPM tool to audit your AWS environment. Every check a CSPM runs is ultimately an AWS API call. This checklist gives you the exact CLI commands so you can run the audit yourself, understand what each check is testing, and know what a bad result actually looks like -- before you pay a pentester, before your auditor arrives, and before an attacker finds it for you.
The checks are organized by service. Run them from a workstation with AWS CLI configured to a role that has at minimum SecurityAudit and ReadOnlyAccess managed policies. You do not need admin access to audit; you need read access to every service.
For each check: the command, what a bad result looks like, and the one-line fix.
IAM Checks (Checks 1 to 12)
IAM is the blast radius multiplier. One over-privileged credential is a breach waiting for its trigger.
Check 1: Root account MFA status
aws iam get-account-summary --query 'SummaryMap.AccountMFAEnabled'
Bad result: 0
Fix: Enable MFA on root. Go to IAM console > Security recommendations > Enable MFA for root.
Check 2: Root access keys (should not exist)
aws iam get-account-summary --query 'SummaryMap.AccountAccessKeysPresent'
Bad result: 1 or higher
Fix: Delete root access keys immediately. Root should never have programmatic access.
Check 3: Users with console access and no MFA
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | \
base64 --decode | \
awk -F',' 'NR>1 && $4=="true" && $8=="false" {print $1, "has console access, no MFA"}'
Bad result: Any username printed Fix: Enforce MFA via IAM policy denying all actions without MFA, or move to SSO.
Check 4: Access keys older than 90 days
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | \
base64 --decode | \
awk -F',' 'NR>1 && $10!="N/A" {cmd="date -j -f "%Y-%m-%dT%H:%M:%S+00:00" \""$10"\" +%s 2>/dev/null"; cmd | getline ts; close(cmd); age=(systime()-ts)/86400; if(age>90) print $1, "key age:", int(age), "days"}'
Bad result: Any user with key age over 90 days Fix: Rotate access keys. Enforce key rotation via Organizations SCP or IAM password policy.
Check 5: Users with AdministratorAccess policy
aws iam list-users --query 'Users[*].UserName' --output text | \
tr '\t' '\n' | while read user; do
policies=$(aws iam list-attached-user-policies --user-name "$user" \
--query 'AttachedPolicies[?PolicyName==`AdministratorAccess`].PolicyName' \
--output text)
[ -n "$policies" ] && echo "$user has AdministratorAccess"
done
Bad result: Any human user name printed (service accounts rarely need admin) Fix: Scope down to least-privilege. Remove AdministratorAccess; attach only needed managed policies.
Check 6: IAM roles with wildcard S3 actions in trust policy
aws iam list-roles --query 'Roles[*].RoleName' --output text | \
tr '\t' '\n' | while read role; do
policies=$(aws iam list-role-policies --role-name "$role" --output text 2>/dev/null)
aws iam list-attached-role-policies --role-name "$role" \
--query 'AttachedPolicies[*].PolicyArn' --output text 2>/dev/null
done
This surfaces role policy associations; use IAM Access Analyzer for deeper inline policy analysis.
Check 7: IAM Access Analyzer findings (unresolved)
aws accessanalyzer list-findings --analyzer-arn $(aws accessanalyzer list-analyzers \
--query 'analyzers[0].arn' --output text) \
--filter '{"status": {"eq": ["ACTIVE"]}}' \
--query 'findings[*].{Resource:resource,Type:findingType,IsPublic:isPublic}'
Bad result: Any finding of type Public or CrossAccount that you did not intentionally configure
Fix: Remediate each finding by restricting the resource policy or trust policy.
Check 8: Password policy strength
aws iam get-account-password-policy
Bad results: MinimumPasswordLength below 14, RequireSymbols or RequireNumbers false, MaxPasswordAge absent or over 90, PasswordReusePrevention absent or below 12
Fix: aws iam update-account-password-policy --minimum-password-length 14 --require-symbols --require-numbers --require-uppercase-characters --require-lowercase-characters --max-password-age 90 --password-reuse-prevention 12
Check 9: Unused IAM roles (no activity in 90 days)
aws iam get-account-authorization-details \
--filter Role \
--query 'RoleDetailList[*].{Name:RoleName,LastUsed:RoleLastUsed.LastUsedDate}' \
--output table
Bad result: Roles with LastUsed older than 90 days or null (never used) Fix: Disable (remove from trust policies) then delete after 30-day monitoring period.
Check 10: Service control policies in Organizations (confirm coverage)
aws organizations list-policies --filter SERVICE_CONTROL_POLICY \
--query 'Policies[*].{Name:Name,Id:Id}'
Bad result: Empty list -- means no guardrails are in place at the organization level Fix: Implement minimum SCPs: deny root API access, deny disabling CloudTrail, deny leaving the organization.
Check 11: Permission boundaries on high-privilege roles Manual check: Review the top 5 roles by permission breadth (those with : or admin policies) and verify permission boundaries are applied to limit what those roles can do in practice.
Check 12: Cross-account role trust policies
aws iam list-roles \
--query 'Roles[?contains(AssumeRolePolicyDocument.Statement[*].Principal.AWS, `*`)].{Name:RoleName}'
Bad result: Roles with * as the principal in the trust policy -- any AWS account can assume the role
Fix: Scope trust policies to specific account IDs and require ExternalId conditions for third-party roles.
S3 Checks (Checks 13 to 18)
Check 13: S3 account-level public access block
aws s3control get-public-access-block --account-id $(aws sts get-caller-identity --query Account --output text)
Bad result: Any of the four flags (BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets) set to false
Fix:
aws s3control put-public-access-block \
--account-id $(aws sts get-caller-identity --query Account --output text) \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Check 14: Buckets with public access block disabled at bucket level
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
result=$(aws s3api get-public-access-block --bucket "$bucket" 2>/dev/null)
if [ -z "$result" ]; then
echo "WARN: $bucket has no bucket-level public access block"
fi
done
Bad result: Any bucket without a public access block configuration Fix: Apply public access block to each bucket or rely on the account-level setting (Check 13).
Check 15: S3 buckets without server-side encryption
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
enc=$(aws s3api get-bucket-encryption --bucket "$bucket" 2>&1)
if echo "$enc" | grep -q 'ServerSideEncryptionConfigurationNotFoundError'; then
echo "NO ENCRYPTION: $bucket"
fi
done
Bad result: Any bucket name printed Fix: Enable SSE-S3 or SSE-KMS. For all new objects, set bucket default encryption.
Check 16: S3 buckets without versioning (for sensitive data buckets)
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
vers=$(aws s3api get-bucket-versioning --bucket "$bucket" --query 'Status' --output text)
[ "$vers" != "Enabled" ] && echo "NO VERSIONING: $bucket"
done
Note: Not every bucket needs versioning. Apply to buckets containing sensitive or regulated data.
Check 17: S3 access logging disabled
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
log=$(aws s3api get-bucket-logging --bucket "$bucket" --query 'LoggingEnabled' --output text 2>/dev/null)
[ "$log" = "None" ] || [ -z "$log" ] && echo "NO LOGGING: $bucket"
done
Bad result: Any bucket containing customer or sensitive data without access logging Fix: Enable S3 access logging to a dedicated logging bucket.
Check 18: Buckets with broad bucket policies (public reads)
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
policy=$(aws s3api get-bucket-policy --bucket "$bucket" 2>/dev/null)
if echo "$policy" | python3 -c "import json,sys; p=json.load(sys.stdin)['Policy']; data=json.loads(p); [print('PUBLIC POLICY: $bucket') for s in data['Statement'] if s.get('Principal')=='*' and s.get('Effect')=='Allow']" 2>/dev/null; then
:
fi
done
Bad result: Any bucket with a policy that allows Principal * with Effect Allow
Fix: Remove the public statement or scope it to specific principals.
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
CloudTrail, Config, and Monitoring Checks (Checks 19 to 26)
Check 19: CloudTrail enabled in all regions
aws cloudtrail describe-trails --include-shadow-trails \
--query 'trailList[*].{Name:Name,Region:HomeRegion,MultiRegion:IsMultiRegionTrail,LoggingEnabled:HasCustomEventSelectors}'
Bad result: No trail with IsMultiRegionTrail: true, or no trail in your primary region
Fix: Create a multi-region trail logging to a dedicated S3 bucket in a separate account.
Check 20: CloudTrail log file validation enabled
aws cloudtrail describe-trails \
--query 'trailList[*].{Name:Name,Validation:LogFileValidationEnabled}'
Bad result: LogFileValidationEnabled: false
Fix: aws cloudtrail update-trail --name [trail-name] --enable-log-file-validation
Check 21: CloudTrail not logging to a separate account Manual check: Confirm the S3 bucket target for CloudTrail logs is in a separate AWS account (security/logging account). If logs are in the same account as production, a compromised admin account can delete or modify the logs.
Check 22: GuardDuty enabled in all regions
for region in $(aws ec2 describe-regions --query 'Regions[*].RegionName' --output text); do
status=$(aws guardduty list-detectors --region "$region" --query 'DetectorIds' --output text 2>/dev/null)
[ -z "$status" ] && echo "GuardDuty NOT enabled in $region"
done
Bad result: Any region where GuardDuty is not enabled Fix: Enable via Organizations delegated admin to cover all accounts and regions centrally.
Check 23: GuardDuty high-severity findings unacknowledged
aws guardduty list-findings \
--detector-id $(aws guardduty list-detectors --query 'DetectorIds[0]' --output text) \
--finding-criteria '{"Criterion": {"severity": {"Gte": 7}}}' \
--query 'FindingIds' | wc -l
Bad result: Any number above 0 after the findings have been open for more than 24 hours Fix: Triage and remediate GuardDuty findings. Acknowledge findings that are investigated and confirmed false positive.
Check 24: AWS Config enabled
aws configservice describe-configuration-recorders
aws configservice describe-configuration-recorder-status \
--query 'ConfigurationRecordersStatus[*].{Name:name,Recording:recording,Status:lastStatus}'
Bad result: Empty output or Recording: false
Fix: Enable AWS Config with all resource types enabled and ship to a centralized Config aggregator.
Check 25: Security Hub enabled and standard coverage
aws securityhub describe-hub 2>/dev/null || echo 'Security Hub NOT enabled'
aws securityhub list-enabled-standards \
--query 'StandardsSubscriptions[*].{Standard:StandardsArn,Status:StandardsStatus}'
Bad result: Not enabled, or enabled without AWS Foundational Security Best Practices standard Fix: Enable Security Hub with at minimum the AWS Foundational Security Best Practices and CIS AWS Foundations Benchmark standards.
Check 26: CloudWatch alarms for critical events Verify you have alarms (with SNS notification) for:
aws cloudwatch describe-alarms --query 'MetricAlarms[*].{Name:AlarmName,Metric:MetricName,State:StateValue}'
Critical events that should have alarms: root account login, CloudTrail config changes, IAM policy changes, unauthorized API calls, security group changes, MFA delete on S3 buckets.
EC2, VPC, and Network Checks (Checks 27 to 34)
Check 27: Security groups with 0.0.0.0/0 ingress on sensitive ports
aws ec2 describe-security-groups \
--query 'SecurityGroups[?contains(IpPermissions[*].IpRanges[*].CidrIp, `0.0.0.0/0`)].{ID:GroupId,Name:GroupName,Ports:IpPermissions[*].FromPort}' \
--output table
Bad result: Security groups allowing 0.0.0.0/0 on ports 22 (SSH), 3389 (RDP), 1433 (MSSQL), 3306 (MySQL), 5432 (PostgreSQL), 27017 (MongoDB) Fix: Replace 0.0.0.0/0 with specific CIDR ranges. Use Systems Manager Session Manager instead of SSH.
Check 28: EC2 instances with public IPs that should not have them
aws ec2 describe-instances \
--filters 'Name=instance-state-name,Values=running' \
--query 'Reservations[*].Instances[*].{ID:InstanceId,PublicIP:PublicIpAddress,Name:Tags[?Key==`Name`].Value|[0]}' \
--output table
Bad result: Instances with public IPs that are application servers or databases (not intentional bastion hosts or NAT instances) Fix: Remove public IP on instance stop/start. Use NAT gateway for outbound traffic. Use ALB/NLB for inbound.
Check 29: EBS volumes not encrypted
aws ec2 describe-volumes \
--query 'Volumes[?Encrypted==`false`].{ID:VolumeId,State:State,Size:Size}'
Bad result: Any volume printed
Fix: Enable EBS encryption by default at the account level: aws ec2 enable-ebs-encryption-by-default. Existing unencrypted volumes require snapshot + copy-with-encryption + restore.
Check 30: Default VPC still in use
aws ec2 describe-vpcs --filters 'Name=isDefault,Values=true' \
--query 'Vpcs[*].{ID:VpcId,Default:IsDefault,State:State}'
# Then check if any instances are using the default VPC
aws ec2 describe-instances \
--filters 'Name=vpc-id,Values=[default-vpc-id]' \
--query 'Reservations[*].Instances[*].InstanceId'
Bad result: Instances running in the default VPC Fix: Migrate instances to purpose-built VPCs. Delete or disable the default VPC once it is empty.
Check 31: VPC Flow Logs disabled
aws ec2 describe-flow-logs --query 'FlowLogs[*].{Status:FlowLogStatus,Resource:ResourceId,Destination:LogDestinationType}'
Bad result: No flow logs for your production VPC(s) Fix: Enable VPC Flow Logs for all VPCs to CloudWatch Logs or S3. Essential for network forensics and anomaly detection.
Check 32: Unrestricted outbound in security groups Many organizations restrict inbound but leave outbound as 0.0.0.0/0 on all ports. For production servers, outbound should be restricted to known ports and destinations. Check your most sensitive instances:
aws ec2 describe-security-groups \
--query 'SecurityGroups[?IpPermissionsEgress[?IpRanges[?CidrIp==`0.0.0.0/0`] && ToPort==null]].{ID:GroupId,Name:GroupName}'
Check 33: SSM Session Manager available (no SSH required)
aws ssm describe-instance-information \
--query 'InstanceInformationList[*].{ID:InstanceId,Platform:PlatformType,PingStatus:PingStatus}'
Good result: All production EC2 instances visible in SSM. This means SSH port can be closed entirely -- all shell access goes through Session Manager with CloudTrail logging.
Check 34: IMDSv2 enforced on EC2 instances
aws ec2 describe-instances \
--query 'Reservations[*].Instances[*].{ID:InstanceId,IMDSv2:MetadataOptions.HttpTokens}'
Bad result: Any instance with HttpTokens: optional (allows IMDSv1)
Fix: Require IMDSv2 on all instances:
aws ec2 modify-instance-metadata-options --instance-id [id] --http-tokens required
IMDSv1 is exploitable via SSRF attacks to steal instance role credentials.
RDS and Database Checks (Checks 35 to 40)
Check 35: RDS instances with public accessibility
aws rds describe-db-instances \
--query 'DBInstances[?PubliclyAccessible==`true`].{ID:DBInstanceIdentifier,Engine:Engine,Endpoint:Endpoint.Address}'
Bad result: Any database printed
Fix: Modify the instance to disable public access: aws rds modify-db-instance --db-instance-identifier [id] --no-publicly-accessible. Move database to a private subnet.
Check 36: RDS encryption at rest disabled
aws rds describe-db-instances \
--query 'DBInstances[?StorageEncrypted==`false`].{ID:DBInstanceIdentifier,Engine:Engine}'
Bad result: Any database printed Fix: Encryption cannot be enabled in place on an existing RDS instance. Take a snapshot, copy the snapshot with encryption enabled, restore from the encrypted snapshot.
Check 37: RDS automated backups disabled or retention too short
aws rds describe-db-instances \
--query 'DBInstances[*].{ID:DBInstanceIdentifier,BackupRetention:BackupRetentionPeriod}'
Bad result: BackupRetentionPeriod: 0 (backups disabled) or below 7 days for production databases
Fix: aws rds modify-db-instance --db-instance-identifier [id] --backup-retention-period 7
Check 38: RDS instances without deletion protection
aws rds describe-db-instances \
--query 'DBInstances[?DeletionProtection==`false`].{ID:DBInstanceIdentifier}'
Bad result: Production databases without deletion protection
Fix: Enable: aws rds modify-db-instance --db-instance-identifier [id] --deletion-protection
Check 39: RDS not using IAM database authentication
aws rds describe-db-instances \
--query 'DBInstances[?IAMDatabaseAuthenticationEnabled==`false`].{ID:DBInstanceIdentifier,Engine:Engine}'
Note: IAM authentication is only available for MySQL and PostgreSQL RDS. For engines that support it, prefer IAM auth over static passwords for application service accounts.
Check 40: RDS parameter groups with insecure settings Check critical parameters:
# For PostgreSQL: confirm ssl is required
aws rds describe-db-parameters \
--db-parameter-group-name [group-name] \
--query 'Parameters[?ParameterName==`rds.force_ssl`].{Name:ParameterName,Value:ParameterValue}'
# For MySQL: confirm require_secure_transport
aws rds describe-db-parameters \
--db-parameter-group-name [group-name] \
--query 'Parameters[?ParameterName==`require_secure_transport`].{Name:ParameterName,Value:ParameterValue}'
Bad result: rds.force_ssl = 0 or require_secure_transport = OFF
Fix: Update the parameter group to enforce SSL for all connections.
The bottom line
These 40 checks cover the misconfigurations that appear in the majority of AWS security assessments. Run them before your next pentest, SOC 2 audit, or CSPM deployment -- you will find real issues and fix them on your timeline, not the auditor's. The checks requiring the most frequent attention in real environments: security groups with 0.0.0.0/0 on sensitive ports, users with no MFA, CloudTrail disabled in non-primary regions, and RDS instances with public accessibility. Start there.
Frequently asked questions
What IAM permissions do I need to run these checks?
Attach the AWS managed policies SecurityAudit and ReadOnlyAccess to the role or user running these commands. SecurityAudit grants read access to security-relevant configurations; ReadOnlyAccess fills in gaps for services not covered by SecurityAudit. You do not need AdministratorAccess to audit -- in fact, running audits from a least-privilege role is better practice than using admin credentials.
Should I run these checks manually or automate them?
Run them manually first to understand what each check tests and what a bad result looks like in your environment. Then automate the ones that matter most: schedule them via AWS Lambda on a weekly basis or integrate them into your CI/CD pipeline for infrastructure changes. AWS Security Hub and Config rules can continuously monitor many of these conditions and alert when they drift. The manual run is for immediate discovery; automation is for continuous assurance.
What is the single highest-priority fix from this list?
Root account MFA (Check 1) and root access key deletion (Check 2) if either is failing. The root account has unrestricted access to your entire AWS environment and cannot be restricted by SCPs or IAM policies. A compromised root account is a complete account takeover. Everything else can be argued about in terms of priority; these two cannot.
How does this compare to running a CSPM tool like Wiz or Orca?
CSPM tools run similar checks plus hundreds more, continuously, across all accounts and regions, with dashboards, prioritization, and ticketing integrations. These CLI checks are a manual alternative for teams that do not yet have a CSPM, want to understand what CSPMs actually do, or need to run a targeted spot-check quickly. If you are running multiple AWS accounts with complex infrastructure, a CSPM pays for itself in analyst time within the first quarter.
What is IMDSv2 and why does it matter?
The Instance Metadata Service (IMDS) provides EC2 instances access to their IAM role credentials via a local endpoint (169.254.169.254). IMDSv1 allows any code running on the instance to query this endpoint with a simple HTTP GET, including server-side request forgery (SSRF) payloads from web application vulnerabilities. IMDSv2 requires a session token obtained via a PUT request, which SSRF attacks cannot obtain. Several notable breaches (including Capital One 2019) exploited IMDSv1 via SSRF to steal instance credentials.
How often should I run this audit?
Run the full checklist quarterly, and automate continuous monitoring for the highest-risk checks (security group 0.0.0.0/0, public S3, GuardDuty findings, root MFA). AWS Security Hub with the Foundational Security Best Practices standard monitors most of these conditions continuously. Before a pentest or SOC 2 audit, run the full checklist manually to catch anything Security Hub missed and confirm your automated remediations are working.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, weekly, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals weekly.
The Mythos Brief is free.
AI that finds 27-year-old zero-days. What it means for your security program.
