Skip to content

AWS Security Checklist for Small Teams

An AWS security checklist for small teams: what to fix this week, this month and as you grow, with CLI commands, verification steps and realistic costs.

29 min read

A small team's AWS setup usually grows by accident. There are one or two accounts, a handful of IAM users created years ago, an access key in the CI system that nobody remembers creating, and the root user still signs in with the founder's personal email. Nothing is on fire, so the AWS security checklist everyone means to work through keeps losing to the roadmap.

The trouble with doing it casually is that most AWS security controls fail quietly. A service shows as enabled while it evaluates nothing, or a guardrail covers EC2 but not the S3 bucket in another Region. You find out at the worst moment: an unexpected invoice, a leaked key, an auditor's question nobody can answer.

This checklist is the short, ordered version. Lock identity down this week, turn on detection this month, and add organization-wide guardrails as you grow. Verify every control after you enable it, because "enabled" and "actually evaluating" are different states. Most of the high-value items cost nothing, and the paid detection services each have a 30-day trial, so you can measure the bill before you commit to it.

AWS already publishes the AWS Startup Security Baseline (SSB), a list of account controls ACCT.01 to ACCT.17 and workload controls WKLD.01 to WKLD.15. It's good, but it isn't ordered by urgency, and it doesn't tell you how to prove a control is working or what it will cost. This guide takes the SSB controls that matter most early on, puts them in order, adds the multi-account guardrails the SSB leaves out, and gives each a verify step and a cost. Where I hit a trap governing a multi-account estate, written up as a multi-account AWS governance case study, I say so.

Prerequisites#

To follow along, you need:

  • The AWS CLI version 2, configured with credentials for the account you're securing.
  • Administrator access to a standalone account, or to the management account if you already use AWS Organizations (the service that groups accounts under one payer and one set of policies).
  • A shared mailbox or distribution list for security and billing mail, such as aws-security@your_domain. Personal inboxes leave with people.

Commands use placeholders such as ${ACCOUNT_ID} or your-region. Replace them with your own values.

How to use this checklist#

The 19 controls fall into three tiers. The first tier is free and mostly about identity, because a leaked key or a taken-over root user hands over everything. The second turns on detection and data protection. The third only makes sense once you run more than one account.

flowchart TB A["This week: identity and account basics"] --> B["This month: detection and data protection"] B --> C["As you grow: multi-account guardrails"]

Work top to bottom, and verify each control before moving on.

The effort column is my rough judgement for a small team that knows its own systems. Treat it as an order of magnitude, not an estimate.

# Control Risk it closes Rough effort Cost
This week
1 Root MFA, no root keys, group email Takeover through the one user you can't restrict Minutes Free
2 Security alternate contact AWS security notices reach nobody Minutes Free
3 People sign in through IAM Identity Center Passwords and keys tied to individuals Hours Free
4 Find and retire long-lived access keys A leaked key works until someone deletes it Hours Free
5 CI through OIDC, not stored keys Keys sitting in CI secrets Hours per pipeline Free
6 Budget and Cost Anomaly Detection Abuse found on the invoice Minutes Free
This month
7 Multi-Region CloudTrail trail No record of who did what An hour First management copy free, S3 storage billed
8 GuardDuty in every Region Nobody watching for threats An hour Usage-based, 30-day trial
9 IAM Access Analyzer, external access Resources shared outside the account Minutes Free
10 S3 Block Public Access, account level Public buckets An hour with inventory Free
11 EBS encryption by default Unencrypted volumes Minutes per Region Free
12 IMDSv2 as the account default Instances on the older metadata service Hours Free
13 Secrets out of code and env files Credentials in repositories Varies From free
14 Security Hub CSPM, standards chosen on purpose No continuous configuration checks Hours Per check, plus AWS Config
As you grow
15 Centralized root access Root credentials in member accounts An hour No extra charge
16 Baseline SCP Someone switches detection off A day with testing No extra charge
17 Region SCP Resources in Regions nobody watches Days, mostly inventory No extra charge
18 RDS snapshot sharing check Database snapshots shared publicly An hour Free
19 Declarative policies Settings drifting back Hours No extra charge

AWS Organizations is offered at no additional charge, so the policies in the last tier add nothing to the bill.

Most items below follow the same shape: what to do, why it matters, how to do it, how to verify it, and what it costs.

Do this week: identity and account basics#

1. Lock down the root user#

The root user can do anything in the account, including things no IAM policy can stop, so it's the one identity you can't afford to lose. AWS now requires MFA on the root user for all account types, with registration due within 35 days of first sign-in. Delete any root access keys, and move the root email to a group address so recovery doesn't depend on one person's inbox.

Sign in as the root user. On the right of the navigation bar, choose your account name, then Security credentials. In the Multi-factor authentication (MFA) section, choose Assign MFA device (AWS steps). Delete any keys in the Access keys section on the same page. To change the email, choose your account name, then Account; next to Account details, choose Actions, Update email address and password (AWS steps).

aws iam get-account-summary \
  --query 'SummaryMap.{MFA:AccountMFAEnabled,RootKeys:AccountAccessKeysPresent}'

The output will look similar to:

Output
{
    "MFA": 1,
    "RootKeys": 0
}

AccountMFAEnabled should be 1 and AccountAccessKeysPresent should be 0. Anything else means the root user still needs work. Cost: free.

2. Set the security alternate contact#

AWS sends security notices to the security alternate contact. If it's empty or points at someone who left, the notice goes nowhere. See updating alternate contacts.

aws account put-alternate-contact \
  --alternate-contact-type=SECURITY \
  --email-address=aws-security@your_domain \
  --name="Security team" \
  --phone-number="${PHONE_NUMBER}" \
  --title="Security contact"
aws account get-alternate-contact --alternate-contact-type=SECURITY

The second command should show your group address. From a management account, add --account-id ${ACCOUNT_ID} to set the contact on a member account; that needs trusted access for AWS Account Management enabled in Organizations first. Cost: free.

3. Give people IAM Identity Center, not IAM users#

IAM users carry a password and often an access key that never expires. IAM Identity Center gives each person one sign-in, short-lived credentials and permission sets you manage centrally, which is what the IAM best practices recommend for human access. Set it up, move each person across, then remove their IAM user. Verify with the credential report from the next item: no human IAM user should have a password. Cost: free.

4. Find and retire long-lived access keys#

Access keys don't expire. A key that leaks into a repository, a laptop backup or a CI log keeps working until someone deletes it. Start with the credential report, a CSV of every IAM user and the state of their credentials.

aws iam generate-credential-report
aws iam get-credential-report --query Content --output text \
  | base64 --decode > credential-report.csv

Run generate-credential-report until it returns "State": "COMPLETE", then download; the second command decodes the base64 content to a file. AWS regenerates the report at most once every four hours. For each user, read access_key_1_last_rotated and access_key_1_last_used_date (and the access_key_2_ pair). Old or idle keys are your first candidates.

Retire keys in two moves: deactivate first, delete later. A deactivated key can be switched back on in seconds if something breaks; a deleted one can't. See managing access keys.

aws iam update-access-key --user-name ${USER_NAME} \
  --access-key-id ${ACCESS_KEY_ID} --status Inactive

Recent use is not the full inventory

Before you deactivate a key, find every workflow that references it, not only the ones that ran recently. A release job or a quarterly report can sit idle for months and then fail on the day it matters. The same applies to roles: IAM's last-used data for a role only covers the trailing 400 days.

Cost: free.

5. Move CI to OIDC instead of stored keys#

The most common long-lived key is the one in your CI system's secrets. With OIDC federation, the CI job presents a signed token and assumes an IAM role for the length of the run, so there's no key to leak. I cover the full trust policy in GitHub Actions OIDC trust policy for AWS.

Three things matter when you plan this across many repositories. Keep a sub condition, because IAM rejects a GitHub trust policy without one, and add the repository_owner_id claim to pin your organization by its numeric ID. Accept both subject formats GitHub now issues, because newer repositories use the immutable one. And run OIDC alongside the old key: retire the key only after each environment has had a green run on OIDC.

Verify by watching the old key's last-used date in the credential report. When it stops moving, the pipeline no longer needs it. Cost: free.

6. Turn on a budget and Cost Anomaly Detection#

Abuse of a compromised account, such as crypto-mining instances, often shows up on the bill first. AWS Budgets are free to monitor, and the first two action-enabled budgets are free too. Cost Anomaly Detection watches spending patterns rather than a fixed ceiling.

In Billing and Cost Management, open Budgets, create a monthly cost budget a little above your normal spend, and add email alerts at 80% and 100% of actual spend to your group address. Then open Cost Anomaly Detection and review the monitor and its alert subscription.

aws budgets create-budget --account-id ${ACCOUNT_ID} \
  --budget '{"BudgetName":"monthly-cost","BudgetLimit":{"Amount":"500","Unit":"USD"},"TimeUnit":"MONTHLY","BudgetType":"COST"}' \
  --notifications-with-subscribers '[
    {"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":80,"ThresholdType":"PERCENTAGE"},
     "Subscribers":[{"SubscriptionType":"EMAIL","Address":"aws-billing@your_domain"}]},
    {"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":100,"ThresholdType":"PERCENTAGE"},
     "Subscribers":[{"SubscriptionType":"EMAIL","Address":"aws-billing@your_domain"}]}
  ]'

Replace 500 with a figure that fits your account.

The default anomaly alert is set for a bigger bill than yours

If you started using Cost Explorer on or after 27 March 2023, AWS created a default anomaly monitor for you. Its alert only fires on anomalies above $100 and above 40%, according to the Cost Anomaly Detection FAQ. On a small bill that may never trigger. Lower the threshold to something that would actually surprise you.

Cost: free.

Do this month: detection and data protection#

7. Record every Region with CloudTrail#

CloudTrail is your record of who did what, and when; without it, an incident investigation has nothing to read. A multi-Region trail also covers the Regions you don't use. With Organizations, create an organization trail from the management account so every member account is covered.

First enable trusted access for CloudTrail (organizations only) and create a bucket for the logs:

aws organizations enable-aws-service-access \
  --service-principal cloudtrail.amazonaws.com
aws s3 mb s3://${TRAIL_BUCKET} --region your-region
aws s3api put-bucket-policy --bucket ${TRAIL_BUCKET} \
  --policy file://trail-bucket-policy.json

The bucket policy has to let CloudTrail write to the bucket. Copy it from the Amazon S3 bucket policy for CloudTrail, using the organization variant if you're creating an org trail. Then create the trail and start it, following the organization trail CLI steps:

aws cloudtrail create-trail --name org-trail \
  --s3-bucket-name ${TRAIL_BUCKET} \
  --is-multi-region-trail \
  --is-organization-trail
aws cloudtrail start-logging --name org-trail
aws cloudtrail get-trail-status --name org-trail --query IsLogging

Drop --is-organization-trail (and the trusted-access command) for a standalone account. The output will look similar to:

Output
true

Per CloudTrail pricing, the first copy of management events is free; you pay for the S3 storage. If you later enroll accounts into AWS Control Tower while they still have their own account-level trails, you can end up paying for the same events twice, so remove the redundant trails first.

8. Turn on GuardDuty in every Region#

GuardDuty is AWS's threat detection service: it analyzes CloudTrail events, VPC flow logs and DNS logs and raises findings for suspicious activity. It's Regional, and a Region where workloads can run but GuardDuty is off is a Region nobody is watching.

aws guardduty create-detector --enable --region your-region

Route findings to email through an EventBridge rule and an SNS topic. Start with the event pattern:

guardduty-findings-rule.json
{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"]
}

Then create the topic, subscribe your group address, and point the rule at the topic:

TOPIC_ARN=$(aws sns create-topic --name guardduty-findings \
  --query TopicArn --output text)
aws sns subscribe --topic-arn "$TOPIC_ARN" --protocol email \
  --notification-endpoint aws-security@your_domain
aws events put-rule --name guardduty-findings \
  --event-pattern file://guardduty-findings-rule.json
aws events put-targets --rule guardduty-findings \
  --targets "Id"="sns","Arn"="$TOPIC_ARN"

The topic's access policy must allow events.amazonaws.com to call sns:Publish, or EventBridge drops the findings without telling you. Confirm the subscription email, too. EventBridge rules are Regional, so repeat this in each Region. With a GuardDuty delegated administrator, create the rules in that account, one per Region, because it receives member findings in each Region.

To verify the whole path, generate sample findings and confirm one reaches the inbox:

DETECTOR_ID=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)
aws guardduty create-sample-findings --detector-id ${DETECTOR_ID}

GuardDuty keeps findings for 90 days, so export them to S3 if you want a longer record. You don't need to turn on VPC flow logs for GuardDuty, which reads that data independently; enable them later if you want them for your own forensics.

Cost is usage-based. In the first pricing tier in us-east-1, GuardDuty pricing lists $4.00 per million CloudTrail events and $1.00 per GB of flow and DNS logs analyzed. New detectors also turn on most protection plans (S3, EKS, Malware, RDS, Lambda), each billed separately; review them on the trial's usage page and turn off what you don't run. The 30-day trial lets you measure first.

9. Run IAM Access Analyzer for external access#

IAM Access Analyzer reads your resource policies and reports anything reachable from outside your account or organization. External access analysis is free. Analyzers are Regional, so create one per Region.

aws accessanalyzer create-analyzer --analyzer-name external-access \
  --type ACCOUNT --region your-region
aws accessanalyzer list-findings-v2 --analyzer-arn ${ANALYZER_ARN}

Use --type ORGANIZATION from the management or delegated administrator account to cover every member account. Organization analyzer findings are visible only in the account that owns the analyzer (the management account or the delegated administrator). Don't create a duplicate analyzer in a member account because the list looked empty from there.

The finding I once reasoned my way past was a queue policy that allowed any principal ("Principal": "*") and relied on an aws:SourceArn condition naming an S3 bucket. S3 bucket ARNs carry no account ID, so that condition doesn't pin access to your account; if the bucket is ever deleted, anyone can create one with the same name. Access Analyzer was right to call it public. If isPublic disagrees with your reading of a policy, trust the analyzer. AWS's confused deputy guidance recommends pairing it with aws:SourceAccount:

Condition block that pins the source account
"Condition": {
  "ArnLike": { "aws:SourceArn": "arn:aws:s3:::example-bucket" },
  "StringEquals": { "aws:SourceAccount": "123456789012" }
}
Optional: the unused-access analyzer

A second analyzer type reports roles, users, keys and permissions nobody has used in a set period. It isn't Regional.

aws accessanalyzer create-analyzer --analyzer-name unused-access \
  --type ACCOUNT_UNUSED_ACCESS \
  --configuration '{"unusedAccess":{"unusedAccessAge":90}}'

This one is paid: $0.20 per IAM role or user per month. AWS's pricing example puts 70 principals at $14 a month; at thousands of roles it becomes hundreds of dollars a month. Setup details are in creating an unused access analyzer.

10. Block public S3 access at the account level#

Bucket-level settings protect one bucket. The account-level Block Public Access setting protects every bucket, including the next one somebody creates.

Find your intentionally public buckets first

The account setting overrides bucket policies and ACLs that grant public access. If you serve a static site or public downloads straight from S3, turning this on breaks them. List those buckets first and move them behind CloudFront, or accept that this account can't use the setting yet.

In the S3 console, choose Account and organization settings in the navigation pane. Under Block Public Access settings for this account, choose Edit, turn on all four settings and choose Save changes, then type confirm and choose Confirm.

aws s3control put-public-access-block --account-id ${ACCOUNT_ID} \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Verify with aws s3control get-public-access-block --account-id ${ACCOUNT_ID}. The output will look similar to:

Output
{
    "PublicAccessBlockConfiguration": {
        "BlockPublicAcls": true,
        "IgnorePublicAcls": true,
        "BlockPublicPolicy": true,
        "RestrictPublicBuckets": true
    }
}

Cost: free. To hold this setting across an organization, use an Organizations S3 policy (a declarative policy type) rather than an SCP deny (item 19).

11. Encrypt new EBS volumes by default#

EBS encryption by default makes every new volume and snapshot copy encrypted without anyone remembering a flag. It's a per-Region setting, and existing volumes stay as they are.

In the EC2 console, select the Region, choose Settings in the navigation pane, then the Data protection and security tab. In the EBS encryption section, choose Manage, select Enable, then choose Update EBS encryption.

aws ec2 enable-ebs-encryption-by-default --region your-region
aws ec2 get-ebs-encryption-by-default --region your-region \
  --query EbsEncryptionByDefault

The output will look similar to:

Output
true

Repeat for every Region you use. Cost: free.

12. Make IMDSv2 the account default#

The instance metadata service hands an EC2 instance its role credentials, and IMDSv2 requires a session token for every request. You can set it as the account default for new instances, per Region:

aws ec2 modify-instance-metadata-defaults --region your-region \
  --http-tokens required --http-put-response-hop-limit -1
aws ec2 get-instance-metadata-defaults --region your-region \
  --query AccountLevel.HttpTokens

The output will look similar to:

Output
"required"

Set the hop limit to 2 only if containers on the instance need the instance role, because going into a container counts as an extra network hop. To keep containers away from the instance's credentials, set it to 1 on those instances. -1 means no preference: at launch it becomes 2 for AMIs marked ImdsSupport: v2.0, such as Amazon Linux 2023, and 1 otherwise. Existing instances are unchanged. Cost: free.

Enforcement makes launches fail

Adding --http-tokens-enforced enabled turns the default into a hard rule: any launch that explicitly asks for IMDSv1 (a launch template or AMI with HttpTokens: optional) fails, and IMDSv1 can't be re-enabled on existing instances. Update your templates first, then turn on enforcement.

While you're on EC2: use Session Manager instead of opening SSH to the internet. The SSB lists it as WKLD.06. Instances no longer need port 22 open, and every session can be logged.

13. Move secrets out of code and environment files#

Database passwords and API tokens in a repository or a committed .env file outlive every rotation policy you write. Move them to Secrets Manager at $0.40 per secret per month, or to Parameter Store SecureString parameters, where standard parameters are free. I'd use Secrets Manager where you want automatic rotation. To check a repository's history for secrets already committed, run a scanner such as gitleaks (gitleaks detect in the repository).

14. Turn on Security Hub CSPM with standards you chose#

Security Hub CSPM (cloud security posture management) runs continuous checks against your configuration using AWS Config, and gathers findings from GuardDuty and Access Analyzer in one place. It needs the AWS Config recorder on in each Region, with IAM and other global resources recorded in one Region only, as the Security Hub prerequisites describe. Two settings then decide whether it's useful.

The first is the standards. Turning CSPM on with default standards enables AWS Foundational Security Best Practices and CIS v1.2.0, even though CIS v1.4.0, v3.0.0 and v5.0.0 are available. Decline the defaults and pick the versions you actually want to be measured against:

aws securityhub enable-security-hub --no-enable-default-standards
aws securityhub describe-standards --query 'Standards[].StandardsArn'
aws securityhub batch-enable-standards \
  --standards-subscription-requests StandardsArn=${STANDARDS_ARN}

Pass each ARN you want from the describe-standards output. The second setting is the home Region.

Match the home Region to where IAM is recorded

If you use central configuration or cross-Region aggregation, and your home Region differs from the Region recording global resources, the IAM controls sit at WARNING or DISABLED and never evaluate. On the estate I worked on, that is exactly where they sat until the home Region was moved to match.

Verify that the IAM. controls show PASSED or FAILED; DISABLED or WARNING means they aren't running.

CSPM costs $0.0010 per check for the first 100,000 checks, and the first 10,000 finding ingestions a month are free, per CSPM pricing. AWS Config is billed separately at $0.003 per configuration item recorded (Config pricing). There's a 30-day trial, and the console shows estimated costs during the trial.

AWS also sells a newer unified Security Hub, priced differently: the Essentials plan is $3.75 per resource unit, where one unit is one EC2 instance, 12 Lambda functions, 18 ECR images or 125 IAM users and roles (unified pricing). Make sure you're signing up for the product you meant.

Do as you grow: multi-account guardrails#

One account means one blast radius: a mistake in a test stack can reach production, and everyone who needs test access gets a door into prod. Once you have more than a couple of people shipping, move to AWS Organizations with at least separate production and non-production accounts, keep the management account for billing and policies only, and apply the guardrails below.

15. Turn on centralized root access#

Your security tooling will flag "root MFA missing" on member accounts. Accounts created in Organizations now start with no root credentials, but older and invited accounts often still have a password, and anyone who controls that root email inbox can reset it. The fix is centralized root access, which removes member root credentials and lets the management account run privileged root tasks when one is needed.

aws organizations enable-aws-service-access --service-principal iam.amazonaws.com
aws iam enable-organizations-root-credentials-management
aws iam enable-organizations-root-sessions

Once member root credentials are removed, AWS evaluates those accounts as not applicable for root MFA rules, so the Security Hub root MFA control (IAM.9) stops failing for them.

16. Attach a baseline SCP#

A service control policy (SCP) caps what principals in member accounts can do, whatever their IAM permissions say. A baseline SCP stops anyone, including an attacker with an admin role, from leaving the organization, switching off detection or acting as root. The SCP guide covers the mechanics, and AWS's deny-changes-to-security-services examples go further than this one.

baseline-scp.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyLeaveOrg",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    },
    {
      "Sid": "ProtectDetection",
      "Effect": "Deny",
      "Action": [
        "cloudtrail:StopLogging",
        "cloudtrail:DeleteTrail",
        "cloudtrail:UpdateTrail",
        "cloudtrail:PutEventSelectors",
        "guardduty:DeleteDetector",
        "guardduty:UpdateDetector",
        "guardduty:DisassociateFromMasterAccount",
        "securityhub:DisableSecurityHub",
        "securityhub:DisassociateFromAdministratorAccount",
        "access-analyzer:DeleteAnalyzer"
      ],
      "Resource": "*",
      "Condition": {
        "ArnNotLike": {
          "aws:PrincipalArn": ["arn:aws:iam::*:role/your-security-admin-role"]
        }
      }
    },
    {
      "Sid": "DenyRootUser",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::*:root" },
        "Null": { "aws:AssumedRoot": "true" }
      }
    }
  ]
}

The Null condition exempts privileged root sessions started from the management account (item 15), so centralized root tasks keep working. The root statement follows AWS's own example.

Replace your-security-admin-role with the name of the role your security tooling uses. If you use Control Tower, add arn:aws:iam::*:role/AWSControlTowerExecution to the same ArnNotLike list. Attach the policy to a test OU (organizational unit, a folder of accounts) first, watch CloudTrail for AccessDenied errors from legitimate work, then widen it.

Control Tower has one more catch: registering an OU attaches its own guardrails, not your baseline SCP, so compare attached policies with a governed OU. I cover that gap in how to move an AWS account to another organization.

17. Restrict Regions with an SCP#

A Region-deny SCP limits accounts to an allowed list, so resources can't appear in Regions nobody watches. Start from the Region-controls/Deny-access-to-AWS-based-on-the-requested-AWS-region.json example in the aws-samples SCP examples repository, the IAM Region-deny example, or the Control Tower Region deny control.

Inventory S3 and every Regional service, not only EC2

S3 operations resolve to the bucket's own Region. If a bucket lives outside your allowed list, the policy makes it unmanageable. On the estate I worked on, a pre-check that only looked at EC2 missed buckets in another Region. The policy was detached within minutes and only a sandbox was affected, but the pre-check now sweeps S3 bucket locations and the other Regional services before any attach, not EC2 alone.

If one team needs an extra Region, attach a variant policy to its OU rather than widening a shared one, which would loosen it for production too.

18. Check RDS snapshot sharing#

Resource control policies (RCPs) set an organization-wide data perimeter for supported services, but they don't cover RDS, so check database snapshot sharing directly:

for id in $(aws rds describe-db-snapshots --snapshot-type manual \
    --query 'DBSnapshots[].DBSnapshotIdentifier' --output text); do
  aws rds describe-db-snapshot-attributes --db-snapshot-identifier "$id" \
    --query "DBSnapshotAttributesResult.DBSnapshotAttributes[?AttributeName=='restore'].AttributeValues[]" \
    --output text | grep -qw all && echo "PUBLIC: $id"
done

A restore attribute value of all means the snapshot is public. For Aurora, run the same check with describe-db-cluster-snapshots and describe-db-cluster-snapshot-attributes. Repeat per Region.

19. Hold settings in place with declarative policies#

Some controls are states, not actions: S3 Block Public Access on, IMDSv2 required, AMIs and snapshots never shared publicly. Declarative policies hold those settings at a fixed value across the organization, enforced by the service itself. SCP vs RCP vs declarative policies compares all three policy types, RCPs included.

Verify the controls actually work#

When a check fails, suspect the check first. On the estate I worked on, I logged more than a dozen cases where a "failure" turned out to be a bug in the tooling, not drift in the account. The habits that came out of it:

  • Sort before you diff. AWS returns policy JSON with keys and arrays in no fixed order. Sort both recursively before comparing a deployed policy with the one in your repository, or every comparison reports drift.
  • Normalize identifiers. Some APIs return full ARNs where others return bare IDs. aws controltower list-enabled-baselines, for example, returns ARNs. Compare like with like.
  • Ask the right account. Before declaring a service absent, query the delegated administrator account. Organization-wide findings often live only there.
  • Treat errors as unknown, not clean. I've seen a rate-limited code search report "no matches" because every throttled call came back empty. A sweep must tell "checked and clean" apart from "the check failed".
  • Include a control case. Run every check against one resource you know is compliant and one you know isn't. If both pass, the check is broken.

A small helper for the first habit:

normalize_policy.py
import json


def normalize(value):
    """Recursively sort dict keys and list items so two policies compare equal."""
    if isinstance(value, dict):
        return {key: normalize(value[key]) for key in sorted(value)}
    if isinstance(value, list):
        items = [normalize(item) for item in value]
        return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
    return value


def same_policy(deployed: str, expected: str) -> bool:
    return normalize(json.loads(deployed)) == normalize(json.loads(expected))

What it costs#

You can't price this without your own usage numbers, so price from the model and let the trials measure it. Every figure below comes from AWS's pricing pages; check them for your Region.

Service What you pay for Free allowance or trial
CloudTrail Additional event copies, S3 storage First copy of management events free
GuardDuty $4.00 per million CloudTrail events, $1.00 per GB flow and DNS logs (first tier, us-east-1), plus protection plans 30-day trial
Access Analyzer Unused access: $0.20 per role or user per month External access analysis free
Security Hub CSPM $0.0010 per check (first 100,000) 10,000 finding ingestions a month free; 30-day trial
AWS Config $0.003 per configuration item recorded No free tier
Secrets Manager $0.40 per secret per month Parameter Store standard parameters free
AWS Budgets Action-enabled budgets beyond the first two Monitoring free

Pricing models only. Your total depends on activity, account count and Regions.

The only worked totals I'd quote are AWS's own: 70 principals in Access Analyzer's unused-access analysis cost $14 a month, and the AWS Config pricing page walks through its own example. For everything else, start the GuardDuty and Security Hub CSPM trials in the same week and take their real usage to whoever approves the spend.

Troubleshooting#

Symptom Likely cause Fix
Security Hub IAM controls stuck at WARNING or DISABLED Home Region differs from the Region recording global resources Align the home Region with the AWS Config global-resource Region (item 14)
Access Analyzer shows no findings in a member account Organization analyzer findings are visible only in the account that owns the analyzer Look there; don't create a duplicate analyzer (item 9)
No GuardDuty emails after sample findings SNS topic policy doesn't allow EventBridge to publish, or the subscription isn't confirmed Fix the topic policy and confirm the subscription (item 8)
Duplicate CloudTrail charges after Control Tower enrollment Old account-level trails still running alongside the organization trail Remove the redundant trails (item 7)
EC2 launches fail after an IMDS change Enforcement on, with templates still asking for IMDSv1 Update templates to require IMDSv2, then re-enable enforcement (item 12)
AccessDenied on an S3 bucket after a Region SCP Bucket lives in a Region outside the allow list Detach or adjust the policy, then inventory S3 in every account (item 17)
Public website bucket stopped working Account-level Block Public Access overrides the bucket policy Serve it through CloudFront, or exclude that account (item 10)
No anomaly alerts ever arrive Default monitor threshold of $100 and 40% is above your spend pattern Lower the alert threshold (item 6)

The full checklist#

Copy this into your tracker; it adds three related tasks to the 19 controls. Tick an item only after its verify step passes.

  • Root user: MFA on, no access keys, group email address
  • Security alternate contact set to a group address
  • People sign in through IAM Identity Center; no human IAM users with passwords
  • Credential report reviewed; old access keys deactivated, then deleted
  • CI pipelines on OIDC; old CI keys' last-used dates have stopped moving
  • Monthly budget with alerts; anomaly alert threshold lowered
  • Multi-Region (or organization) CloudTrail trail logging
  • GuardDuty on in every allowed Region; sample finding reached the inbox; unused protection plans off
  • Access Analyzer external access analyzer in every Region; findings reviewed
  • S3 Block Public Access on at the account level
  • EBS encryption by default on in every Region
  • IMDSv2 required as the account default; enforcement planned
  • SSH closed to the internet; Session Manager in use
  • Secrets moved to Secrets Manager or Parameter Store; repository history scanned
  • Security Hub CSPM on, standards chosen, IAM controls showing PASSED or FAILED
  • AWS Backup plan running; one restore tested
  • Organizations with separate prod and non-prod accounts
  • Centralized root access on
  • Baseline SCP tested on an OU, then attached; every Control Tower OU carries it
  • Region SCP attached after a full S3 and Regional-service inventory
  • No RDS or Aurora snapshots shared publicly
  • Declarative policies holding S3, IMDS and public-sharing settings

Conclusion#

You now have a working order for the controls that matter most from the AWS Startup Security Baseline, plus the multi-account guardrails it leaves out: identity this week, detection this month, guardrails as you add accounts. The first tier costs nothing; the second has a real but measurable bill. The decision to bring to whoever owns the budget: approve the free items now, and approve the GuardDuty and Security Hub CSPM trials with a date to review their usage.

Whatever you turn on, run its verify step. A control that is enabled but not evaluating looks exactly like one that works, right up until you need it. If you're moving to more than one account, read SCP vs RCP vs declarative policies before writing your first guardrail, and GitHub Actions OIDC trust policy for AWS before retiring your CI keys.

If you'd rather have someone walk your accounts against this list, I do short AWS security and governance reviews: written findings, prioritized fixes and the commands to verify each one. Consulting starts at $75 an hour (see rates). The first step is a free 20-minute intro call, or you can request a quote for a security review directly.

Frequently asked questions

What should a small team secure first in AWS?

Start with identity. Put MFA on the root user, delete any root access keys, and stop using IAM users with long-lived access keys for people: give them IAM Identity Center sign-in instead. Then turn on a multi-Region CloudTrail trail and a budget alert. Those few steps close some of the most common ways small AWS accounts get taken over or run up a surprise bill.

How much do AWS security services cost for a small team?

It depends on activity and account count, so price it from the model rather than a guess. External access analysis in IAM Access Analyzer and the first copy of CloudTrail management events are free. GuardDuty bills per million CloudTrail events and per GB of flow and DNS logs, Security Hub CSPM per check, and AWS Config per configuration item. Each offers a 30-day trial so you can measure first.

Do member accounts in AWS Organizations need root MFA?

Yes, unless you remove the credentials. AWS requires root MFA on every account type. Accounts created in AWS Organizations now start with no root credentials, but older and invited member accounts may still have them. The cleaner fix is centralized root access: delete member root credentials from the management account and run privileged root tasks from there. Always protect the management account root with MFA.

Is it safe to restrict AWS Regions with an SCP?

Only after you inventory every Regional service in every account, not only EC2. S3 buckets are the usual trap: bucket operations go to the bucket's own Region, so a bucket outside the allowed list becomes unmanageable once the policy lands. Attach the policy to a test OU first, watch CloudTrail for AccessDenied errors, then widen it one OU at a time.

Why are my Security Hub IAM controls not evaluating?

Security Hub CSPM can show controls as disabled or warning instead of failed when AWS Config does not record IAM resources in that Region. AWS recommends recording global resources in one Region only, and if you use cross-Region aggregation that Region should be your Security Hub home Region. Pick the home Region to match where global resources are recorded, then confirm the IAM controls report pass or fail.

Found this useful? Share it

AWSDevOpsSecurity

Written by Shahid Yousuf

Senior Software Engineer building secure, scalable web applications, AI solutions and cloud infrastructure for businesses worldwide. Work with me or follow along via RSS.

Shahid Yousuf

Senior Software Engineer

I build and consult on software across web, mobile, cloud and AI. Have something in mind? Let's talk.

Hire me Book a free intro call See case studies

Get new posts

No newsletter or email list. Follow along in any feed reader.

GitHub Actions OIDC Trust Policy for AWS

GitHub now issues two OIDC subject formats. Here is a trust policy that accepts both, a way to prove it against every repository, and a safe order for retiring keys.

AWS15 min read

SCP vs RCP vs Declarative Policies in AWS

Deny a verb or assert a state? A practical guide to choosing between SCPs, RCPs and declarative policies, with examples and a safe rollout checklist.

AWS13 min read

Move an AWS Account to Another Organization

The order of operations for moving an AWS account between organizations: invite from the destination, enroll in Control Tower, then close the sign-on, guardrail and old-role gaps.

AWS12 min read

Have a project in mind?

Share your goals, timeline and any constraints, whether you need it built or want expert advice. I read every message myself, as the engineer who would build it, and reply with a clear view on approach, scope and next steps.

Hire me