Account security baseline checklist
What “good enough” looks like on day one.
A new AWS (Amazon Web Services) account arrives the way a flat does when the builders hand back the keys. The walls are up, the doors close, and nothing is switched on. No smoke alarm. No lock on the back gate. It looks finished, and it is not safe to move into yet.
Day one baseline is the handover inspection. A fixed list of things you check and switch on before any workload lands, applied the same way to every account, whether that account is production or a sandbox someone begged for on a Friday afternoon. The value is in the sameness. A checklist that changes per account is a preference. A checklist that never changes is a control you can audit.
The awkward part is that the defaults are not neutral. Several of them are working against you from the first minute.
What You Actually Get on Day One
The account ships with a root user, the original login created when somebody typed in a card number. Treat it as the master key the letting agent cut before the locks were changed: it opens every door, and the ordinary house rules do not reach it. In AWS terms, no IAM (Identity and Access Management, the service that decides who is allowed to call what) policy attached to that user can hold it back, because the root user is the account itself. It starts with a password and no MFA (multi-factor authentication, a second proof of identity on top of that password) until somebody adds one. For logging you get CloudTrail, the audit log that writes down every API call (Application Programming Interface, the machine-to-machine request that every console click turns into), but only in its free form, called Event history: management events, kept 90 days, one region at a time, readable in the console, delivered nowhere you control, and blind to data events such as s3:GetObject, a read of a single file out of S3 (Simple Storage Service, the object store). You do not get GuardDuty, the threat detector that reads that audit log and your network traffic. You do not get AWS Config, which records how every resource is set up and re-checks it against rules. You do not get Security Hub, which scores you against a catalogue of controls and now wears the label Security Hub CSPM (cloud security posture management) in the console.
You also get a default VPC (Virtual Private Cloud, your own fenced-off slice of AWS networking) in every enabled region, and that one bites hardest. Each default VPC has an internet gateway attached and subnets that hand a public IPv4 address to anything launched into them without being asked. An engineer testing a build agent in ap-southeast-1 does not believe they are putting a machine on the public internet. AWS does it on their behalf.
Two more per-region toggles start in the unsafe position. EBS (Elastic Block Store, the virtual hard disks bolted onto instances) default encryption is off, so a volume created carelessly and snapshotted later is a plaintext copy of your data sitting in an account. And IMDSv1 (Instance Metadata Service version one, the unauthenticated address 169.254.169.254 that software on a machine calls to fetch that machine's role credentials) is still permitted. Think of it as an intercom on the inside wall that hands out the safe combination to anyone who presses the button. That default is the mechanism behind a whole genre of cloud breach: an application with an SSRF flaw (server-side request forgery, where an attacker tricks a server into fetching a URL of the attacker's choosing) is talked into fetching the metadata address, and the response body comes back holding working AWS credentials. IMDSv2 makes the caller send a PUT request for a short-lived session token before anything else, which most SSRF tricks cannot manage.
# Account baseline: every account, before any workload lands.# Skip an item only with a dated exception object: owner, reason, expiry.## Audit and detection- [ ] Organization CloudTrail trail, multi-region, log file validation on- [ ] get-trail-status read for every trail, not only describe-trails- [ ] Local backup trail into an account the workload team cannot delete from- [ ] GuardDuty detector in EVERY enabled region, not only the ones you deploy to- [ ] Config recorder on, conformance pack attached, delivery bucket locked down- [ ] Security Hub on with AWS Foundational Security Best Practices (FSBP)- [ ] EventBridge rule routing HIGH and CRITICAL findings to a ticket queue## Identity- [ ] Root MFA on, zero root access keys, root never used for daily work- [ ] Centralised root credentials management on across member accounts- [ ] Root sessions enabled for the handful of tasks that still need root- [ ] No IAM users holding long-lived AKIA keys for apps: OIDC and roles only- [ ] Break-glass role, MFA required, alarm on every AssumeRole- [ ] Permissions boundary on any role that developers can create themselves## Data and network- [ ] Account-level S3 Block Public Access, all four settings true- [ ] EBS default encryption per region, customer-managed KMS key where required- [ ] Default VPCs reviewed, deleted in regions where nothing runs- [ ] No security group open to 0.0.0.0/0 on 22, 3389, or a database port- [ ] IMDSv2 required by account default AND in every launch template## Guardrails and cost- [ ] SCP denies StopLogging, disabling GuardDuty/Config/Hub, and LeaveOrganization- [ ] SCP pins activity to approved regions- [ ] Budget with a named alert owner (mining hits the invoice before anything else)
Check What Is True, Not What the Ticket Says
Every line above has a command that answers it in seconds, and running them is the whole difference between a baseline and a belief. Start with the audit log, because every control after it depends on that one working.
aws cloudtrail describe-trails \--query 'trailList[].{name:Name,multiRegion:IsMultiRegionTrail,org:IsOrganizationTrail,validation:LogFileValidationEnabled}'
[{"name": "org-audit-trail","multiRegion": true,"org": true,"validation": true}]
That reads like a pass. Multi-region, so no region goes dark. Organization-wide, so member accounts feed the same log. Log file validation on, so a tampered file can be spotted afterwards. Now ask the second question, the one most checklists never ask.
aws cloudtrail get-trail-status \--name arn:aws:cloudtrail:eu-west-1:123456789012:trail/org-audit-trail \--query '{logging:IsLogging,lastDelivery:LatestDeliveryTime,error:LatestDeliveryError}'
{"logging": false,"lastDelivery": "2026-07-19T02:14:41.113000+00:00","error": null}
The trail is there. It has written nothing for eight days. This is the burglar alarm still screwed to the wall with the battery quietly out. StopLogging switches a trail off and leaves the object exactly where it was, name and settings intact, which is precisely why an attacker who cannot delete a trail will happily stop one instead. describe-trails cannot tell the difference. get-trail-status can. Any check that reads configuration without reading state will wave through a trail that has been silent for a week, which is also why cloudtrail:StopLogging belongs in the organisation-level deny list rather than in a wiki page about good behaviour.
Next comes the mistake that hides in almost every audit: reading one region and reporting on all of them. GuardDuty is per region. A detector in eu-west-1 says nothing about sa-east-1. Loop over the regions the account has actually enabled.
for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); doprintf '%-16s %s\n' "$r" "$(aws guardduty list-detectors --region "$r" --query 'length(DetectorIds)')"done
ap-south-1 1eu-north-1 1eu-west-3 0eu-west-2 1eu-west-1 1ap-northeast-3 0ap-northeast-2 1ap-northeast-1 1sa-east-1 0ca-central-1 0ap-southeast-1 1ap-southeast-2 1eu-central-1 1us-east-1 1us-east-2 1us-west-1 1us-west-2 1
Four regions with no detector, and nobody noticed because nobody deploys there. Stolen credentials do not consult your deployment map. The pattern in real incidents is boringly consistent: the mining fleet goes up in the region you are not watching, because the attacker checked first and you were not watching.
aws ec2 describe-regions returns only the regions this account has enabled, which is the right list to iterate over. It is also the right list to shrink: an SCP (service control policy, an organisation-level cap that sits above the account and cannot be edited from inside it) that denies activity outside your approved regions turns seventeen checks into two. Keep the detectors on in the denied regions anyway. An SCP produces a pile of AccessDenied events that you want somebody watching, and it never applies to the organisation's management account at all.The Account-Wide Switches Worth Arguing About
S3 public access is the control people most often assume is already handled. Ask the account directly.
aws s3control get-public-access-block --account-id 123456789012
An error occurred (NoSuchPublicAccessBlockConfiguration) when calling the GetPublicAccessBlock operation: The public access block configuration was not found
Since April 2023, new buckets are created with block public access on and ACLs (access control lists, the older per-object permission system) disabled, which is where the false confidence comes from. A per-bucket default is still a default, and defaults protect the well-behaved caller. Anyone holding s3:PutBucketPublicAccessBlock can switch it off at create time, a bucket built before that change never had it, and a Terraform module copied from a 2021 blog post will cheerfully disable it for you. The account-level setting is a different animal. It is not an IAM policy, but it behaves the way a good guardrail does: a ceiling checked on every request, which no bucket policy and no module below it can lift.
aws s3control put-public-access-block --account-id 123456789012 \--public-access-block-configuration \BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true \&& aws s3control get-public-access-block --account-id 123456789012
{"PublicAccessBlockConfiguration": {"BlockPublicAcls": true,"IgnorePublicAcls": true,"BlockPublicPolicy": true,"RestrictPublicBuckets": true}}
Identity comes next, and one call covers the headline questions.
aws iam get-account-summary \--query 'SummaryMap.{RootMFA:AccountMFAEnabled,RootKeys:AccountAccessKeysPresent,Users:Users,Roles:Roles}'
{"RootMFA": 1,"RootKeys": 0,"Users": 4,"Roles": 37}
Read the first two as flags rather than counts. RootMFA is 1 when the root user has an MFA device and 0 when it does not. RootKeys has to stay 0 for the life of the account, because a root access key is a permanent password for the one identity that IAM policies cannot touch, and no workload has an honest reason to hold one. Users: 4 in an account that is supposed to run entirely on federation and roles is the finding worth chasing. Generate the credential report and look at what those four are carrying, especially any key starting AKIA, the prefix that marks a long-lived IAM user key, with a recent last-used date.
It pays to know exactly what stops a call, because baselines get argued about on this precise point. When a request lands, AWS gathers every policy that applies and works through them in a fixed order. An explicit Deny anywhere wins on the spot, whatever else says yes. Then, inside an organisation, the SCP has to allow the action, and so does any RCP (resource control policy, the same idea aimed at the resource rather than the caller). Then the permissions boundary on the role, if it has one. Then the session policy handed over at assume time, then the identity policy on the role or user, then any policy attached to the resource being touched. A missing allow at any of those layers is a deny. Two consequences shape a baseline: an SCP can restrain the root user of a member account even though IAM policies never can, and an SCP does not restrain the management account, which is the argument for running nothing of value there.
If the account sits inside an organisation you can go further than checking. Two separate switches, both thrown from the management account once IAM has trusted access in Organizations. The first strips root passwords, access keys, signing certificates and MFA devices out of member accounts entirely. The second lets you borrow root back for a few minutes when you genuinely need it.
aws iam enable-organizations-root-credentials-managementaws iam enable-organizations-root-sessionsaws iam list-organizations-features --query 'EnabledFeatures'
{"EnabledFeatures": ["RootCredentialsManagement"],"OrganizationId": "o-aa111bb222"}{"EnabledFeatures": ["RootCredentialsManagement","RootSessions"],"OrganizationId": "o-aa111bb222"}["RootCredentialsManagement","RootSessions"]
A member account with no root credentials cannot have its root password phished, because there is nothing left to phish. That beats any alarm on root login, since an alarm tells you afterwards and a missing credential prevents the event. With RootSessions on, the rare genuine need is served by aws sts assume-root, which issues a session scoped to one of a small set of AWS-managed task policies, such as unlocking an S3 bucket policy somebody wrote themselves out of. Narrow, short-lived, and logged, rather than a standing password in a vault.
Instance Defaults You Set Once Per Region
Two commands close the metadata and disk gaps that new accounts ship with.
aws ec2 modify-instance-metadata-defaults --region eu-west-1 \--http-tokens required --http-put-response-hop-limit 2 --http-endpoint enabledaws ec2 get-instance-metadata-defaults --region eu-west-1aws ec2 get-ebs-encryption-by-default --region eu-west-1
{"Return": true}{"AccountLevel": {"HttpTokens": "required","HttpPutResponseHopLimit": 2,"HttpEndpoint": "enabled","ManagedBy": "account"}}{"EbsEncryptionByDefault": false}
Two details decide whether the metadata setting helps you at all. It fills a gap rather than overruling anyone: it applies to instances launched after you set it, and only when the launch request stays silent about metadata options. A launch template with HttpTokens=optional written into it keeps producing IMDSv1 machines, and the twenty instances already running carry on exactly as before until somebody walks aws ec2 modify-instance-metadata-options down the list one instance at a time. An account that has never set this returns an empty AccountLevel block, which is the shape you are hunting for in an audit. The hop limit is a real trade-off rather than a bigger-is-better dial. The metadata service stamps that number onto the reply packet as its time-to-live, and every network hop knocks one off, so a container sitting behind a Docker bridge network is one hop further away than the instance itself: a limit of 1 starves it, 2 feeds it, and every value past that widens the circle of things that can reach credentials nobody meant to share.
The EBS answer came back false, which is both the default and a gap. One command per region closes it, and it covers new volumes only, so anything already unencrypted stays that way until someone re-creates it.
aws ec2 enable-ebs-encryption-by-default --region eu-west-1
{"EbsEncryptionByDefault": true}
Make It a Factory, Then Prove the Factory Works
Doing this by hand is fine for one account and hopeless by the fourth. The checklist belongs in code that the vending pipeline applies before anyone gets access, whether that is your own Terraform or a Control Tower and AFT (Account Factory for Terraform, the AWS blueprint runner that builds accounts to a template) setup.
# Applied once per enabled region by the account vending pipeline.resource "aws_guardduty_detector" "this" {enable = truefinding_publishing_frequency = "FIFTEEN_MINUTES"}resource "aws_ebs_encryption_by_default" "this" {enabled = true}resource "aws_ec2_instance_metadata_defaults" "this" {http_tokens = "required"http_put_response_hop_limit = 2http_endpoint = "enabled"}
Terraform cannot create providers on the fly, so "once per enabled region" means either one aliased provider block per region or the same root module run in a loop with AWS_REGION set. Both are ugly. Pick one, write down which, and make the pipeline fail loudly the day a new region is enabled and nobody updated the list. That gap is exactly how the four zeroes in the earlier output happened.
A pipeline that applies cleanly has proved the API calls succeeded. It has proved nothing about whether a finding in this account ever reaches a human. Test the whole path with a fake finding on vending day, the way a landlord presses the test button on the smoke alarm instead of trusting the green light.
aws guardduty create-sample-findings \--detector-id 12abc34d567e8fa901bc2d34e56789f0 \--finding-types "UnauthorizedAccess:EC2/SSHBruteForce" "CryptoCurrency:EC2/BitcoinTool.B!DNS"sleep 300aws securityhub get-findings \--filters '{"ProductName":[{"Value":"GuardDuty","Comparison":"EQUALS"}],"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}]}' \--query 'length(Findings)'
2
If that number comes back 0, GuardDuty is running and Security Hub is not receiving from it, and you found out on a fake finding rather than during a real intrusion. The samples arrive with [SAMPLE] on the front of the title, so they are easy to spot and easy to clear afterwards with aws guardduty archive-findings, which stops somebody paging themselves at 2 a.m. over a test. Run the canary on every vend, and treat a failure as a reason to stop vending until the factory is fixed.
What the Baseline Costs, and Where People Cheat
AWS Config is the line item that surprises people. It charges per configuration item recorded and per rule evaluation, and an account with an Auto Scaling group that scales in and out every few minutes produces configuration items all day long. Teams see the bill, switch the recorder off, and lose the drift detection the entire baseline rests on. The honest lever is recording frequency: Config lets you pick continuous or daily per resource type, so the noisy types move to daily and everything else stays continuous. Write the decision down, because a resource type you stop recording also blanks the Config rules that evaluate it and the Security Hub controls sitting on top of those rules. You are buying money with blind spots, and that trade is only acceptable when it is deliberate and dated.
GuardDuty is priced on volume too: the CloudTrail management events it analyses, plus VPC flow logs and DNS query logs, plus whichever protection plans you switch on. Each account gets a free trial with a running usage estimate attached to it. Look at that estimate during the trial rather than after the first invoice, and size your protection plans then.
The other cost is human, and it is the one that kills baselines. A tight baseline will break something at an inconvenient hour: an SCP denies a region a team genuinely needed, account block public access stops a static site going live, required IMDSv2 breaks a software library nobody has upgraded since 2019. Loosening the baseline is the wrong response, because the loosening always outlives the incident. The right one is a break-glass role that demands MFA, alarms on every assume, and gets reviewed the following morning, paired with exceptions recorded as dated objects with an owner and an expiry. That turns "disable GuardDuty for the demo" into something with a name, a deadline and a person, instead of a quiet gap nobody remembers creating.
Try This
Take one non-production account and answer three questions with commands rather than memory. Is every trail actually logging? Does every enabled region have a detector? Is account-level block public access set? Write each failure to a file, one line apiece, and put a name against every line before you close the laptop.
aws cloudtrail describe-trails --query 'trailList[].TrailARN' --output text \| tr '\t' '\n' \| while read -r arn; doprintf '%-6s %s\n' "$(aws cloudtrail get-trail-status --name "$arn" --query IsLogging)" "$arn"done
false arn:aws:cloudtrail:eu-west-1:123456789012:trail/org-audit-trailtrue arn:aws:cloudtrail:eu-west-1:123456789012:trail/local-backup-trail
One line of that output is a control that has been dead for a week in an account which passes its checklist on paper. Finding it takes about ninety seconds. Finding it during an incident, when the log you need covers everything except the days that matter, takes considerably longer and costs considerably more.
aws cloudtrail describe-trails shows one trail with IsMultiRegionTrail: true and LogFileValidationEnabled: true. What has that actually proved about the account's audit log?s3:GetObject are off by default and billed separately. A trail records management events until you add event selectors for data events.StopLogging leaves the trail object exactly where it was. It still appears, fully configured, and records nothing at all.aws ec2 modify-instance-metadata-defaults --region eu-west-1 --http-tokens required and it returns {"Return": true}. Twenty instances are already running in that region, and a launch template in the same account sets HttpTokens=optional. Which instances now require IMDSv2?aws ec2 modify-instance-metadata-options.HttpTokens in the launch request wins, so that template keeps producing IMDSv1 machines until somebody edits it.guardduty list-detectors across every enabled region and four regions return 0. Nothing is deployed in those regions and the account was vended last week. What do you do?ec2:CreateVpc rebuilds one in a minute. It blocks nothing.Takeaway
The trap worth remembering here: a pass in one region is not a pass. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.