← WritingData Engineering

AWS security for data engineers: seventeen recipes, worked through

· 37 min read

awssecurityiams3kmsclouddata-platform

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.json

Create 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 table

Now 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.json

Create 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 IsLogging

By 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-id

Launch 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-default

Part 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.json

Create 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 Credentials

The 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_TOKEN

5. 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.json

Now 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 Credentials

The 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-policy

Now 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-required

The 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-policy

7. 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.csv

Column 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 table

Findings 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-access

Part 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 text

where 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 table

The 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 SimRole

10. 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 match

The 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_POLICY

The 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 policy

Keep 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
done

The 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/info

One 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-1

Notice 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.json

Validate 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/
# AccessDenied

The 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.json

The 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
# AccessDenied

The 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.json

The 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.json

Trusting 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 table

Their 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.json

Validate 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"    # 403

Clean-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

#RecipeThe habit it becomes
1Account-level public access blockNo bucket can be made public; Access Analyzer proves it
2CloudTrail with a locked-down bucketEverything below is on the record, including S3 data events
3EBS encryption by defaultEncryption is a region setting, not a per-volume decision
4Create and assume a roleNobody holds long-lived keys; identity lives in trust policies
5MFA on role assumptionA stolen password is not enough to become the role
6Account password policyFew users, strong floor, permissions on groups
7Credential report and unused accessStale keys and idle roles are found, rotated, and removed
8Generate a policy from CloudTrailLeast privilege is derived from evidence, not guessed
9Simulate a policyTest a policy the way you test code, before attaching it
10Permissions boundaryTeams create their own roles inside a cap you set
11Service control policiesLogging and regions are protected from the account's own admins
12Session ManagerNo SSH, no bastion, no keys; shells are IAM-authorised and logged
13Secrets ManagerPasswords exist once; roles fetch them at runtime
14S3 endpoint policyThe private path to S3 leads only to your own buckets
15TLS and KMS on data bucketsData is readable only with your key, only over TLS
16Cross-account role with external IDSharing is a role you own, not a policy full of strangers
17CloudFront in front of S3The 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.

Share

Related