Every data platform on AWS has a security story, and most of them start
the same way. A Spark job fails with AccessDenied on a bucket it read
yesterday. Someone fixes it by attaching AdministratorAccess to the
role "for now". A year later, that role is what every pipeline runs as,
an access key for it is in a Jupyter notebook on a laptop, and nobody
can say which of the forty buckets it touches actually need it.
None of that happens because engineers are careless. It happens because IAM is usually learned by collision: you meet it when it blocks you, and the fastest way past it is to grant more than you need. This post is the walkthrough I wish I had been given early. Seventeen recipes, each a handful of CLI commands, in the order I would apply them to a new account: the account-wide settings that prevent the worst day first, then identity, then permissions, then the machines and the buckets. None of it is exotic. All of it turns into a habit you will use weekly.
Two things before we start. First, run all of this in a sandbox account, not in one that carries production data. Second, each section ends with its clean-up commands. Skipping clean-up is how sandbox accounts turn into the thing this post is trying to prevent.
export AWS_REGION=us-east-1
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
PRINCIPAL_ARN=$(aws sts get-caller-identity --query Arn --output text)
RANDOM_STRING=$(aws secretsmanager get-random-password \
--exclude-punctuation --exclude-uppercase --password-length 6 \
--require-each-included-type --query RandomPassword --output text)The third line is the identity you are running as right now. Most recipes below need it, and it is the first thing to look at whenever something is denied: who is being denied is half the answer. The random suffix is for bucket names, which have to be globally unique.
Part 1: stop the worst day first
1. Block public access for the whole account, and prove it
The public S3 bucket is the cloud incident with the most headlines, and it is also the one with the simplest fix. This recipe deliberately creates the problem first so you can watch the detection and the remediation work.
Make a bucket and give it a bucket policy that allows anyone to read objects:
BUCKET="public-demo-${RANDOM_STRING}"
aws s3api create-bucket --bucket "$BUCKET"
cat > public-read.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "PublicRead", "Effect": "Allow", "Principal": "*",
"Action": ["s3:GetObject", "s3:GetObjectVersion"],
"Resource": "arn:aws:s3:::${BUCKET}/*" }
]
}
JSON
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://public-read.jsonCreate an account-level Access Analyzer and ask it about the bucket. It
reasons over the bucket policy and reports a finding whose isPublic is
true:
ANALYZER_ARN=$(aws accessanalyzer create-analyzer \
--analyzer-name account-analyzer --type ACCOUNT \
--query arn --output text)
aws accessanalyzer list-findings --analyzer-arn "$ANALYZER_ARN" \
--filter "{\"resource\":{\"contains\":[\"${BUCKET}\"]}}" \
--query "findings[].[resource,status,isPublic]" --output tableNow the fix, and it goes on the account, not the bucket. Four flags, all true:
aws s3control put-public-access-block --account-id "$AWS_ACCOUNT_ID" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3control get-public-access-block --account-id "$AWS_ACCOUNT_ID"
aws accessanalyzer start-resource-scan --analyzer-arn "$ANALYZER_ARN" \
--resource-arn "arn:aws:s3:::${BUCKET}"Run the findings query again and the status moves to RESOLVED. The
bucket policy is still there and it no longer matters, because
RestrictPublicBuckets makes S3 ignore public grants in it. Try to
apply the public policy to a new bucket now and the API refuses.
The same four flags exist per bucket, and you will see them in every CloudFormation template you inherit. That is fine as belt and braces, but the account-level setting is the one that matters: with it on, no bucket can be made public by a policy, an ACL, or a well-meaning script. Keep the analyzer running afterwards; it also finds cross-account access you did not intend, which in a data platform is usually a bucket policy written for one vendor and never revoked.
Clean up (keep the account block and the analyzer; delete the demo bucket):
aws s3api delete-bucket-policy --bucket "$BUCKET"
aws s3api delete-bucket --bucket "$BUCKET"2. Turn on CloudTrail with a locked-down log bucket
Almost every recipe below ends with "check CloudTrail". The console shows ninety days of management events without any setup, but a trail writes every event to S3 as files you keep, query with Athena, and feed to the policy generator in recipe 8. Set it up before anything else so the rest of your work is on the record.
The log bucket needs a policy that lets the CloudTrail service write to it and nothing else. Scope it to your trail's ARN so another account's trail cannot write into your bucket.
TRAIL_BUCKET="cloudtrail-${AWS_ACCOUNT_ID}-${RANDOM_STRING}"
TRAIL_ARN="arn:aws:cloudtrail:${AWS_REGION}:${AWS_ACCOUNT_ID}:trail/account-trail"
aws s3api create-bucket --bucket "$TRAIL_BUCKET"
cat > trail-bucket-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "AclCheck", "Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:GetBucketAcl",
"Resource": "arn:aws:s3:::${TRAIL_BUCKET}",
"Condition": { "StringEquals": { "AWS:SourceArn": "${TRAIL_ARN}" } } },
{ "Sid": "Write", "Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::${TRAIL_BUCKET}/AWSLogs/${AWS_ACCOUNT_ID}/*",
"Condition": { "StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control",
"AWS:SourceArn": "${TRAIL_ARN}" } } }
]
}
JSON
aws s3api put-bucket-policy --bucket "$TRAIL_BUCKET" \
--policy file://trail-bucket-policy.jsonCreate a multi-region trail with log file validation, which writes a signed digest so you can prove later that nobody edited the logs:
aws cloudtrail create-trail --name account-trail \
--s3-bucket-name "$TRAIL_BUCKET" \
--is-multi-region-trail --enable-log-file-validation
aws cloudtrail start-logging --name account-trail
aws cloudtrail get-trail-status --name account-trail --query IsLoggingBy default a trail records management events only: API calls that create, change, or describe resources. Reads and writes of individual S3 objects are data events, and they are off by default because they are high volume. For a data platform you want them on for the buckets that matter, and an advanced event selector does that by prefix:
aws cloudtrail put-event-selectors --trail-name account-trail \
--advanced-event-selectors '[{
"Name": "S3 data events on data buckets",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::data-"] }
]
}]'Validate by looking up the call you just made:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=CreateTrail \
--max-results 1 --query "Events[0].[EventName,Username,EventTime]"In a real estate the log bucket lives in a separate, locked-down account, so that an attacker who wins the workload account cannot erase their own footprints. Recipe 11 adds the guardrail that stops anyone in this account from switching the trail off.
Clean up:
aws cloudtrail stop-logging --name account-trail
aws cloudtrail delete-trail --name account-trail
aws s3 rm "s3://${TRAIL_BUCKET}" --recursive
aws s3api delete-bucket --bucket "$TRAIL_BUCKET"3. Encrypt every EBS volume by default with your own KMS key
EBS encryption is free, has no measurable performance cost, and is off by default in a new account. Turn it on at the account and region level so that nobody has to remember, and point it at a key you own so that you control who can use it and can see every use in CloudTrail.
KMS_KEY_ID=$(aws kms create-key \
--description "Default EBS encryption key" \
--query KeyMetadata.KeyId --output text)
KMS_KEY_ARN=$(aws kms describe-key --key-id "$KMS_KEY_ID" \
--query KeyMetadata.Arn --output text)
aws kms create-alias --alias-name alias/ebs-default \
--target-key-id "$KMS_KEY_ID"
aws ec2 enable-ebs-encryption-by-default
aws ec2 modify-ebs-default-kms-key-id --kms-key-id alias/ebs-default
aws ec2 get-ebs-encryption-by-default
aws ec2 get-ebs-default-kms-key-idLaunch an instance after this and its volumes are encrypted with your key without anyone passing a flag. Three things to know. The setting is per region, so repeat it everywhere you run. It does not touch volumes that already exist; those need a snapshot copied with encryption and a volume created from the copy. And a customer-managed key can be disabled, which makes every volume encrypted under it unreadable. That is a real capability (it is how you kill-switch data in a compromised account) and a real foot-gun, which is why the clean-up schedules deletion with a seven-day window rather than deleting immediately.
Recipe 15 reuses this key for S3, so keep it until the end.
Clean up, at the very end:
aws ec2 modify-ebs-default-kms-key-id --kms-key-id alias/aws/ebs
aws ec2 disable-ebs-encryption-by-default # only in a sandbox
aws kms disable-key --key-id "$KMS_KEY_ID"
aws kms schedule-key-deletion --key-id "$KMS_KEY_ID" --pending-window-in-days 7
aws kms delete-alias --alias-name alias/ebs-defaultPart 2: identity
4. Assume a role instead of keeping keys
The single habit that improves an AWS estate the most is this: humans and jobs assume roles; they do not hold long-lived access keys. A role has no credentials of its own. Whoever is allowed to assume it gets temporary credentials that expire, and the list of who is allowed lives in one place: the role's trust policy.
Create a trust policy that allows your current principal to assume the
role. The file uses a placeholder so it can be committed without an
account ID in it; sed fills it in.
cat > assume-role-policy-template.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "PRINCIPAL_ARN" },
"Action": "sts:AssumeRole"
}
]
}
JSON
sed -e "s|PRINCIPAL_ARN|${PRINCIPAL_ARN}|g" \
assume-role-policy-template.json > assume-role-policy.jsonCreate the role, attach a managed policy, and assume it:
ROLE_ARN=$(aws iam create-role \
--role-name DevRole \
--assume-role-policy-document file://assume-role-policy.json \
--query Role.Arn --output text)
aws iam attach-role-policy \
--role-name DevRole \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
aws sts assume-role \
--role-arn "$ROLE_ARN" \
--role-session-name dev-session \
--query CredentialsThe output is three values: an access key ID, a secret access key, and a
session token, plus an expiry. Export them as AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN and every subsequent CLI
call runs as the role. Run aws sts get-caller-identity again and the
ARN now says assumed-role/DevRole/dev-session. That session name is
what shows up in CloudTrail, which is the point: you can tell which
human used the shared role.
Two things bite people here. If you are already running as an assumed
role, get-caller-identity returns an assumed-role ARN, and trust
policies cannot reference those. Put the underlying role's ARN in the
trust policy instead. And PowerUserAccess is deliberately generous for
a first recipe. It allows everything except IAM administration, which
is why Part 3 exists.
In daily practice you never export these variables by hand. A named
profile in ~/.aws/config with role_arn and source_profile does the
assumption for you, and IAM Identity Center does it for a whole
organisation. But you should do it by hand once, so that when an Airflow
worker's boto3 session mysteriously expires after an hour you know
exactly what expired.
Keep the role for the next recipe. Clean up later:
aws iam detach-role-policy --role-name DevRole \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
aws iam delete-role --role-name DevRole
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN5. Require MFA to assume the role
A role trusted by an IAM user is only as safe as that user's password and access key. The fix is one condition on the trust policy: the assumption is allowed only when the caller's session was opened with multi-factor authentication.
cat > mfa-trust-template.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "PRINCIPAL_ARN" },
"Action": "sts:AssumeRole",
"Condition": { "Bool": { "aws:MultiFactorAuthPresent": "true" } }
}
]
}
JSON
sed -e "s|PRINCIPAL_ARN|${PRINCIPAL_ARN}|g" \
mfa-trust-template.json > mfa-trust.json
aws iam update-assume-role-policy --role-name DevRole \
--policy-document file://mfa-trust.jsonNow the plain assumption from recipe 4 fails with AccessDenied. To
assume the role you pass your MFA device and a current code, and the
resulting session carries the MFA flag:
MFA_ARN=$(aws iam list-mfa-devices \
--query "MFADevices[0].SerialNumber" --output text)
aws sts assume-role --role-arn "$ROLE_ARN" \
--role-session-name mfa-session \
--serial-number "$MFA_ARN" --token-code 123456 \
--query CredentialsThe condition uses Bool with true, not BoolIfExists. That is
deliberate: if the key is absent, which is what happens when a role
assumes another role, the statement does not match and the assumption
is denied. The looser BoolIfExists form belongs in Deny statements,
where you want a missing key to be treated as "no MFA" without breaking
service-to-service chains.
This only applies to IAM users. When people sign in through IAM Identity Center, MFA is enforced by the identity provider before AWS ever sees them, which is one more reason to move humans there and leave this condition for the break-glass users that remain.
6. Enforce a password policy for the humans who remain
You should have very few IAM users. Federated identity through IAM Identity Center is the right answer for people, and roles are the right answer for workloads. But every account has a few users left: a break glass account, a legacy vendor integration, a service that predates federation. For those, the account password policy is the floor.
aws iam update-account-password-policy \
--minimum-password-length 32 \
--require-symbols \
--require-numbers \
--require-uppercase-characters \
--require-lowercase-characters \
--allow-users-to-change-password \
--max-password-age 90 \
--password-reuse-prevention 24
aws iam get-account-password-policyNow create a group, give it a narrow managed policy, create a user, and give the user a console password generated by Secrets Manager's random-password API rather than by a human:
aws iam create-group --group-name BillingReaders
aws iam attach-group-policy --group-name BillingReaders \
--policy-arn arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess
aws iam create-user --user-name billing-reader
aws iam add-user-to-group --user-name billing-reader \
--group-name BillingReaders
PASSWORD=$(aws secretsmanager get-random-password \
--password-length 32 --require-each-included-type \
--query RandomPassword --output text)
aws iam create-login-profile --user-name billing-reader \
--password "$PASSWORD" --password-reset-requiredThe validation is the useful part. Try to give a second user a short
password and IAM refuses with a PasswordPolicyViolation. That refusal
is what you are buying: the policy does not make anyone choose a good
password, it makes the API unable to accept a bad one.
Permissions go on the group, never on the user. When the user leaves, you delete one user; when the job changes, you edit one group.
Keep the user for the next recipe. Clean up later:
aws iam delete-login-profile --user-name billing-reader
aws iam remove-user-from-group --user-name billing-reader \
--group-name BillingReaders
aws iam detach-group-policy --group-name BillingReaders \
--policy-arn arn:aws:iam::aws:policy/AWSBillingReadOnlyAccess
aws iam delete-group --group-name BillingReaders
aws iam delete-user --user-name billing-reader
aws iam delete-account-password-policy7. Find stale access keys and unused roles
Every account accumulates credentials nobody uses: the key a contractor created in 2022, the role for a pipeline that was decommissioned, the user whose password was last used before the last reorganisation. Each one is a way in that nobody is watching. Two tools find them.
The credential report is a CSV of every IAM user with the age and last use of their password and both access keys. Generate it, decode it, and pull out the active keys:
aws iam generate-credential-report
aws iam get-credential-report --query Content --output text \
| base64 -d > credential-report.csv
# user, key 1 rotated, key 1 last used
awk -F, 'NR > 1 && $9 == "true" { print $1, $10, $11 }' credential-report.csvColumn nine is "access key 1 active"; ten and eleven are when it was last rotated and last used. A key rotated two years ago and last used never is the finding you are looking for. Give the demo user a key and watch it appear:
KEY_ID=$(aws iam create-access-key --user-name billing-reader \
--query AccessKeyMetadata.AccessKeyId --output text)
aws iam get-access-key-last-used --access-key-id "$KEY_ID"Rotation is a four-step dance, and doing it in this order means nothing
breaks mid-way: create the new key, deploy it, set the old key
Inactive and wait a week for anything that was still using it to
complain, then delete it.
aws iam update-access-key --user-name billing-reader \
--access-key-id "$KEY_ID" --status Inactive
aws iam delete-access-key --user-name billing-reader --access-key-id "$KEY_ID"The credential report covers users only. For roles, and for the permissions inside a role that nobody exercises, IAM Access Analyzer has a second analyzer type, unused access, which reads the last-accessed data for every principal and reports anything idle for longer than a threshold:
UNUSED_ARN=$(aws accessanalyzer create-analyzer \
--analyzer-name unused-access --type ACCOUNT_UNUSED_ACCESS \
--configuration '{"unusedAccess":{"unusedAccessAge":90}}' \
--query arn --output text)
aws accessanalyzer list-findings-v2 --analyzer-arn "$UNUSED_ARN" \
--query "findings[].[resource,findingType,status]" --output tableFindings come in four kinds: unused roles, unused user passwords, unused
user access keys, and unused permissions inside an active role. The
last kind is the interesting one for a data platform: it tells you the
ETL role has s3:DeleteBucket and has never called it, which is the
evidence you need to take it away. This analyzer is priced per role and
user analysed, unlike the free one from recipe 1, so run it on the
accounts that matter rather than everywhere.
Clean up the unused-access analyzer when you are done reading its findings:
aws accessanalyzer delete-analyzer --analyzer-name unused-accessPart 3: getting permissions right
8. Generate a least-privilege policy from what actually ran
Least privilege is easy to recommend and hard to author. Nobody knows in advance the exact 23 actions a Glue job needs. The honest approach is to let the job run under a broad role for a while, then ask CloudTrail what it actually called. IAM Access Analyzer does exactly that: give it a principal, the trail from recipe 2, and a time window, and it writes a policy from the observed activity.
Access Analyzer needs a service role that can read the trail's bucket. Then:
JOB_ID=$(aws accessanalyzer start-policy-generation \
--policy-generation-details "principalArn=${ROLE_ARN}" \
--cloud-trail-details file://cloudtrail-details.json \
--query jobId --output text)
aws accessanalyzer get-generated-policy --job-id "$JOB_ID" \
--query "generatedPolicyResult.generatedPolicies[0].policy" \
--output textwhere cloudtrail-details.json names the trail, the access role, and
the window:
{
"trails": [
{ "cloudTrailArn": "arn:aws:cloudtrail:us-east-1:111111111111:trail/account-trail",
"allRegions": true }
],
"accessRole": "arn:aws:iam::111111111111:role/AccessAnalyzerTrailReader",
"startTime": "2026-08-01T00:00:00Z",
"endTime": "2026-09-01T00:00:00Z"
}The generated policy is a draft, not a verdict. It contains every action the role used in the window, with placeholders where the resource ARN could be narrowed. Read it, narrow the resources, and delete the actions that were one-off debugging. Then attach it and put the broad policy away. For a data pipeline this is the most honest audit you can produce: "this job reads these two prefixes and writes this one table" is a sentence you can now prove.
The catch: the generator works from management events. It will tell you which services saw data-level activity, such as S3 object reads, but you write those statements yourself. The data event selectors from recipe 2 are what let you see which objects the job touched.
9. Simulate a policy before you attach it
An IAM policy is code, and you would not deploy code without running it.
The IAM Policy Simulator runs a policy against a list of actions and
tells you allowed or implicitDeny for each, and which statement
matched. It is the fastest way to answer "will this role be able to do
X" without doing X.
Create a role trusted by EC2, attach the read-only EC2 managed policy, and simulate a write and a read:
cat > ec2-trust.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
JSON
aws iam create-role --role-name SimRole \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy --role-name SimRole \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:role/SimRole" \
--action-names ec2:CreateInternetGateway ec2:DescribeInstances \
--query "EvaluationResults[].[EvalActionName,EvalDecision]" \
--output tableThe table says CreateInternetGateway is an implicitDeny and
DescribeInstances is allowed, and for the allowed one the full output
names the matched statement down to the line and column in the policy.
That last detail is what makes the simulator a debugging tool: when
something is allowed that should not be, it tells you which statement to
go and fix.
The simulator evaluates the whole stack: identity policies, permissions boundaries, service control policies, and resource policies. When a job's role looks right but the call is still denied, simulate it with the resource ARN and the answer is usually a boundary or an SCP that nobody told you about.
Clean up:
aws iam detach-role-policy --role-name SimRole \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess
aws iam delete-role --role-name SimRole10. Delegate IAM safely with permissions boundaries
This is the recipe that makes a data platform team self-sufficient without handing them the keys to the account. The problem: engineers need to create roles for their Lambda functions and Glue jobs, but anyone who can create a role can create an admin role. The answer is a permissions boundary: a policy that caps what any role created under it can ever do, regardless of what gets attached to that role.
Three policies do the work, and the picture is worth having in your head before the commands:
First, the boundary itself. It is the ceiling: CloudWatch Logs, a few DynamoDB writes on tables with a name prefix, and reads and writes to buckets with the same prefix.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "Logs", "Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:*:AWS_ACCOUNT_ID:*" },
{ "Sid": "DynamoWrites", "Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem"],
"Resource": "arn:aws:dynamodb:*:AWS_ACCOUNT_ID:table/Team*" },
{ "Sid": "TeamBuckets", "Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::team*/*" }
]
}Second, the policy for the delegated engineer. The statement that
matters is RolesWithBoundary: they may create and modify roles named
Team*, only when the request carries the boundary. Two Deny
statements stop them removing the boundary or editing it.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "DenyBoundaryRemoval", "Effect": "Deny",
"Action": "iam:DeleteRolePermissionsBoundary", "Resource": "*" },
{ "Sid": "IAMRead", "Effect": "Allow",
"Action": ["iam:Get*", "iam:List*"], "Resource": "*" },
{ "Sid": "RolesWithBoundary", "Effect": "Allow",
"Action": ["iam:CreateRole", "iam:DeleteRole", "iam:PutRolePolicy",
"iam:DeleteRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy"],
"Resource": "arn:aws:iam::AWS_ACCOUNT_ID:role/Team*",
"Condition": { "StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::AWS_ACCOUNT_ID:policy/TeamBoundary" } } },
{ "Sid": "PassRoleToLambda", "Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::AWS_ACCOUNT_ID:role/Team*",
"Condition": { "StringLikeIfExists": {
"iam:PassedToService": "lambda.amazonaws.com" } } },
{ "Sid": "Serverless", "Effect": "Allow",
"Action": ["lambda:*", "logs:*", "dynamodb:*", "s3:*"], "Resource": "*" },
{ "Sid": "ProtectBoundary", "Effect": "Deny",
"Action": ["iam:CreatePolicyVersion", "iam:DeletePolicy",
"iam:DeletePolicyVersion", "iam:SetDefaultPolicyVersion"],
"Resource": ["arn:aws:iam::AWS_ACCOUNT_ID:policy/TeamBoundary",
"arn:aws:iam::AWS_ACCOUNT_ID:policy/TeamDelegatePolicy"] }
]
}Create both, plus a delegate role trusted by your principal, then assume the delegate role and try the two cases that matter:
sed -e "s|AWS_ACCOUNT_ID|${AWS_ACCOUNT_ID}|g" boundary-template.json > boundary.json
sed -e "s|AWS_ACCOUNT_ID|${AWS_ACCOUNT_ID}|g" delegate-template.json > delegate.json
aws iam create-policy --policy-name TeamBoundary --policy-document file://boundary.json
aws iam create-policy --policy-name TeamDelegatePolicy --policy-document file://delegate.json
aws iam create-role --role-name TeamDelegateRole \
--assume-role-policy-document file://assume-role-policy.json
aws iam attach-role-policy --role-name TeamDelegateRole \
--policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/TeamDelegatePolicy"
# ... assume TeamDelegateRole as in recipe 4, then as the delegate:
aws iam create-role --role-name TeamLambdaRole \
--assume-role-policy-document file://lambda-trust.json \
--permissions-boundary "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/TeamBoundary"
# succeeds
aws iam attach-role-policy --role-name TeamLambdaRole \
--policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
# succeeds, but the role still cannot do more than the boundary allows
aws iam create-role --role-name TeamUnboundedRole \
--assume-role-policy-document file://lambda-trust.json
# AccessDenied: the request has no boundary, so RolesWithBoundary does not matchThe second command is the one to sit with. The delegate attached
AmazonDynamoDBFullAccess, and it worked, and it did not matter: the
Lambda role's effective permissions are the intersection of its identity
policies and the boundary, so it still cannot delete a table or touch
one not named Team*. Simulate it with recipe 9 and you will see the
boundary in the evaluation. This is how you let a team ship their own
roles at their own pace and still sleep.
Clean-up detaches the policies, deletes the test roles, then deletes the delegate policy and the boundary, in that order. Unset the assumed-role variables first, or you will be trying to delete the boundary as a principal that is explicitly forbidden from doing so.
11. Guardrails nobody in the account can undo: SCPs
Permissions boundaries cap roles that a delegate creates. A service control policy caps the whole account, including its administrators, and it is attached from the organisation's management account, so nobody inside the workload account can remove it. That makes SCPs the right place for the handful of rules that must hold no matter who is having a bad day: the trail stays on, and work stays in the regions you have approved.
You need an AWS Organizations setup with the sandbox account in an organisational unit (OU). SCPs are inert on the management account itself, so attach to the OU, never to the root, while testing.
ROOT_ID=$(aws organizations list-roots --query "Roots[0].Id" --output text)
aws organizations enable-policy-type --root-id "$ROOT_ID" \
--policy-type SERVICE_CONTROL_POLICYThe policy has two statements. The first denies the calls that would
silence CloudTrail. The second denies everything outside the approved
regions, with a NotAction list of global services that have no region
and would otherwise break, and an exemption for one break-glass role.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "ProtectCloudTrail", "Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail", "cloudtrail:PutEventSelectors"],
"Resource": "*" },
{ "Sid": "ApprovedRegionsOnly", "Effect": "Deny",
"NotAction": ["iam:*", "sts:*", "organizations:*", "account:*",
"cloudfront:*", "route53:*", "kms:*", "support:*",
"budgets:*", "cur:*", "health:*", "trustedadvisor:*",
"shield:*", "waf:*", "access-analyzer:*"],
"Resource": "*",
"Condition": {
"StringNotEquals": { "aws:RequestedRegion": ["us-east-1", "eu-west-2"] },
"ArnNotLike": { "aws:PrincipalARN": "arn:aws:iam::*:role/PlatformBreakGlass" } } }
]
}That NotAction list is trimmed; the full list of global services is
in the AWS documentation example for this exact policy and is worth
copying rather than retyping.
POLICY_ID=$(aws organizations create-policy \
--name platform-guardrails --type SERVICE_CONTROL_POLICY \
--description "Keep CloudTrail on; approved regions only" \
--content file://guardrails-scp.json \
--query Policy.PolicySummary.Id --output text)
aws organizations attach-policy --policy-id "$POLICY_ID" \
--target-id "$SANDBOX_OU_ID"Validate from inside the sandbox account, as an administrator. Both calls fail, and the error names the SCP as the reason:
aws cloudtrail stop-logging --name account-trail
# AccessDenied ... with an explicit deny in a service control policy
aws ec2 describe-instances --region ap-southeast-1
# UnauthorizedOperation ... with an explicit deny in a service control policyKeep SCPs few and boring. They are evaluated on every call in every account under the OU, they are hard to debug from inside the account, and every one you add is another reason a pipeline fails at 3 a.m. with an error the on-call engineer cannot fix. Three or four rules that protect logging, regions, and the root user are plenty.
Clean up:
aws organizations detach-policy --policy-id "$POLICY_ID" --target-id "$SANDBOX_OU_ID"
aws organizations delete-policy --policy-id "$POLICY_ID"Part 4: machines and secrets
12. Connect to instances with Session Manager, not SSH
Somewhere in every platform there is a bastion host with port 22 open and
a shared .pem file that has been emailed at least once. Systems
Manager Session Manager removes the whole arrangement. An agent on the
instance opens an outbound HTTPS connection to the SSM service, and you
get a shell through the AWS API, authorised by IAM and logged by
CloudTrail. No inbound ports, no keys, no bastion.
Put the instance in an isolated subnet: no internet gateway, no NAT. It can still reach SSM if the VPC has interface endpoints for the three services the agent talks to. The endpoint security group must allow HTTPS in from the instance's security group.
for svc in ssm ssmmessages ec2messages; do
aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" \
--vpc-endpoint-type Interface \
--service-name "com.amazonaws.${AWS_REGION}.${svc}" \
--subnet-ids "$SUBNET_ID" --security-group-ids "$ENDPOINT_SG" \
--private-dns-enabled
doneThe instance needs an instance profile carrying a role that can talk to SSM. The profile is the thing you attach to an instance; the role is the permissions inside it. Both are required, and forgetting the second step (adding the role to the profile) is the classic reason an instance never appears in Session Manager.
aws iam create-role --role-name SsmInstanceRole \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy --role-name SsmInstanceRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile --instance-profile-name SsmInstanceProfile
aws iam add-role-to-instance-profile \
--instance-profile-name SsmInstanceProfile --role-name SsmInstanceRole
AMI_ID=$(aws ssm get-parameters \
--names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query "Parameters[0].Value" --output text)
INSTANCE_ID=$(aws ec2 run-instances --image-id "$AMI_ID" \
--instance-type t3.nano \
--iam-instance-profile Name=SsmInstanceProfile \
--subnet-id "$SUBNET_ID" --security-group-ids "$INSTANCE_SG" \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ssm-demo}]' \
--query "Instances[0].InstanceId" --output text)That is the arrangement to copy for anything that processes sensitive data: the machine cannot reach the internet and the internet cannot reach it, and you can still get a shell.
HttpTokens=required enforces IMDSv2 on the instance metadata service.
This is not decoration. With IMDSv1, any server-side request forgery
bug in an application on the instance can read the role's credentials
from 169.254.169.254. IMDSv2 requires a session token obtained with a
PUT, which those bugs typically cannot do.
Validate and connect:
aws ssm describe-instance-information \
--query "InstanceInformationList[].InstanceId" --output text
aws ssm start-session --target "$INSTANCE_ID"Inside the session, prove which identity the instance has by asking the metadata service for the instance profile, IMDSv2-style:
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 300")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/infoOne warning worth repeating: Session Manager can log every command and its output to CloudWatch or S3. That is a feature for audit and a hazard for secrets. Do not paste passwords into a logged session.
Keep the instance for the next two recipes. Clean up later: terminate the instance, detach the policy, remove the role from the profile, delete the profile, delete the role, and delete the endpoints.
13. Keep passwords in Secrets Manager and let the role fetch them
This is the recipe data engineers need most and adopt least. The database password for the warehouse should exist in exactly one place, a Secrets Manager secret, and the things that need it should fetch it at runtime using the role they already have. Not in an environment variable baked into an AMI, not in a config file in the repo, not in an Airflow Variable someone pasted in.
Create a secret with a generated value, then a policy that allows reading only that secret, and attach it to the instance role from recipe 12:
SECRET_VALUE=$(aws secretsmanager get-random-password \
--password-length 32 --require-each-included-type \
--query RandomPassword --output text)
SECRET_ARN=$(aws secretsmanager create-secret \
--name warehouse/etl-password \
--description "Password the ETL role uses for the warehouse" \
--secret-string "$SECRET_VALUE" \
--query ARN --output text){
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy", "secretsmanager:ListSecretVersionIds"],
"Resource": "SECRET_ARN" },
{ "Effect": "Allow",
"Action": "secretsmanager:ListSecrets",
"Resource": "*" }
]
}sed -e "s|SECRET_ARN|${SECRET_ARN}|g" \
secret-access-template.json > secret-access.json
aws iam create-policy --policy-name EtlSecretAccess \
--policy-document file://secret-access.json
aws iam attach-role-policy --role-name SsmInstanceRole \
--policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/EtlSecretAccess"The instance is in an isolated subnet, so it needs a fourth interface
endpoint, for secretsmanager, created the same way as the three in
recipe 12. Then open a session and fetch the secret from inside it. No
credentials were configured on the box; the instance profile is the
credential.
aws ssm start-session --target "$INSTANCE_ID"
# inside the session
aws secretsmanager get-secret-value \
--secret-id warehouse/etl-password \
--query SecretString --output text --region us-east-1Notice the resource in the policy is the one secret's ARN, not *. A
role that can read every secret in the account is a role that turns a
small compromise into a large one. The pattern is the same for an EMR
core node, an ECS task, or a Glue job: the role is the identity, the
secret is fetched by name, and nothing sensitive is written anywhere on
disk.
Clean up:
aws secretsmanager delete-secret --secret-id warehouse/etl-password \
--recovery-window-in-days 7
aws iam detach-role-policy --role-name SsmInstanceRole \
--policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/EtlSecretAccess"
aws iam delete-policy \
--policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/EtlSecretAccess"14. Restrict the S3 VPC endpoint to your own buckets
A processing instance in an isolated subnet reaches S3 through a gateway endpoint. By default that endpoint allows access to every bucket in every account, which means a compromised job can copy your data to a bucket the attacker owns, over a private path that never touches the internet and never trips a firewall. The endpoint policy closes that door.
Create the endpoint on the subnet's route table, then attach a policy
that allows full access to buckets owned by your account and read-only
access to the Amazon Linux package repositories, which live in
Amazon-owned buckets and would otherwise break dnf:
S3_ENDPOINT_ID=$(aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" \
--vpc-endpoint-type Gateway \
--service-name "com.amazonaws.${AWS_REGION}.s3" \
--route-table-ids "$ROUTE_TABLE_ID" \
--query VpcEndpoint.VpcEndpointId --output text)
cat > s3-endpoint-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "OwnAccountOnly", "Effect": "Allow", "Principal": "*",
"Action": "s3:*", "Resource": "*",
"Condition": { "StringEquals": { "aws:ResourceAccount": "${AWS_ACCOUNT_ID}" } } },
{ "Sid": "AmazonLinuxRepos", "Effect": "Allow", "Principal": "*",
"Action": "s3:GetObject",
"Resource": ["arn:aws:s3:::al2023-repos-*/*",
"arn:aws:s3:::amazonlinux-2-repos-*/*"] }
]
}
JSON
aws ec2 modify-vpc-endpoint --vpc-endpoint-id "$S3_ENDPOINT_ID" \
--policy-document file://s3-endpoint-policy.jsonValidate from inside a session on the instance. A bucket in your account works; a bucket in any other account is denied even if that bucket's own policy would allow the write:
aws s3 ls "s3://data-${RANDOM_STRING}/"
# listing
aws s3 cp /etc/hostname s3://some-other-accounts-bucket/
# AccessDeniedThe mirror image is a bucket policy on your data buckets that denies
any request not arriving through your endpoint, using the
aws:sourceVpce condition key. Together they say: this data leaves only
through this door, and this door leads only to our own buckets. That is
the shape of every data exfiltration control, and it costs nothing.
Clean up:
aws ec2 delete-vpc-endpoints --vpc-endpoint-ids "$S3_ENDPOINT_ID"Part 5: data buckets
15. Require TLS and your own KMS key on a data bucket
S3 has encrypted every new object at rest by default since 2023, with keys it manages. For a bucket that holds customer data that is not the control you want. You want objects encrypted with a key you own, so that access to the data requires access to the key, so that every decrypt is in CloudTrail, and so that disabling the key makes the data unreadable in an emergency. And you want the bucket to refuse plain HTTP, which the S3 API still accepts.
Create the bucket, set its default encryption to the KMS key from recipe 3 with a bucket key enabled (this caches the data key per bucket and cuts KMS request costs by an order of magnitude on busy buckets), and apply a policy with three deny statements:
DATA_BUCKET="data-${RANDOM_STRING}"
aws s3api create-bucket --bucket "$DATA_BUCKET"
aws s3api put-bucket-encryption --bucket "$DATA_BUCKET" \
--server-side-encryption-configuration "{
\"Rules\": [{
\"ApplyServerSideEncryptionByDefault\": {
\"SSEAlgorithm\": \"aws:kms\", \"KMSMasterKeyID\": \"${KMS_KEY_ARN}\" },
\"BucketKeyEnabled\": true }]}"
cat > data-bucket-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "DenyPlainHttp", "Effect": "Deny", "Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::${DATA_BUCKET}", "arn:aws:s3:::${DATA_BUCKET}/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } } },
{ "Sid": "DenyNonKmsUploads", "Effect": "Deny", "Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::${DATA_BUCKET}/*",
"Condition": { "StringNotEqualsIfExists": {
"s3:x-amz-server-side-encryption": "aws:kms" } } },
{ "Sid": "DenyWrongKey", "Effect": "Deny", "Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::${DATA_BUCKET}/*",
"Condition": { "StringNotEqualsIfExists": {
"s3:x-amz-server-side-encryption-aws-kms-key-id": "${KMS_KEY_ARN}" } } }
]
}
JSON
aws s3api put-bucket-policy --bucket "$DATA_BUCKET" \
--policy file://data-bucket-policy.jsonThe IfExists suffix matters. A client that sends no encryption header
at all gets the bucket default, which is your key, so the condition
should pass when the header is absent and fail only when a client asks
for something else. Without IfExists, every ordinary upload would be
denied.
Validate all three. An upload with no header succeeds and lands under your key; an upload that asks for S3-managed encryption is refused; a listing over plain HTTP is refused:
echo "row" > sample.csv
aws s3 cp sample.csv "s3://${DATA_BUCKET}/"
aws s3api head-object --bucket "$DATA_BUCKET" --key sample.csv \
--query "[ServerSideEncryption, SSEKMSKeyId, BucketKeyEnabled]"
# [ "aws:kms", "arn:aws:kms:...:key/...", true ]
aws s3 cp sample.csv "s3://${DATA_BUCKET}/" --sse AES256
# AccessDenied
aws s3 ls "s3://${DATA_BUCKET}/" --endpoint-url http://s3.amazonaws.com
# AccessDeniedThe key policy on the KMS key now controls who can read the data. A role
with s3:GetObject on this bucket and no kms:Decrypt on this key gets
AccessDenied, which is the whole point, and also the most common
"but the bucket policy allows it" support ticket you will ever field.
Keep the bucket for the next recipe.
16. Share a bucket with another account: a role, an external ID, a key
Sooner or later a vendor, an auditor, or a sister team in another AWS account needs to read from your data bucket. There are two ways to do it. A bucket policy can name their principal directly, but then their identities appear in your policies, their reads of KMS-encrypted objects still need a grant on your key, and ownership of anything they write gets complicated. The cleaner way is a role in your account that their account is allowed to assume. Everything they do runs as your role, under your policies, in your CloudTrail.
The trust policy names their account and requires an external ID, a shared secret that they must present on every assumption. It exists to stop the confused deputy problem: a vendor that serves many customers could otherwise be tricked by one customer into assuming a role in another customer's account. The external ID ties the role to the one customer it was created for.
VENDOR_ACCOUNT_ID=222222222222
EXTERNAL_ID=$(aws secretsmanager get-random-password \
--exclude-punctuation --password-length 32 \
--query RandomPassword --output text)
cat > vendor-trust.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::${VENDOR_ACCOUNT_ID}:root" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "${EXTERNAL_ID}" } } }
]
}
JSON
aws iam create-role --role-name VendorReadRole \
--assume-role-policy-document file://vendor-trust.jsonThe role's own policy is read-only on the one bucket, plus decrypt on the one key that bucket uses. Both halves are necessary: S3 checks the bucket permission and KMS checks the key permission, independently.
cat > vendor-read.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::${DATA_BUCKET}",
"Condition": { "StringLike": { "s3:prefix": ["exports/*"] } } },
{ "Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::${DATA_BUCKET}/exports/*" },
{ "Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "${KMS_KEY_ARN}" }
]
}
JSON
aws iam put-role-policy --role-name VendorReadRole \
--policy-name VendorRead --policy-document file://vendor-read.jsonTrusting the vendor account's root means any principal in their
account that their administrators allow can assume the role. That is
the right boundary: which of their people can use it is their problem
to manage, and you never have to update your trust policy when their
staff changes.
Hand the vendor three things: the role ARN, the external ID, and the prefix. On their side the assumption looks like this:
aws sts assume-role \
--role-arn "arn:aws:iam::111111111111:role/VendorReadRole" \
--role-session-name vendor-nightly-pull \
--external-id "$EXTERNAL_ID"Validate from your side with the simulator, without needing their account at all. The export prefix is allowed; the rest of the bucket is not:
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:role/VendorReadRole" \
--action-names s3:GetObject \
--resource-arns "arn:aws:s3:::${DATA_BUCKET}/exports/2026-09.csv" \
"arn:aws:s3:::${DATA_BUCKET}/raw/customers.csv" \
--query "EvaluationResults[].[EvalResourceName,EvalDecision]" --output tableTheir reads show up in your CloudTrail with the session name they chose, under your role. When the contract ends, you delete one role. Nothing in their account, and nothing in your bucket policy, needs to change.
Clean up:
aws iam delete-role-policy --role-name VendorReadRole --policy-name VendorRead
aws iam delete-role --role-name VendorReadRole
aws s3 rm "s3://${DATA_BUCKET}" --recursive
aws s3api delete-bucket --bucket "$DATA_BUCKET"17. Serve a bucket through CloudFront, never directly
The last question: if buckets cannot be public, how do you serve the static site, the documentation, the published data extract? The answer is a private bucket behind a CloudFront distribution that holds the only identity allowed to read it. Users hit CloudFront over HTTPS; CloudFront fetches from S3 as itself; the bucket never has a public grant.
Older guides do this with an Origin Access Identity, which still works. AWS now recommends Origin Access Control (OAC) instead: it signs requests with SigV4, supports every region and SSE-KMS objects, and scopes the bucket policy to one distribution's ARN.
echo "hello" > index.html
SITE_BUCKET="site-${RANDOM_STRING}"
aws s3api create-bucket --bucket "$SITE_BUCKET"
aws s3 cp index.html "s3://${SITE_BUCKET}/"
OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config \
"Name=site-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query OriginAccessControl.Id --output text)The distribution config is a JSON document with the bucket as its only origin, the OAC attached to that origin, and viewers redirected to HTTPS. Trimmed to the parts that matter:
{
"CallerReference": "site-demo-1",
"Comment": "private bucket behind CloudFront",
"Enabled": true,
"DefaultRootObject": "index.html",
"Origins": { "Quantity": 1, "Items": [ {
"Id": "s3-origin",
"DomainName": "S3_BUCKET_NAME.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "OAC_ID",
"S3OriginConfig": { "OriginAccessIdentity": "" }
} ] },
"DefaultCacheBehavior": {
"TargetOriginId": "s3-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] },
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
},
"ViewerCertificate": { "CloudFrontDefaultCertificate": true }
}That cache policy ID is AWS's managed CachingOptimized policy. The
viewer protocol policy is redirect-to-https; the other option,
allow-all, serves the same content over plain HTTP if asked, which
quietly undoes the point of the recipe. Create the distribution, then
write the bucket policy that lets this distribution and nothing else
read objects:
sed -e "s|S3_BUCKET_NAME|${SITE_BUCKET}|g" -e "s|OAC_ID|${OAC_ID}|g" \
distribution-template.json > distribution.json
DISTRIBUTION_ID=$(aws cloudfront create-distribution \
--distribution-config file://distribution.json \
--query Distribution.Id --output text)
DOMAIN_NAME=$(aws cloudfront get-distribution --id "$DISTRIBUTION_ID" \
--query Distribution.DomainName --output text)
cat > site-bucket-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "AllowCloudFrontRead", "Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${SITE_BUCKET}/*",
"Condition": { "StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::${AWS_ACCOUNT_ID}:distribution/${DISTRIBUTION_ID}" } } }
]
}
JSON
aws s3api put-bucket-policy --bucket "$SITE_BUCKET" \
--policy file://site-bucket-policy.jsonValidate both directions. Through CloudFront the object is served; the direct S3 URL returns 403.
aws cloudfront wait distribution-deployed --id "$DISTRIBUTION_ID"
curl -s "https://${DOMAIN_NAME}/index.html" # hello
curl -s -o /dev/null -w "%{http_code}\n" \
"https://${SITE_BUCKET}.s3.amazonaws.com/index.html" # 403Clean-up for a distribution takes patience: disable it, wait for the
change to deploy, then delete it with its current ETag, delete the
origin access control, empty and delete the bucket.
The seventeen, in one table
| # | Recipe | The habit it becomes |
|---|---|---|
| 1 | Account-level public access block | No bucket can be made public; Access Analyzer proves it |
| 2 | CloudTrail with a locked-down bucket | Everything below is on the record, including S3 data events |
| 3 | EBS encryption by default | Encryption is a region setting, not a per-volume decision |
| 4 | Create and assume a role | Nobody holds long-lived keys; identity lives in trust policies |
| 5 | MFA on role assumption | A stolen password is not enough to become the role |
| 6 | Account password policy | Few users, strong floor, permissions on groups |
| 7 | Credential report and unused access | Stale keys and idle roles are found, rotated, and removed |
| 8 | Generate a policy from CloudTrail | Least privilege is derived from evidence, not guessed |
| 9 | Simulate a policy | Test a policy the way you test code, before attaching it |
| 10 | Permissions boundary | Teams create their own roles inside a cap you set |
| 11 | Service control policies | Logging and regions are protected from the account's own admins |
| 12 | Session Manager | No SSH, no bastion, no keys; shells are IAM-authorised and logged |
| 13 | Secrets Manager | Passwords exist once; roles fetch them at runtime |
| 14 | S3 endpoint policy | The private path to S3 leads only to your own buckets |
| 15 | TLS and KMS on data buckets | Data is readable only with your key, only over TLS |
| 16 | Cross-account role with external ID | Sharing is a role you own, not a policy full of strangers |
| 17 | CloudFront in front of S3 | The bucket is private; the distribution is the only reader |
Where this leads
Notice how much of the list is really one idea seen from different sides.
A role is an identity without a secret. A policy is what that identity
may do. A boundary is what any identity a delegate creates may ever do,
and an SCP is what anyone in the account may ever do. An instance
profile, a Secrets Manager policy, a KMS key policy, a vendor role, and
a CloudFront origin access control are all ways of giving a thing an
identity so that it never needs a secret. Once that clicks, most
AccessDenied errors stop being mysterious. The question becomes: which
identity made this call, which policies applied to it, and which one
said no. Recipe 9 answers it in one command.
The order above is the order I would do them in for a new account. Part 1 is an afternoon, prevents the worst day, and never needs touching again. Part 2 removes long-lived keys from the estate, which is where most real incidents start. Part 3 is the way every new pipeline role gets built, and recipes 10 and 11 arrive when the team is large enough that one platform engineer creating every role has become the bottleneck. Parts 4 and 5 are the recipes you reach for when the platform starts holding data that matters, which, if you are reading this, it probably already does.