<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shahid Yousuf</title>
    <description>The latest articles on DEV Community by Shahid Yousuf (@shahidyousuf).</description>
    <link>https://dev.to/shahidyousuf</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F476783%2F9b9545d8-28c9-473d-9571-2b8097ac7466.jpeg</url>
      <title>DEV Community: Shahid Yousuf</title>
      <link>https://dev.to/shahidyousuf</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shahidyousuf"/>
    <language>en</language>
    <item>
      <title>GitHub Actions OIDC Trust Policy for AWS</title>
      <dc:creator>Shahid Yousuf</dc:creator>
      <pubDate>Fri, 09 Oct 2026 06:02:53 +0000</pubDate>
      <link>https://dev.to/shahidyousuf/github-actions-oidc-trust-policy-for-aws-380n</link>
      <guid>https://dev.to/shahidyousuf/github-actions-oidc-trust-policy-for-aws-380n</guid>
      <description>&lt;p&gt;Plenty of AWS accounts have an IAM user that exists only so a GitHub Actions workflow can push an image or run a deploy. Its access key sits in a repository secret and never expires. GitHub Actions OIDC is the better answer.&lt;/p&gt;

&lt;p&gt;The trust policy is where this goes wrong, and it got harder this year. Create a repository in your GitHub organization today and its Actions jobs present a different identity to AWS than the repository next to it. The older one says &lt;code&gt;repo:example-org/api:ref:refs/heads/main&lt;/code&gt;, while the new one says &lt;code&gt;repo:example-org@1234567/api@456789:ref:refs/heads/main&lt;/code&gt;. A trust policy written only for the first format denies the second, and the error tells you nothing about why.&lt;/p&gt;

&lt;p&gt;I designed around that trap when I built GitHub OIDC roles for CI image pushes in a multi-account AWS estate, trusted by every repository across two GitHub organizations, so the long-lived access keys those pipelines use can be retired. &lt;strong&gt;A GitHub Actions OIDC AWS trust policy that survives this needs three things: accept both subject formats, pin the organization by its numeric ID, and prove the policy against every repository before it ships.&lt;/strong&gt; After that you switch workflows over additively and retire keys only after watching them go quiet. Done in that order, every step can be rolled back by reverting one workflow file while the old keys still work. More of the governance work is in my &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;AWS multi-account governance case study&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How GitHub OIDC reaches AWS
&lt;/h2&gt;

&lt;p&gt;OIDC (OpenID Connect, a standard for one system to vouch for an identity to another) replaces the stored key. GitHub signs a short-lived token describing the job, and AWS trades it for temporary credentials if the role's trust policy accepts what the token says.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shahidyousuf.dev/blog/github-actions-oidc-aws-trust-policy/" rel="noopener noreferrer"&gt;View this diagram in the original post&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;STS issues credentials only when the token's claims satisfy the role's trust policy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The job needs &lt;code&gt;id-token: write&lt;/code&gt; permission to request the token. The &lt;a href="https://github.com/aws-actions/configure-aws-credentials" rel="noopener noreferrer"&gt;&lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt;&lt;/a&gt; action (currently v6) does the exchange. Its default audience is &lt;code&gt;sts.amazonaws.com&lt;/code&gt;, and its default session is one hour, adjustable from 15 minutes to 12 hours. GitHub's &lt;a href="https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws" rel="noopener noreferrer"&gt;guide to OIDC in AWS&lt;/a&gt; covers the provider setup. If you lead a team, ask how many IAM users exist only so CI can reach AWS; each is a candidate for OIDC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two subject formats, side by side
&lt;/h2&gt;

&lt;p&gt;Trust policies match on the &lt;code&gt;sub&lt;/code&gt; claim. GitHub's &lt;a href="https://docs.github.com/en/actions/reference/security/oidc" rel="noopener noreferrer"&gt;OIDC reference&lt;/a&gt; builds it from the repository plus one context: an environment, a pull request, a branch or a tag.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job context&lt;/th&gt;
&lt;th&gt;Name-only format&lt;/th&gt;
&lt;th&gt;Immutable format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Job references an environment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG/REPO:environment:NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG@ORG-ID/REPO@REPO-ID:environment:NAME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull request event, no environment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG/REPO:pull_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG@ORG-ID/REPO@REPO-ID:pull_request&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch, no environment, not a pull request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG/REPO:ref:refs/heads/BRANCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG@ORG-ID/REPO@REPO-ID:ref:refs/heads/BRANCH&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag, no environment, not a pull request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG/REPO:ref:refs/tags/TAG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:ORG@ORG-ID/REPO@REPO-ID:ref:refs/tags/TAG&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Per GitHub's reference, the pull request, branch and tag forms apply only when the job references no environment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/" rel="noopener noreferrer"&gt;immutable format&lt;/a&gt; is the default for repositories created after July 15, 2026. Older repositories keep the name-only format unless the organization or repository opts in, and the immutable format isn't available on GitHub Enterprise Server.&lt;/p&gt;

&lt;p&gt;A rename flips the format&lt;/p&gt;

&lt;p&gt;Repository renames and transfers after July 15, 2026 also move to the immutable format. A name-only policy stops matching a repository the day someone renames it.&lt;/p&gt;

&lt;p&gt;IDs matter because the OIDC specification requires a subject to be "locally unique and never reassigned", and a recycled name breaks that. GitHub separates name and ID with &lt;code&gt;@&lt;/code&gt;, which cannot appear in a GitHub name, after researchers showed an earlier &lt;code&gt;-&lt;/code&gt; delimiter was squattable&lt;sup id="fnref:1"&gt;1&lt;/sup&gt;. AWS's &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-wif" rel="noopener noreferrer"&gt;condition key reference&lt;/a&gt; agrees:&lt;/p&gt;

&lt;p&gt;AWS IAM documentation: use immutable identifiers, not names&lt;/p&gt;

&lt;p&gt;A name that is freed by renaming or deletion can be claimed by a different account. Policies that rely solely on mutable name-based claims (such as repository or actor) could grant access to unintended identities.&lt;/p&gt;

&lt;p&gt;If your organization has created or renamed a repository since July 15, 2026, check which format each repository now uses. Then ask whether your AWS roles accept both before the next new one needs to deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the GitHub OIDC trust policy
&lt;/h2&gt;

&lt;p&gt;When you create or update a role that trusts GitHub, &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html#idp_oidc_Create_GitHub" rel="noopener noreferrer"&gt;IAM checks&lt;/a&gt; that &lt;code&gt;token.actions.githubusercontent.com:sub&lt;/code&gt; is present and is not solely a wildcard or null, and fails the request otherwise. That rules out trusting by organization ID alone.&lt;/p&gt;

&lt;p&gt;You cannot trust by organization ID alone&lt;/p&gt;

&lt;p&gt;A policy with only &lt;code&gt;repository_owner_id&lt;/code&gt; is rejected. &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc_secure-by-default.html" rel="noopener noreferrer"&gt;Existing roles are not re-evaluated&lt;/a&gt; until someone edits their trust policy, so an old, loose role keeps working until its next edit fails.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; condition lists both formats for your organization, and a separate &lt;code&gt;repository_owner_id&lt;/code&gt; condition pins the organization by its numeric ID. AWS &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-logic-multiple-context-keys-or-values.html" rel="noopener noreferrer"&gt;evaluates&lt;/a&gt; multiple values for one key as a logical OR and separate keys or operators as a logical AND, so the token must match one of the &lt;code&gt;sub&lt;/code&gt; patterns and carry the right owner ID and the right audience. The slash in each pattern matters: &lt;code&gt;repo:example-org/*&lt;/code&gt; doesn't match &lt;code&gt;repo:example-orgX/...&lt;/code&gt;, because the character after &lt;code&gt;example-org&lt;/code&gt; must be &lt;code&gt;/&lt;/code&gt;. The four variants run from widest to narrowest. Account &lt;code&gt;123456789012&lt;/code&gt;, &lt;code&gt;example-org&lt;/code&gt; and the IDs are placeholders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Org-wide&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;trust-policy-org-wide.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org@1234567/*"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One repository&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;trust-policy-one-repo.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"456789"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org/example-repo:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org@1234567/example-repo@456789:*"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One environment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;trust-policy-environment.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org/example-repo:environment:production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org@1234567/example-repo@456789:environment:production"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Branch main&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;trust-policy-ref-main.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:repository_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"456789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org/example-repo:ref:refs/heads/main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"repo:example-org@1234567/example-repo@456789:ref:refs/heads/main"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the image-push roles I built, I chose org-wide. Each environment account got one OIDC provider and one narrowly scoped role, which can do only the push and pull actions the existing CI key's IAM user uses, only against its own account's registries, with the session capped at four hours. The worst case is a compromised workflow in any repository overwriting an image tag that production pulls. Tag immutability on the registries, or one role per registry, narrows that further.&lt;/p&gt;

&lt;p&gt;I rejected a named repository allow-list, because an allow-list is only as complete as the inventory behind it. One built from default-branch workflows misses repositories that run only on other branches, every new repository needs a policy change before it can push, and a name-only list denies every new-format repository. I'd rather accept breadth in the trust policy and keep the permission policy narrow.&lt;/p&gt;

&lt;p&gt;AWS warns that &lt;code&gt;sub&lt;/code&gt; must be limited to your organization or repository, and strongly recommends protection rules wherever you use GitHub environments. &lt;strong&gt;For a role that can change production, I go further: scope it by environment or ref, behind protection rules.&lt;/strong&gt; Org-wide trust is a deliberate exception I make only when the permission policy is narrow.&lt;/p&gt;

&lt;p&gt;I also left GitHub's subject customization (&lt;code&gt;include_claim_keys&lt;/code&gt;) alone. A custom template changes the &lt;code&gt;sub&lt;/code&gt; for every repository that adopts it, so every trust policy those repositories reach has to change in step with it, and the default format needs no such coordination. Whatever width you choose, ask for a one-line note per role: who can assume it, and the worst case if a workflow is compromised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it against every repository
&lt;/h2&gt;

&lt;p&gt;A trust policy that looks right is still only a claim, so before shipping I checked it against every repository in both organizations. The first step reads each repository's subject template. The &lt;a href="https://docs.github.com/en/rest/actions/oidc" rel="noopener noreferrer"&gt;REST endpoint&lt;/a&gt; returns &lt;code&gt;use_default&lt;/code&gt;, &lt;code&gt;include_claim_keys&lt;/code&gt;, &lt;code&gt;use_immutable_subject&lt;/code&gt; and &lt;code&gt;sub_claim_prefix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;audit-oidc-subjects.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="k"&gt;for &lt;/span&gt;org &lt;span class="k"&gt;in &lt;/span&gt;example-org example-org-two&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gh repo list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$org&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt; 1000 &lt;span class="nt"&gt;--json&lt;/span&gt; nameWithOwner &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].nameWithOwner'&lt;/span&gt; |
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; repo&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      &lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/actions/oidc/customization/sub"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, .use_default, (.use_immutable_subject // false), (.include_claim_keys // [] | join(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;))] | @tsv"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\tERROR\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;done
done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gh&lt;/code&gt; needs a token with the &lt;code&gt;repo&lt;/code&gt; scope, and &lt;code&gt;--limit 1000&lt;/code&gt; caps the list silently, so raise it for larger organizations. Any repository with a custom template or the immutable flag is one your policy must handle. When I ran this check across both organizations, several recently created repositories already used the new format.&lt;/p&gt;

&lt;p&gt;The second step is a simulation. I generated the &lt;code&gt;sub&lt;/code&gt; each repository would send for a fixed set of trigger shapes and matched it against the policy's patterns locally. &lt;strong&gt;This was a local pattern-match simulation, not the IAM policy simulator.&lt;/strong&gt; Every real repository was allowed.&lt;/p&gt;

&lt;h1&gt;
  
  
  A minimal version of the local check
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;fnmatchcase&lt;/code&gt; treats &lt;code&gt;*&lt;/code&gt; and &lt;code&gt;?&lt;/code&gt; the way &lt;code&gt;StringLike&lt;/code&gt; does. It also treats &lt;code&gt;[&lt;/code&gt; specially, which &lt;code&gt;StringLike&lt;/code&gt; does not, so this is a sanity check rather than an IAM evaluation.&lt;/p&gt;

&lt;p&gt;simulate_trust.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fnmatch&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fnmatchcase&lt;/span&gt;

&lt;span class="n"&gt;OWNER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-org/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-org@1234567/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OWNER_ID&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fnmatchcase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PATTERNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-org/api:ref:refs/heads/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-org@1234567/api@456789:pull_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-orgx/api:ref:refs/heads/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:other-org/api:ref:refs/heads/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:example-org-fork/api:pull_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:someone@999/example-org@1234567:ref:refs/heads/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1234567&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all cases match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allows alone prove half the policy, so the third step is a set of negative controls: subjects that should never get credentials.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Negative control&lt;/th&gt;
&lt;th&gt;What it imitates&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Look-alike organization name&lt;/td&gt;
&lt;td&gt;Your org name plus one character&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign organization&lt;/td&gt;
&lt;td&gt;Any other GitHub organization&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fork-style owner&lt;/td&gt;
&lt;td&gt;An organization named after yours with a suffix, such as &lt;code&gt;example-org-fork&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedded &lt;code&gt;@&lt;/code&gt; identity&lt;/td&gt;
&lt;td&gt;Another owner whose &lt;code&gt;sub&lt;/code&gt; carries your &lt;code&gt;org@ID&lt;/code&gt; after its own name&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each was denied by the &lt;code&gt;sub&lt;/code&gt; patterns alone, and the owner-ID condition is a second, independent check. The last two steps cover permissions. I pulled the existing key's last 90 days of API calls and confirmed every one fit inside the new role's allowed actions, and &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-policy-validation.html" rel="noopener noreferrer"&gt;IAM Access Analyzer&lt;/a&gt; policy validation returned no errors. Before a trust policy ships, ask for that evidence: every repository simulated, negative cases denied, the existing key's real usage covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switch workflows over additively
&lt;/h2&gt;

&lt;p&gt;The role sits alongside the keys, so you can switch one workflow at a time while the old keys still work as a fallback. For a typical image-push workflow the diff is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before: access keys&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;.github/workflows/push-image.yml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push-image&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws sts get-caller-identity&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After: OIDC&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;.github/workflows/push-image.yml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push-image&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.AWS_ROLE_ARN }}&lt;/span&gt;
          &lt;span class="na"&gt;role-duration-seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws sts get-caller-identity&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that diff matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;contents: read&lt;/code&gt; stays. A &lt;code&gt;permissions&lt;/code&gt; block sets every scope you do not list to none, and &lt;code&gt;actions/checkout&lt;/code&gt; needs read access.&lt;/li&gt;
&lt;li&gt;The role ARN lives in a GitHub environment variable, one per environment, which keeps dev, staging and prod roles apart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;environment: dev&lt;/code&gt; changes the &lt;code&gt;sub&lt;/code&gt;. The job now sends &lt;code&gt;repo:...:environment:dev&lt;/code&gt;, not &lt;code&gt;ref:refs/heads/main&lt;/code&gt;. The org-wide pattern accepts both. A branch-scoped policy would deny it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;role-duration-seconds&lt;/code&gt; must not exceed the role's maximum session duration. I show tags for readability; pin both actions to a full commit SHA in real workflows, as GitHub's own example does.&lt;/p&gt;

&lt;p&gt;Roll out in environment order: dev, then staging, then prod. For each one, verify three ways: the run succeeds, CloudTrail, in the Region the workflow sets as &lt;code&gt;aws-region&lt;/code&gt;, shows an &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; event whose &lt;code&gt;userIdentity.userName&lt;/code&gt; is the actual &lt;code&gt;sub&lt;/code&gt;, and the role's last activity in IAM updates. If anything fails, revert the workflow change, because the keys still work. Ask for one environment at a time, a named rollback and CloudTrail proof, not just a green build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retire the keys, then close the door
&lt;/h2&gt;

&lt;p&gt;Retirement runs per environment. These two commands check when a key was last used and switch it off without deleting it.&lt;/p&gt;

&lt;p&gt;check-and-deactivate-key.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam get-access-key-last-used &lt;span class="nt"&gt;--access-key-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCESS_KEY_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

aws iam update-access-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CI_USER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-key-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCESS_KEY_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--status&lt;/span&gt; Inactive

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence for each environment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm every workflow in that environment uses the role.&lt;/li&gt;
&lt;li&gt;Watch &lt;code&gt;GetAccessKeyLastUsed&lt;/code&gt; through a quiet period, for example seven days, with no key use.&lt;/li&gt;
&lt;li&gt;Deactivate the key, then wait one release cycle.&lt;/li&gt;
&lt;li&gt;Delete the GitHub secrets that held it.&lt;/li&gt;
&lt;li&gt;Delete the key, then the IAM user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deactivate before you delete&lt;/p&gt;

&lt;p&gt;An &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id-credentials-access-keys-update.html" rel="noopener noreferrer"&gt;inactive key&lt;/a&gt; can be reactivated in seconds if a forgotten job surfaces. A deleted key cannot. Also look for keys created outside your infrastructure-as-code state: a &lt;code&gt;terraform destroy&lt;/code&gt; of the user fails while such a key still exists, because IAM will not delete a user that has an access key.&lt;/p&gt;

&lt;p&gt;The last step of the method is a guardrail so new keys don't creep back: an SCP (a service control policy, an organization-level policy that can only deny) that blocks IAM user and key creation for everyone except your landing-zone automation. Stage it before enforcing it.&lt;/p&gt;

&lt;p&gt;scp-deny-iam-user-credentials.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyLongLivedIamUserCredentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateLoginProfile"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/example-landing-zone-automation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/example-break-glass-admin"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With multiple values under a negated operator, AWS evaluates them as a logical NOR: the deny applies unless the caller matches one of the exempt roles. &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html" rel="noopener noreferrer"&gt;SCPs&lt;/a&gt; do not affect the management account or service-linked roles, so they are not a complete fence. Denying &lt;code&gt;CreateAccessKey&lt;/code&gt; also blocks key rotation for any IAM user still in service, so retire those first or exempt them deliberately.&lt;/p&gt;

&lt;p&gt;Before attaching, search CloudTrail for the last 90 days of &lt;code&gt;CreateUser&lt;/code&gt;, &lt;code&gt;CreateAccessKey&lt;/code&gt; and &lt;code&gt;CreateLoginProfile&lt;/code&gt; calls. Every caller you find either moves off keys first or goes on the exemption list. For AWS Control Tower, the exemptions include &lt;code&gt;AWSControlTowerExecution&lt;/code&gt;. Attach to a small OU first. If you are weighing an SCP against the newer policy types, I compare them in &lt;a href="https://shahidyousuf.dev/blog/scp-vs-rcp-declarative-policies/" rel="noopener noreferrer"&gt;SCP vs RCP vs declarative policies&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;"Keys retired" should mean deleted after a watched quiet period, not deactivated and forgotten. Ask for the guardrail to land last, on a test OU first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist
&lt;/h2&gt;

&lt;p&gt;Track the method per environment. Close the ticket only when every box is ticked.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☐ Inventory every IAM user whose keys live in GitHub secrets&lt;/li&gt;
&lt;li&gt;☐ Audit every repository's OIDC subject template with the &lt;code&gt;gh api&lt;/code&gt; script&lt;/li&gt;
&lt;li&gt;☐ One OIDC provider per account; one role per environment, permissions copied from the existing key's real usage&lt;/li&gt;
&lt;li&gt;☐ Trust policy accepts both &lt;code&gt;sub&lt;/code&gt; formats and pins &lt;code&gt;repository_owner_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;☐ Local simulation of every repository, plus negative controls&lt;/li&gt;
&lt;li&gt;☐ Access Analyzer validation clean&lt;/li&gt;
&lt;li&gt;☐ Workflows switched dev, staging, prod, each verified in CloudTrail&lt;/li&gt;
&lt;li&gt;☐ Quiet period observed per key, then deactivate, wait, delete secrets, delete key and user&lt;/li&gt;
&lt;li&gt;☐ Credential-creation SCP staged on a test OU, then enforced&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;Run the audit script against your own organization; it's read-only. If any repository reports &lt;code&gt;use_immutable_subject&lt;/code&gt; as true and your trust policies only list names, you have found a repository that will be denied the first time a workflow there tries to assume one of those roles.&lt;/p&gt;

&lt;p&gt;Then check your CI roles' &lt;code&gt;sub&lt;/code&gt; conditions against the two-format table in &lt;em&gt;Two subject formats, side by side&lt;/em&gt;. If you want to talk through the rollout order before touching a pipeline, &lt;a href="https://shahidyousuf.dev/book/" rel="noopener noreferrer"&gt;book a free intro call&lt;/a&gt;.&lt;/p&gt;




&lt;ol&gt;
&lt;li&gt;Boost Security Labs, &lt;a href="https://labs.boostsecurity.io/articles/sleeper-squats-github-oidc-immutable-subject-claim/" rel="noopener noreferrer"&gt;"Sleeper Squats: How a Hyphen (Almost) Unraveled GitHub's Immutable OIDC Subject Claim"&lt;/a&gt;. ↩
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>SCP vs RCP vs Declarative Policies in AWS</title>
      <dc:creator>Shahid Yousuf</dc:creator>
      <pubDate>Fri, 09 Oct 2026 06:02:48 +0000</pubDate>
      <link>https://dev.to/shahidyousuf/scp-vs-rcp-vs-declarative-policies-in-aws-40ne</link>
      <guid>https://dev.to/shahidyousuf/scp-vs-rcp-vs-declarative-policies-in-aws-40ne</guid>
      <description>&lt;p&gt;Once an AWS organization has more than a handful of accounts, guardrails stop being something each account team sets up for itself. A typical request: S3 Block Public Access switched on in every account, and kept on. AWS Organizations has three policy types that look like they could do that, a service control policy (SCP), a resource control policy (RCP) and a declarative-style policy. Only one of them can actually hold the setting, and picking the wrong one either leaves a gap or blocks your own fix.&lt;/p&gt;

&lt;p&gt;The obvious control is an SCP denying &lt;code&gt;s3:PutBucketPublicAccessBlock&lt;/code&gt;. Check CloudTrail before you write it: in a real estate most calls to that API are Terraform turning protection on. No condition key separates enabling Block Public Access from disabling it, so the deny stops the safe change along with the dangerous one.&lt;/p&gt;

&lt;p&gt;The three types answer different questions. SCPs limit what your own principals can do, RCPs limit how anyone can reach your resources, and declarative policies hold a setting at a fixed value, so the useful question for any guardrail is whether you need to deny a verb or assert a state. The examples come from governing a real multi-account estate, written up as a &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;multi-account AWS governance case study&lt;/a&gt;. Every policy is generic, with placeholders for Regions and AWS's documentation example account &lt;code&gt;123456789012&lt;/code&gt; for every account ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  SCP vs RCP vs declarative policy at a glance
&lt;/h2&gt;

&lt;p&gt;All three are &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_authorization_policies.html" rel="noopener noreferrer"&gt;authorization or management policies in AWS Organizations&lt;/a&gt;, attached to the root, an OU (organizational unit, a folder of accounts) or an account. Strictly, AWS calls the EC2 type a declarative policy (&lt;code&gt;DECLARATIVE_POLICY_EC2&lt;/code&gt;) and the S3 one an Amazon S3 policy (&lt;code&gt;S3_POLICY&lt;/code&gt;). Both use the same syntax and hold a setting, so this guide groups them as "declarative".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SCP&lt;/th&gt;
&lt;th&gt;RCP&lt;/th&gt;
&lt;th&gt;Declarative (EC2) / S3 policy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it limits&lt;/td&gt;
&lt;td&gt;IAM users and roles in member accounts, including the member root user&lt;/td&gt;
&lt;td&gt;Requests to your resources, from any principal&lt;/td&gt;
&lt;td&gt;A service setting, held at a fixed value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grants access?&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Not an access policy; it sets state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management account&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;td&gt;Not stated in AWS docs; test before relying on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-linked roles&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;td&gt;Governed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Principals outside your org&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;td&gt;Affected&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report-only mode&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Some attributes have one (Allowed AMIs &lt;code&gt;audit_mode&lt;/code&gt;, VPC Encryption Controls &lt;code&gt;attempt_monitor&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DescribeEffectivePolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Supported (EC2 and S3 types)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size and per-target limit&lt;/td&gt;
&lt;td&gt;10,240 characters, 10&lt;/td&gt;
&lt;td&gt;5,120 characters, 5&lt;/td&gt;
&lt;td&gt;10,000 characters, 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Sources: the AWS Organizations user guide pages for SCPs, RCPs and declarative policies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The service-linked roles row matters most: AWS services acting on your behalf slip past SCPs and RCPs, but not past a declarative policy, which the service enforces in its control plane. And none of the three grants anything. If someone proposes an SCP "to give the team access", send the plan back before it starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deny a verb or assert a state
&lt;/h2&gt;

&lt;p&gt;I run every proposed guardrail through three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who is acting?&lt;/strong&gt; If the control is about your own users and roles doing something, it is an SCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What resource is being reached?&lt;/strong&gt; If the control is about any caller touching your data (including outsiders), it is an RCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What setting must hold?&lt;/strong&gt; If the control is "this must be on", look for a declarative attribute first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://shahidyousuf.dev/blog/scp-vs-rcp-declarative-policies/" rel="noopener noreferrer"&gt;View this diagram in the original post&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Start from the end state you want, not the API you want to block.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Block Public Access is the clean example. The Organizations S3 policy asserts "on" for every account in scope, and in my test Terraform's bucket-level calls kept succeeding (more on that in the declarative section). It also avoids an ordering trap. If you deny the account-level Block Public Access API before the value is set, you've blocked your own fix until you detach the SCP or add an exemption, while with the S3 policy Organizations sets the value for you.&lt;/p&gt;

&lt;p&gt;When a team asks for a guardrail, ask them to describe the state they want rather than the action they want to block. The answer usually picks the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  SCPs for your own principals
&lt;/h2&gt;

&lt;p&gt;An SCP is a ceiling on IAM permissions in member accounts, and that includes member accounts acting as delegated administrators. An action must be allowed at every level from the root down to the account, and a deny at any level wins. That's why the default &lt;code&gt;FullAWSAccess&lt;/code&gt; SCP must stay attached or be replaced by your own allow list.&lt;/p&gt;

&lt;p&gt;A baseline that has held up well for me covers five things: no leaving the organization, no closing accounts, no turning off the main security services, no new unencrypted EBS volumes, and no root user minting access keys, IAM users or passwords. Treat the action lists below as a starting point: each service has more off switches (for example &lt;code&gt;guardduty:UpdateDetector&lt;/code&gt; and &lt;code&gt;cloudtrail:UpdateTrail&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;baseline-scp.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyLeaveOrgAndCloseAccount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"organizations:LeaveOrganization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"account:CloseAccount"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ProtectSecurityServices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:StopLogging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:DeleteTrail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"guardduty:DeleteDetector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"securityhub:DisableSecurityHub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"access-analyzer:DeleteAnalyzer"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/AWSControlTowerExecution"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringNotEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123456789012"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyUnencryptedEbs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ec2:CreateVolume"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ec2:RunInstances"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:ec2:*:*:volume/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Bool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ec2:Encrypted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ProtectEbsDefaultEncryption"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ec2:DisableEbsEncryptionByDefault"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyRootCredentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateLoginProfile"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:root"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two conditions in the security statement are ANDed, so the deny skips both the Control Tower role and the security tooling account (&lt;code&gt;123456789012&lt;/code&gt; here). It exempts the whole account because that account is the delegated administrator for these services and manages them for every member. If you can, narrow it to the specific roles there. EBS encryption-by-default has no declarative attribute, so protecting it stays an SCP job.&lt;/p&gt;

&lt;p&gt;Turn on EBS encryption-by-default first&lt;/p&gt;

&lt;p&gt;Enable EBS encryption-by-default in every account and Region before you attach the unencrypted-volume deny. Otherwise every volume create that does not ask for encryption fails.&lt;/p&gt;

&lt;p&gt;I keep the Region restriction in a separate SCP. Region rules and data-protection rules change at different speeds, and a separate policy keeps the blast radius of each edit small. The pattern follows AWS's &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps_examples_general.html" rel="noopener noreferrer"&gt;deny-by-requested-Region example&lt;/a&gt;. The &lt;code&gt;NotAction&lt;/code&gt; list here is a short sample of global services; extend it with every global service you use before attaching.&lt;/p&gt;

&lt;p&gt;region-scp.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyOutsideApprovedRegions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"NotAction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"organizations:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"sts:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"support:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudfront:*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringNotEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:RequestedRegion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${APPROVED_REGION_1}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${APPROVED_REGION_2}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/AWSControlTowerExecution"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sweep every service before a Region lock&lt;/p&gt;

&lt;p&gt;A pre-check that only lists EC2 resources misses S3 buckets in non-approved Regions. S3 bucket operations resolve to the bucket's Region, so once the SCP lands, such a bucket can no longer be managed. Inventory every service in every Region first.&lt;/p&gt;

&lt;p&gt;If you use Control Tower, know that registering an OU attaches only its own &lt;code&gt;aws-guardrails-*&lt;/code&gt; SCP. Your baseline needs its own attachment, and you shouldn't edit or detach Control Tower's SCPs. If you're moving accounts between organizations, the order of operations is in &lt;a href="https://shahidyousuf.dev/blog/move-aws-account-to-another-organization/" rel="noopener noreferrer"&gt;how to move an AWS account to another organization&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I handle exceptions with placement, not conditions: put the account that needs one in an OU without the policy, or attach the policy to its siblings individually. That's easier to audit than a tangle of exemption conditions. When you plan the work, budget for a baseline SCP, a separate Region SCP and a pre-attach inventory across every service. The inventory is the part teams skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  RCPs for the data perimeter
&lt;/h2&gt;

&lt;p&gt;An RCP is evaluated on the resource side and, unlike an SCP, applies to principals outside your organization too. That makes it the right place for data-perimeter rules: "nobody reaches these resources without TLS", whoever they are.&lt;/p&gt;

&lt;p&gt;Beyond the table's exceptions, RCPs also skip AWS-managed KMS keys and &lt;code&gt;kms:RetireGrant&lt;/code&gt;. They cover a growing list of services, including S3, STS, KMS, SQS, Secrets Manager, DynamoDB, ECR, Cognito, CloudWatch Logs and OpenSearch Serverless. RDS is not on it, so an RCP will not protect an RDS or Aurora database. The TLS-only policy here is AWS's &lt;a href="https://github.com/aws-samples/resource-control-policy-examples/blob/main/Restrict-resource-access-patterns/Restrict-access-to-only-HTTPS-connections-to-your-resources.json" rel="noopener noreferrer"&gt;published example&lt;/a&gt;, with a &lt;code&gt;Sid&lt;/code&gt; added.&lt;/p&gt;

&lt;p&gt;tls-only-rcp.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EnforceSecureTransport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"sts:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"kms:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"sqs:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cognito-identity:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cognito-idp:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"logs:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"aoss:*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"BoolIfExists"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:SecureTransport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pilot it on a non-production OU first. S3 website endpoints serve HTTP only, so a CloudFront distribution whose origin is an S3 website endpoint talks to it over HTTP, and under a TLS-only RCP that origin breaks. Find those distributions before you attach.&lt;/p&gt;

&lt;p&gt;An RCP is how you protect data from callers you don't control. Before anyone assumes it protects a given data store, ask which services it covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS declarative policies vs SCP
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_declarative.html" rel="noopener noreferrer"&gt;declarative policy&lt;/a&gt; does not block an API. It tells the service what the setting is, and the service enforces it in its control plane. Three properties follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It governs service-linked roles, which SCPs cannot.&lt;/li&gt;
&lt;li&gt;It stays in force as the service adds new APIs, so there is no deny list to keep current.&lt;/li&gt;
&lt;li&gt;New accounts inherit it the moment they join the OU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;S3&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;s3-policy.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"s3_attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"public_access_block_configuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;EC2&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ec2-declarative.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ec2_attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image_block_public_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block_new_sharing"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"snapshot_block_public_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block_new_sharing"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"serial_console_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@@assign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disabled"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The S3 policy makes public buckets private on attach&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;all&lt;/code&gt; turns on all four Block Public Access settings and overrides the account's own. A bucket serving public content today stops serving it the moment the policy applies. Find those buckets first, then attach the policy to the other accounts individually, or move the account that needs public access to an OU without it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_s3_syntax.html" rel="noopener noreferrer"&gt;S3 policy&lt;/a&gt; has a single attribute, &lt;code&gt;public_access_block_configuration&lt;/code&gt;, and it is all or none. AWS's guidance is to disable the S3 policy type if you want to manage Block Public Access per account. It &lt;a href="https://aws.amazon.com/about-aws/whats-new/2025/11/amazon-s3-block-public-access-organization-level-enforcement" rel="noopener noreferrer"&gt;shipped in November 2025&lt;/a&gt;, so guides written before then don't mention it. On detach, the setting rolls back to its pre-attach value, so know that value before you count on detach as a rollback.&lt;/p&gt;

&lt;p&gt;In my test, with the S3 organization policy in force, a bucket-level &lt;code&gt;PutBucketPublicAccessBlock&lt;/code&gt; call still succeeded. Public bucket policies and public ACLs were denied, because S3 applies the most restrictive combination of settings. That was a single test, so verify it yourself before you depend on it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_ec2_syntax.html" rel="noopener noreferrer"&gt;EC2 declarative attributes&lt;/a&gt; also cover VPC Block Public Access, Allowed AMIs, IMDS defaults and VPC Encryption Controls. An EC2 declarative policy can replace SCP statements that block AMI and snapshot sharing, but prove the new coverage is a strict superset of the old before you remove anything.&lt;/p&gt;

&lt;p&gt;IMDSv2 enforcement can stop launches&lt;/p&gt;

&lt;p&gt;AWS warns that turning on &lt;code&gt;http_tokens_enforced&lt;/code&gt; while launches still allow IMDSv1 (&lt;code&gt;http_tokens&lt;/code&gt; optional, from a launch template, launch call or AMI default) causes launch failures. Set &lt;code&gt;http_tokens&lt;/code&gt; to &lt;code&gt;required&lt;/code&gt; and find IMDSv1 callers first. I left that attribute out deliberately.&lt;/p&gt;

&lt;p&gt;For snapshots I chose &lt;code&gt;block_new_sharing&lt;/code&gt; over &lt;code&gt;block_all_sharing&lt;/code&gt;. &lt;code&gt;block_new_sharing&lt;/code&gt; leaves anything already public as it is and stops new public shares, so it can't cut off a consumer you haven't found yet. &lt;code&gt;block_all_sharing&lt;/code&gt; makes existing public snapshots private the moment it applies, so inventory them first. Neither mode affects sharing with specific accounts.&lt;/p&gt;

&lt;p&gt;For Block Public Access and AMI or snapshot sharing, I'd choose an S3 or EC2 declarative policy over an SCP. It covers more principals and needs no maintenance as APIs change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quotas changed in 2026
&lt;/h2&gt;

&lt;p&gt;AWS &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/05/aws-organizations-increased-scp-quotas/" rel="noopener noreferrer"&gt;raised the SCP quotas on 15 May 2026&lt;/a&gt;. Current values from the &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_reference_limits.html" rel="noopener noreferrer"&gt;Organizations quotas page&lt;/a&gt;, which does not list the S3 policy type separately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy type&lt;/th&gt;
&lt;th&gt;Max size&lt;/th&gt;
&lt;th&gt;Max attached per root, OU or account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SCP&lt;/td&gt;
&lt;td&gt;10,240 characters&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RCP&lt;/td&gt;
&lt;td&gt;5,120 characters&lt;/td&gt;
&lt;td&gt;5 (&lt;code&gt;RCPFullAWSAccess&lt;/code&gt; uses one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declarative (EC2)&lt;/td&gt;
&lt;td&gt;10,000 characters&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inherited policies do not count toward a target's limit. A policy type must be enabled on the root before you can use it. Enabling SCPs auto-attaches &lt;code&gt;FullAWSAccess&lt;/code&gt;; enabling the S3 policy or EC2 declarative policy type attaches nothing. RCP slots are the scarce ones, so plan RCPs as a few broad policies rather than one per rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling out without breaking production
&lt;/h2&gt;

&lt;p&gt;Testing is where these tools are weakest. There is no report-only mode for SCPs or RCPs. The &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html" rel="noopener noreferrer"&gt;IAM policy simulator&lt;/a&gt; evaluates SCPs that already apply but does not support RCPs, and it will not simulate a draft you have not attached. AWS's advice is to test on a test OU or account, use CloudTrail and last-accessed data, then move up the tree. Checking coverage afterwards is awkward too: &lt;a href="https://docs.aws.amazon.com/organizations/latest/APIReference/API_DescribeEffectivePolicy.html" rel="noopener noreferrer"&gt;&lt;code&gt;DescribeEffectivePolicy&lt;/code&gt;&lt;/a&gt; does not support SCPs or RCPs, so to confirm an account is covered, walk its parent chain with &lt;code&gt;ListParents&lt;/code&gt; and call &lt;code&gt;ListPoliciesForTarget&lt;/code&gt; at each level.&lt;/p&gt;

&lt;p&gt;validate-before-create.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws accessanalyzer validate-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-type&lt;/span&gt; SERVICE_CONTROL_POLICY &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-document&lt;/span&gt; file://baseline-scp.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IAM Access Analyzer's &lt;code&gt;validate-policy&lt;/code&gt; catches invented action names. For example, there is no &lt;code&gt;s3:DeleteBucketPublicAccessBlock&lt;/code&gt;; deleting maps to the &lt;code&gt;Put&lt;/code&gt; permission. It also recommends &lt;code&gt;ArnLike&lt;/code&gt; over &lt;code&gt;StringLike&lt;/code&gt; on ARN condition keys, which is why validation is the first line of the checklist I run for every attachment.&lt;/p&gt;

&lt;h1&gt;
  
  
  Comparing deployed policy JSON
&lt;/h1&gt;

&lt;p&gt;AWS returns policy documents with keys and arrays in varying order. A plain string diff reports drift that is not there. Sort keys and arrays recursively on both sides before comparing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☐ Validate the JSON with Access Analyzer before creating the policy&lt;/li&gt;
&lt;li&gt;☐ Sweep every service in every Region for resources the policy would strand&lt;/li&gt;
&lt;li&gt;☐ Set any value you are about to protect (encryption-by-default, Block Public Access) first&lt;/li&gt;
&lt;li&gt;☐ Find every bucket that is public today before attaching the S3 policy&lt;/li&gt;
&lt;li&gt;☐ For EC2 declarative policies, generate the Organizations account status report first&lt;/li&gt;
&lt;li&gt;☐ Attach to a canary OU with non-production accounts&lt;/li&gt;
&lt;li&gt;☐ Prove a fast detach works, and know what the setting reverts to&lt;/li&gt;
&lt;li&gt;☐ Walk each account's parent chain to confirm coverage&lt;/li&gt;
&lt;li&gt;☐ Check CloudTrail for new access denied errors the next day&lt;/li&gt;
&lt;li&gt;☐ Move up one OU at a time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When one of these lands in a change request, look for the canary OU, the detach plan and the next-day CloudTrail check. If any of them is missing, the rollout isn't ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;List every guardrail you have and the state each one is meant to guarantee. Anything that reads "this setting must be on" moves to a declarative policy, anything about outsiders reaching your data belongs in an RCP, and the rest is SCP work.&lt;/p&gt;

&lt;p&gt;Then run the checklist above on one policy and one low-risk OU. If you want a second pair of eyes on an estate before you start, &lt;a href="https://shahidyousuf.dev/book/" rel="noopener noreferrer"&gt;book a free intro call&lt;/a&gt;; the &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;multi-account governance case study&lt;/a&gt; shows the kind of estate this came from.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Move an AWS Account to Another Organization</title>
      <dc:creator>Shahid Yousuf</dc:creator>
      <pubDate>Fri, 09 Oct 2026 06:02:44 +0000</pubDate>
      <link>https://dev.to/shahidyousuf/move-an-aws-account-to-another-organization-5f95</link>
      <guid>https://dev.to/shahidyousuf/move-an-aws-account-to-another-organization-5f95</guid>
      <description>&lt;p&gt;When you need to move an AWS account to another organization, the move itself takes minutes. Getting the account governed afterwards is the real work. In a multi-account &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;AWS governance engagement&lt;/a&gt;, I worked on exactly this: production accounts that had to end up under the destination's AWS Control Tower, reachable by the same people, and inside the destination's own guardrails.&lt;/p&gt;

&lt;p&gt;Done casually, a move looks finished long before it is. The account shows up in the new organization, the Control Tower dashboard says enrolled, and people sign in through the new portal. Meanwhile the old organization can still administer the account, and your own guardrail policies may not reach it at all. Nothing on the screen tells you either of those things.&lt;/p&gt;

&lt;p&gt;The right route is to invite the account from the destination organization's management account. When it accepts, AWS moves it across directly: it never leaves its old organization by itself and never runs standalone in between. The older leave-then-join route is the one that produces this error, and you don't need to fix it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The member account is missing one or more of the prerequisites required to operate as a standalone account. To add what is missing, sign-in to the member account using the AWS Organizations console, then select to leave the organization...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What follows is the order of operations that works with Control Tower, and the three checks that decide whether a moved account is actually governed: sign-on, your own guardrail policies, and the old organization's admin role. On those moves, the client's cloud admin sent and accepted the invitations and ran the enrollment. I planned the sequence, prepared the landing OU, sign-on and baseline policies, and verified each account afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why leave-then-join gets refused
&lt;/h2&gt;

&lt;p&gt;The classic way to move an account is leave, then join: the account &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_leave-as-member.html" rel="noopener noreferrer"&gt;leaves its organization as a member&lt;/a&gt;, runs standalone for a while, then accepts an invite from the new one. To leave, it must be able to stand alone, and that is where it gets stuck.&lt;/p&gt;

&lt;p&gt;In accounts created inside an organization, the payment method is usually missing. That was true here, but adding a card wasn't enough: Leave still refused, and AWS's message doesn't say what else it wants. No public API reads whether a payment instrument exists (billing, invoicing, billingconductor, account, ce and budgets all come up empty), but the console does: Billing, then Payment preferences, in the member account lists payment methods. The Leave dialog itself only said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You can't leave the organization yet&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AWS documents a "Complete the account sign-up steps" link for this message. With the invite path, you can skip it. If your migration plan depends on someone typing a payment card into every account by hand, send it back; there's a better path.&lt;/p&gt;

&lt;p&gt;A refused removal is safe, but it is not a dry run&lt;/p&gt;

&lt;p&gt;The 400 changes nothing. But if the prerequisites are met, that same call removes a production account from its organization on the spot. Do not use it to "test" readiness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invite from the destination instead
&lt;/h2&gt;

&lt;p&gt;On 19 November 2025 AWS &lt;a href="https://aws.amazon.com/about-aws/whats-new/2025/11/aws-organizations-direct-account-transfers/" rel="noopener noreferrer"&gt;announced direct account transfers&lt;/a&gt;. The destination management account invites the account, the account accepts, and it moves straight across without leaving its old organization first or running standalone in between. In practice the invitation went through, and on acceptance the account left its old organization automatically, with no separate leave step.&lt;/p&gt;

&lt;p&gt;The API reference makes this easy to miss. The &lt;a href="https://docs.aws.amazon.com/organizations/latest/APIReference/API_InviteAccountToOrganization.html" rel="noopener noreferrer"&gt;&lt;code&gt;InviteAccountToOrganization&lt;/code&gt; reference&lt;/a&gt; still lists &lt;code&gt;ALREADY_IN_AN_ORGANIZATION&lt;/code&gt; as a failure reason, though read closely, that list sits under a caveat:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Some of the reasons in the following list might not be applicable to this specific API or operation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_account_migration.html" rel="noopener noreferrer"&gt;account migration guide&lt;/a&gt; describes the direct transfer, and the API reference's error list is shared boilerplate. When the two disagree, I go with the user guide.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Leave, then join&lt;/th&gt;
&lt;th&gt;Invite from destination&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standalone prerequisites&lt;/td&gt;
&lt;td&gt;Required before leaving&lt;/td&gt;
&lt;td&gt;AWS's docs say none needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment card per account&lt;/td&gt;
&lt;td&gt;Typed in by hand&lt;/td&gt;
&lt;td&gt;Not mentioned in AWS's docs. Untested here: the one invited account already had a card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing gap&lt;/td&gt;
&lt;td&gt;A standalone period outside consolidated billing&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who acts&lt;/td&gt;
&lt;td&gt;Member account leaves, then accepts an invite&lt;/td&gt;
&lt;td&gt;Destination invites, member accepts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome here&lt;/td&gt;
&lt;td&gt;Refused, even with a card added&lt;/td&gt;
&lt;td&gt;Worked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The two ways to move an account, as they played out here.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The commands themselves are short. Invite from the destination's management account, then accept in the member account:&lt;/p&gt;

&lt;p&gt;Run in the destination management account&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws organizations invite-account-to-organization &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="nv"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789012,Type&lt;span class="o"&gt;=&lt;/span&gt;ACCOUNT

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run in the member account&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws organizations accept-handshake &lt;span class="nt"&gt;--handshake-id&lt;/span&gt; h-examplehandshakeid111

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the guide's prerequisites first: rules on the age of the account and the organization, a Seller of Record that must match, and the fact that the account lands in the root of the new organization. Per the &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_invites.html" rel="noopener noreferrer"&gt;invitations page&lt;/a&gt;, invitations expire after 15 days and the new organization's policies apply as soon as the account joins. If your team plans to leave, then join, ask them why; the invite path skips per-account billing setup and the standalone gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to move an AWS account to another organization, step by step
&lt;/h2&gt;

&lt;p&gt;With AWS Control Tower in the destination, "moved" really means "moved and enrolled in the right organizational unit (OU)". The sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register the landing OU with Control Tower while it is still empty. For an empty OU it took about two minutes here; AWS's guidance is to plan for ten or more. Control Tower attaches its own &lt;code&gt;aws-guardrails-*&lt;/code&gt; SCP (a service control policy: an org-level policy that can only deny, never grant). That is expected, not drift.&lt;/li&gt;
&lt;li&gt;Associate security monitoring with the OU before anything arrives.&lt;/li&gt;
&lt;li&gt;Invite from the destination; accept in the member account.&lt;/li&gt;
&lt;li&gt;Create the &lt;code&gt;AWSControlTowerExecution&lt;/code&gt; role in the account.&lt;/li&gt;
&lt;li&gt;Enroll the account through Control Tower into the OU. Until enrollment, the account sits in the root under whatever policies are attached there, so enroll it the same day.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://shahidyousuf.dev/blog/move-aws-account-to-another-organization/" rel="noopener noreferrer"&gt;View this diagram in the original post&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full move, from invitation to the cleanup that makes the account fully yours.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The execution role trusts the destination's management account. This is the shape from the &lt;a href="https://docs.aws.amazon.com/controltower/latest/userguide/enrollment-prerequisites.html" rel="noopener noreferrer"&gt;enrollment prerequisites&lt;/a&gt;; replace 111122223333 with your destination management account ID:&lt;/p&gt;

&lt;p&gt;trust.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:root"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role also needs the &lt;code&gt;AdministratorAccess&lt;/code&gt; managed policy, or enrollment fails:&lt;/p&gt;

&lt;p&gt;Run in the member account&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; AWSControlTowerExecution &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assume-role-policy-document&lt;/span&gt; file://trust.json
aws iam attach-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; AWSControlTowerExecution &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::aws:policy/AdministratorAccess

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I don't use &lt;code&gt;aws organizations move-account&lt;/code&gt; for step 5. With auto-enrollment off, a manual move into a Control Tower OU does not enroll the account and causes &lt;a href="https://docs.aws.amazon.com/controltower/latest/userguide/governance-drift.html" rel="noopener noreferrer"&gt;inheritance drift&lt;/a&gt;. If you want moves to enroll on their own, Control Tower offers &lt;a href="https://docs.aws.amazon.com/controltower/latest/userguide/account-auto-enrollment.html" rel="noopener noreferrer"&gt;automatic enrollment&lt;/a&gt; on landing zone 3.1 and later.&lt;/p&gt;

&lt;p&gt;I also wouldn't move every account at once. Order them by access risk, and hold back any account whose permission sets don't yet exist in the destination. Whoever owns the migration should confirm the landing OU and its security monitoring are in place, and checked, before the first account arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign-on after the move
&lt;/h2&gt;

&lt;p&gt;For users, the account disappeared from the old access portal and appeared in the new one with the same groups, with no access requests to re-file. This works only because the destination's directory already had the same groups, and permission sets were assigned to them on arrival. Nothing in Identity Center carries over by itself. The destination's IAM Identity Center creates the new sign-on roles (&lt;code&gt;AWSReservedSSO_*&lt;/code&gt;) when permission sets are assigned, &lt;a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/identity-center-and-iam-roles.html" rel="noopener noreferrer"&gt;as Identity Center does&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If a group doesn't exist in the destination's directory, its members lose that path into the account, so create groups and permission sets in the destination before you move anything. Engineers will also hit a confusing error: a CLI profile still pointing at the old portal fails with &lt;code&gt;ForbiddenException ... No access&lt;/code&gt;. It reads like a missing permission, but it's just the old portal. Point the profile at the new start URL and sign in again.&lt;/p&gt;

&lt;p&gt;Root email addresses come across unchanged. One of ours pointed at a mailbox on a legacy domain, so check each one. Budget an identity prep step before the move, and tell engineers ahead of time that their CLI profiles will need a new start URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enrolled is not the same as governed
&lt;/h2&gt;

&lt;p&gt;Run a parity check against the organization's own guardrails after each move. On these moves, the accounts were enrolled, recording AWS Config and reporting to the security services, yet outside every one of the organization's own baseline policies, the SCPs and configuration policies (an EC2 declarative policy and an S3 policy, which set a service's configuration directly), for most of a week.&lt;/p&gt;

&lt;p&gt;A dashboard showing "enrolled" means Control Tower's own controls apply, nothing more. Your organization's policies apply to an OU only if they are attached to it or inherited from above it. Here the baseline was attached OU by OU, not at the root, so a brand-new OU inherited none of it. Attaching policies that are safe everywhere at the root closes this class of gap; the trade-off is a wider blast radius for every change to them.&lt;/p&gt;

&lt;p&gt;There is no effective-policy API for SCPs, so the check walks the parent chain. Configuration policies do have one:&lt;/p&gt;

&lt;p&gt;policy-parity.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Run with management account credentials.&lt;/span&gt;
&lt;span class="c"&gt;# List the SCPs that reach an account: its own, each parent OU's, and the root's.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;:?set&lt;span class="p"&gt; ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  aws organizations list-policies-for-target &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--target-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; SERVICE_CONTROL_POLICY &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Policies[].Name'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
  &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; parent_id parent_type &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;aws organizations list-parents &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--child-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Parents[0].[Id,Type]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;parent_id&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;parent_type&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"ROOT"&lt;/span&gt;&lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (root)"&lt;/span&gt;
    aws organizations list-policies-for-target &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--target-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; SERVICE_CONTROL_POLICY &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Policies[].Name'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
    &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
done&lt;/span&gt;

&lt;span class="c"&gt;# Configuration policies have an effective-policy API.&lt;/span&gt;
&lt;span class="c"&gt;# Repeat with --policy-type DECLARATIVE_POLICY_EC2.&lt;/span&gt;
aws organizations describe-effective-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-type&lt;/span&gt; S3_POLICY &lt;span class="nt"&gt;--target-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details keep a check like this honest. Control Tower's &lt;code&gt;list-enabled-baselines&lt;/code&gt; returns an OU as an ARN, while Organizations uses the bare &lt;code&gt;ou-&lt;/code&gt; ID, so compare like with like. And list the policy types your organization has enabled (&lt;code&gt;aws organizations list-roots --query 'Roots[0].PolicyTypes'&lt;/code&gt;) instead of hard-coding them, or a check can silently skip one.&lt;/p&gt;

&lt;p&gt;Attach missing policies one at a time, smallest blast radius first, and take care with one type in particular:&lt;/p&gt;

&lt;p&gt;An S3 policy changes live state on attach&lt;/p&gt;

&lt;p&gt;An SCP only denies future calls. An Organizations S3 policy overrides the account's Block Public Access settings the moment you attach it. A bucket that is public today stops being public. Detaching it restores the previous settings. If one account in the OU has public content that must stay public until its owner decides, attach the S3 policy to the other accounts individually and move it to the OU later.&lt;/p&gt;

&lt;p&gt;The same check found a long-governed account missing baseline policies too, so the gap is not unique to new OUs. Make the parity check a runbook step after every move, and don't accept a dashboard's "enrolled" as proof that your own policies apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old organization's admin role
&lt;/h2&gt;

&lt;p&gt;Accounts created inside an organization get &lt;code&gt;OrganizationAccountAccessRole&lt;/code&gt;, an admin role the management account can assume. &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_accounts_invites.html" rel="noopener noreferrer"&gt;Invited accounts do not get one&lt;/a&gt; in the new organization.&lt;/p&gt;

&lt;p&gt;The old one is the problem. It survives the move, still trusts the old organization's management account, and still has admin rights, so whoever controls that old management account can still administer the account you just moved. The cleanup comes after the move because, until &lt;code&gt;AWSControlTowerExecution&lt;/code&gt; and Identity Center access exist, the old role is the only admin path. Do it the same day.&lt;/p&gt;

&lt;p&gt;Inspect who the role trusts&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam get-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; OrganizationAccountAccessRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Role.AssumeRolePolicyDocument'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't the only role to check. Old-org StackSets execution roles can also trust the old management account:&lt;/p&gt;

&lt;p&gt;Find every role that trusts the old management account&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-roles &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Roles[?contains(to_string(AssumeRolePolicyDocument), '&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OLD_MGMT_ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')].RoleName"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace your access path before you remove the old one&lt;/p&gt;

&lt;p&gt;Verify both paths first: the human path (you can sign in through the new organization's Identity Center) and the automation path (&lt;code&gt;AWSControlTowerExecution&lt;/code&gt; is assumable from the new management account). Then delete the old role, or retrust it to the new management account.&lt;/p&gt;

&lt;p&gt;An account is not migrated until the old organization can no longer administer it. Make that an explicit exit criterion for every move, with someone named to verify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the next move
&lt;/h2&gt;

&lt;p&gt;Treat each account as its own change: one ticket, both checklists, and a named person for the exit check. Put the baseline attach and the old-role cleanup inside the enrollment runbook, so they happen on move day rather than in a follow-up. If the payment question matters for your budget, invite one account that has no card on file before you plan the rest around either answer.&lt;/p&gt;

&lt;p&gt;Before the move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☐ Read the &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_account_migration.html" rel="noopener noreferrer"&gt;account migration prerequisites&lt;/a&gt;: account and organization age, matching Seller of Record&lt;/li&gt;
&lt;li&gt;☐ No SCP or IAM policy in the source organization blocks the migration&lt;/li&gt;
&lt;li&gt;☐ Resource policies using &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; found and updated (they break silently after the move)&lt;/li&gt;
&lt;li&gt;☐ Account deregistered as a delegated administrator, if it is one&lt;/li&gt;
&lt;li&gt;☐ Organization-level billing history exported (AWS deletes it on removal)&lt;/li&gt;
&lt;li&gt;☐ No existing AWS Config recorder or delivery channel left in the account (remove before enrolling)&lt;/li&gt;
&lt;li&gt;☐ Groups and permission sets exist in the destination's Identity Center&lt;/li&gt;
&lt;li&gt;☐ Landing OU registered with Control Tower while empty&lt;/li&gt;
&lt;li&gt;☐ Security monitoring associated with the landing OU&lt;/li&gt;
&lt;li&gt;☐ Accounts ordered by access risk; any account missing identity prep held back&lt;/li&gt;
&lt;li&gt;☐ Root email addresses reviewed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☐ Invitation accepted within its 15-day window&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;AWSControlTowerExecution&lt;/code&gt; created with &lt;code&gt;AdministratorAccess&lt;/code&gt; and the account enrolled into the OU (not &lt;code&gt;move-account&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;☐ Users can sign in through the new portal; CLI profiles updated&lt;/li&gt;
&lt;li&gt;☐ Parity check: the organization's baseline SCPs and configuration policies reach the account&lt;/li&gt;
&lt;li&gt;☐ Sanctioned public content checked before any S3 policy is attached&lt;/li&gt;
&lt;li&gt;☐ Old &lt;code&gt;OrganizationAccountAccessRole&lt;/code&gt;, and any other role trusting the old management account, deleted or retrusted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paste both lists into the ticket for each account, and don't close it until every box is ticked. If you're planning a move like this and want a second pair of eyes on the order of operations, &lt;a href="https://shahidyousuf.dev/book/" rel="noopener noreferrer"&gt;book an intro call&lt;/a&gt;. The full engagement it came from is in the &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;AWS governance case study&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>AWS Security Checklist for Small Teams</title>
      <dc:creator>Shahid Yousuf</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:30:13 +0000</pubDate>
      <link>https://dev.to/shahidyousuf/aws-security-checklist-for-small-teams-39cp</link>
      <guid>https://dev.to/shahidyousuf/aws-security-checklist-for-small-teams-39cp</guid>
      <description>&lt;p&gt;A small team's AWS setup usually grows by accident. There are one or two accounts, a handful of IAM users created years ago, an access key in the CI system that nobody remembers creating, and the root user still signs in with the founder's personal email. Nothing is on fire, so the AWS security checklist everyone means to work through keeps losing to the roadmap.&lt;/p&gt;

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

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

&lt;p&gt;AWS already publishes the &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/aws-startup-security-baseline/" rel="noopener noreferrer"&gt;AWS Startup Security Baseline&lt;/a&gt; (SSB), a list of account controls ACCT.01 to ACCT.17 and workload controls WKLD.01 to WKLD.15. It's good, but it isn't ordered by urgency, and it doesn't tell you how to prove a control is working or what it will cost. This guide takes the SSB controls that matter most early on, puts them in order, adds the multi-account guardrails the SSB leaves out, and gives each a verify step and a cost. Where I hit a trap governing a multi-account estate, written up as a &lt;a href="https://shahidyousuf.dev/work/aws-multi-account-governance/" rel="noopener noreferrer"&gt;multi-account AWS governance case study&lt;/a&gt;, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along, you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" rel="noopener noreferrer"&gt;AWS CLI version 2&lt;/a&gt;, configured with credentials for the account you're securing.&lt;/li&gt;
&lt;li&gt;Administrator access to a standalone account, or to the management account if you already use AWS Organizations (the service that groups accounts under one payer and one set of policies).&lt;/li&gt;
&lt;li&gt;A shared mailbox or distribution list for security and billing mail, such as &lt;code&gt;aws-security@your_domain&lt;/code&gt;. Personal inboxes leave with people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Commands use placeholders such as &lt;code&gt;${ACCOUNT_ID}&lt;/code&gt; or &lt;code&gt;your-region&lt;/code&gt;. Replace them with your own values.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this checklist
&lt;/h2&gt;

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

&lt;p&gt;&lt;a href="https://shahidyousuf.dev/blog/aws-security-checklist/" rel="noopener noreferrer"&gt;View this diagram in the original post&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Work top to bottom, and verify each control before moving on.&lt;/em&gt;&lt;/p&gt;

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

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

&lt;p&gt;&lt;em&gt;AWS Organizations is offered at no additional charge, so the policies in the last tier add nothing to the bill.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most items below follow the same shape: what to do, why it matters, how to do it, how to verify it, and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do this week: identity and account basics
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lock down the root user
&lt;/h3&gt;

&lt;p&gt;The root user can do anything in the account, including things no IAM policy can stop, so it's the one identity you can't afford to lose. AWS now &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/root-user-best-practices.html" rel="noopener noreferrer"&gt;requires MFA on the root user&lt;/a&gt; for all account types, with registration due within 35 days of first sign-in. Delete any root access keys, and move the root email to a group address so recovery doesn't depend on one person's inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sign in as the root user. On the right of the navigation bar, choose your account name, then &lt;strong&gt;Security credentials&lt;/strong&gt;. In the &lt;strong&gt;Multi-factor authentication (MFA)&lt;/strong&gt; section, choose &lt;strong&gt;Assign MFA device&lt;/strong&gt; (&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/enable-virt-mfa-for-root.html" rel="noopener noreferrer"&gt;AWS steps&lt;/a&gt;). Delete any keys in the &lt;strong&gt;Access keys&lt;/strong&gt; section on the same page. To change the email, choose your account name, then &lt;strong&gt;Account&lt;/strong&gt; ; next to &lt;strong&gt;Account details&lt;/strong&gt; , choose &lt;strong&gt;Actions&lt;/strong&gt; , &lt;strong&gt;Update email address and password&lt;/strong&gt; (&lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-root-user-email.html" rel="noopener noreferrer"&gt;AWS steps&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam get-account-summary &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'SummaryMap.{MFA:AccountMFAEnabled,RootKeys:AccountAccessKeysPresent}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output will look similar to:&lt;/p&gt;

&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MFA"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"RootKeys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AccountMFAEnabled&lt;/code&gt; should be &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;AccountAccessKeysPresent&lt;/code&gt; should be &lt;code&gt;0&lt;/code&gt;. Anything else means the root user still needs work. Cost: free.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Set the security alternate contact
&lt;/h3&gt;

&lt;p&gt;AWS sends security notices to the security alternate contact. If it's empty or points at someone who left, the notice goes nowhere. See &lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-update-contact-alternate.html" rel="noopener noreferrer"&gt;updating alternate contacts&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws account put-alternate-contact &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alternate-contact-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SECURITY &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--email-address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws-security@your_domain &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Security team"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--phone-number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PHONE_NUMBER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Security contact"&lt;/span&gt;
aws account get-alternate-contact &lt;span class="nt"&gt;--alternate-contact-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SECURITY

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  3. Give people IAM Identity Center, not IAM users
&lt;/h3&gt;

&lt;p&gt;IAM users carry a password and often an access key that never expires. &lt;a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/getting-started.html" rel="noopener noreferrer"&gt;IAM Identity Center&lt;/a&gt; gives each person one sign-in, short-lived credentials and permission sets you manage centrally, which is what the &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" rel="noopener noreferrer"&gt;IAM best practices&lt;/a&gt; recommend for human access. Set it up, move each person across, then remove their IAM user. Verify with the credential report from the next item: no human IAM user should have a password. Cost: free.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Find and retire long-lived access keys
&lt;/h3&gt;

&lt;p&gt;Access keys don't expire. A key that leaks into a repository, a laptop backup or a CI log keeps working until someone deletes it. Start with the &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html" rel="noopener noreferrer"&gt;credential report&lt;/a&gt;, a CSV of every IAM user and the state of their credentials.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam generate-credential-report
aws iam get-credential-report &lt;span class="nt"&gt;--query&lt;/span&gt; Content &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; credential-report.csv

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Retire keys in two moves: deactivate first, delete later. A deactivated key can be switched back on in seconds if something breaks; a deleted one can't. See &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html" rel="noopener noreferrer"&gt;managing access keys&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam update-access-key &lt;span class="nt"&gt;--user-name&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-key-id&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCESS_KEY_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;--status&lt;/span&gt; Inactive

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent use is not the full inventory&lt;/p&gt;

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

&lt;p&gt;Cost: free.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Move CI to OIDC instead of stored keys
&lt;/h3&gt;

&lt;p&gt;The most common long-lived key is the one in your CI system's secrets. With &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html" rel="noopener noreferrer"&gt;OIDC federation&lt;/a&gt;, the CI job presents a signed token and assumes an IAM role for the length of the run, so there's no key to leak. I cover the full trust policy in &lt;a href="https://shahidyousuf.dev/blog/github-actions-oidc-aws-trust-policy/" rel="noopener noreferrer"&gt;GitHub Actions OIDC trust policy for AWS&lt;/a&gt;.&lt;/p&gt;

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

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

&lt;h3&gt;
  
  
  6. Turn on a budget and Cost Anomaly Detection
&lt;/h3&gt;

&lt;p&gt;Abuse of a compromised account, such as crypto-mining instances, often shows up on the bill first. &lt;a href="https://aws.amazon.com/aws-cost-management/aws-budgets/pricing/" rel="noopener noreferrer"&gt;AWS Budgets&lt;/a&gt; are free to monitor, and the first two action-enabled budgets are free too. &lt;a href="https://docs.aws.amazon.com/cost-management/latest/userguide/getting-started-ad.html" rel="noopener noreferrer"&gt;Cost Anomaly Detection&lt;/a&gt; watches spending patterns rather than a fixed ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws budgets create-budget &lt;span class="nt"&gt;--account-id&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--budget&lt;/span&gt; &lt;span class="s1"&gt;'{"BudgetName":"monthly-cost","BudgetLimit":{"Amount":"500","Unit":"USD"},"TimeUnit":"MONTHLY","BudgetType":"COST"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notifications-with-subscribers&lt;/span&gt; &lt;span class="s1"&gt;'[
    {"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":80,"ThresholdType":"PERCENTAGE"},
     "Subscribers":[{"SubscriptionType":"EMAIL","Address":"aws-billing@your_domain"}]},
    {"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":100,"ThresholdType":"PERCENTAGE"},
     "Subscribers":[{"SubscriptionType":"EMAIL","Address":"aws-billing@your_domain"}]}
  ]'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;500&lt;/code&gt; with a figure that fits your account.&lt;/p&gt;

&lt;p&gt;The default anomaly alert is set for a bigger bill than yours&lt;/p&gt;

&lt;p&gt;If you started using Cost Explorer on or after 27 March 2023, AWS created a default anomaly monitor for you. Its alert only fires on anomalies above $100 and above 40%, according to the &lt;a href="https://aws.amazon.com/aws-cost-management/aws-cost-anomaly-detection/faqs/" rel="noopener noreferrer"&gt;Cost Anomaly Detection FAQ&lt;/a&gt;. On a small bill that may never trigger. Lower the threshold to something that would actually surprise you.&lt;/p&gt;

&lt;p&gt;Cost: free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do this month: detection and data protection
&lt;/h2&gt;

&lt;h3&gt;
  
  
  7. Record every Region with CloudTrail
&lt;/h3&gt;

&lt;p&gt;CloudTrail is your record of who did what, and when; without it, an incident investigation has nothing to read. A multi-Region trail also covers the Regions you don't use. With Organizations, create an &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/creating-trail-organization.html" rel="noopener noreferrer"&gt;organization trail&lt;/a&gt; from the management account so every member account is covered.&lt;/p&gt;

&lt;p&gt;First enable trusted access for CloudTrail (organizations only) and create a bucket for the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws organizations enable-aws-service-access &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-principal&lt;/span&gt; cloudtrail.amazonaws.com
aws s3 mb s3://&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TRAIL_BUCKET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; your-region
aws s3api put-bucket-policy &lt;span class="nt"&gt;--bucket&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TRAIL_BUCKET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy&lt;/span&gt; file://trail-bucket-policy.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket policy has to let CloudTrail write to the bucket. Copy it from &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/create-s3-bucket-policy-for-cloudtrail.html" rel="noopener noreferrer"&gt;the Amazon S3 bucket policy for CloudTrail&lt;/a&gt;, using the organization variant if you're creating an org trail. Then create the trail and start it, following the &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-create-and-update-an-organizational-trail-by-using-the-aws-cli.html" rel="noopener noreferrer"&gt;organization trail CLI steps&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudtrail create-trail &lt;span class="nt"&gt;--name&lt;/span&gt; org-trail &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-bucket-name&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TRAIL_BUCKET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--is-multi-region-trail&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--is-organization-trail&lt;/span&gt;
aws cloudtrail start-logging &lt;span class="nt"&gt;--name&lt;/span&gt; org-trail
aws cloudtrail get-trail-status &lt;span class="nt"&gt;--name&lt;/span&gt; org-trail &lt;span class="nt"&gt;--query&lt;/span&gt; IsLogging

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop &lt;code&gt;--is-organization-trail&lt;/code&gt; (and the trusted-access command) for a standalone account. The output will look similar to:&lt;/p&gt;

&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per &lt;a href="https://aws.amazon.com/cloudtrail/pricing/" rel="noopener noreferrer"&gt;CloudTrail pricing&lt;/a&gt;, the first copy of management events is free; you pay for the S3 storage. If you later enroll accounts into AWS Control Tower while they still have their own account-level trails, you can end up paying for the same events twice, so remove the redundant trails first.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Turn on GuardDuty in every Region
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_settingup.html" rel="noopener noreferrer"&gt;GuardDuty&lt;/a&gt; is AWS's threat detection service: it analyzes CloudTrail events, VPC flow logs and DNS logs and raises findings for suspicious activity. It's Regional, and a Region where workloads can run but GuardDuty is off is a Region nobody is watching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws guardduty create-detector &lt;span class="nt"&gt;--enable&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; your-region

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route findings to email through an EventBridge rule and an SNS topic. Start with the event pattern:&lt;/p&gt;

&lt;p&gt;guardduty-findings-rule.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.guardduty"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail-type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GuardDuty Finding"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the topic, subscribe your group address, and point the rule at the topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws sns create-topic &lt;span class="nt"&gt;--name&lt;/span&gt; guardduty-findings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; TopicArn &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
aws sns subscribe &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; email &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notification-endpoint&lt;/span&gt; aws-security@your_domain
aws events put-rule &lt;span class="nt"&gt;--name&lt;/span&gt; guardduty-findings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--event-pattern&lt;/span&gt; file://guardduty-findings-rule.json
aws events put-targets &lt;span class="nt"&gt;--rule&lt;/span&gt; guardduty-findings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--targets&lt;/span&gt; &lt;span class="s2"&gt;"Id"&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sns"&lt;/span&gt;,&lt;span class="s2"&gt;"Arn"&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;To verify the whole path, generate sample findings and confirm one reaches the inbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DETECTOR_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws guardduty list-detectors &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DetectorIds[0]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
aws guardduty create-sample-findings &lt;span class="nt"&gt;--detector-id&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DETECTOR_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GuardDuty keeps findings for 90 days, so export them to S3 if you want a longer record. You don't need to turn on &lt;a href="https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html" rel="noopener noreferrer"&gt;VPC flow logs&lt;/a&gt; for GuardDuty, which reads that data independently; enable them later if you want them for your own forensics.&lt;/p&gt;

&lt;p&gt;Cost is usage-based. In the first pricing tier in us-east-1, &lt;a href="https://aws.amazon.com/guardduty/pricing/" rel="noopener noreferrer"&gt;GuardDuty pricing&lt;/a&gt; lists $4.00 per million CloudTrail events and $1.00 per GB of flow and DNS logs analyzed. New detectors also turn on most protection plans (S3, EKS, Malware, RDS, Lambda), each billed separately; review them on the trial's usage page and turn off what you don't run. The 30-day trial lets you measure first.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Run IAM Access Analyzer for external access
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html" rel="noopener noreferrer"&gt;IAM Access Analyzer&lt;/a&gt; reads your resource policies and reports anything reachable from outside your account or organization. External access analysis is free. Analyzers are Regional, so create one per Region.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws accessanalyzer create-analyzer &lt;span class="nt"&gt;--analyzer-name&lt;/span&gt; external-access &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; ACCOUNT &lt;span class="nt"&gt;--region&lt;/span&gt; your-region
aws accessanalyzer list-findings-v2 &lt;span class="nt"&gt;--analyzer-arn&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ANALYZER_ARN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Condition block that pins the source account&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ArnLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::example-bucket"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123456789012"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Optional: the unused-access analyzer
&lt;/h1&gt;

&lt;p&gt;A second analyzer type reports roles, users, keys and permissions nobody has used in a set period. It isn't Regional.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws accessanalyzer create-analyzer &lt;span class="nt"&gt;--analyzer-name&lt;/span&gt; unused-access &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; ACCOUNT_UNUSED_ACCESS &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--configuration&lt;/span&gt; &lt;span class="s1"&gt;'{"unusedAccess":{"unusedAccessAge":90}}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is paid: $0.20 per IAM role or user per month. &lt;a href="https://aws.amazon.com/iam/access-analyzer/pricing/" rel="noopener noreferrer"&gt;AWS's pricing example&lt;/a&gt; puts 70 principals at $14 a month; at thousands of roles it becomes hundreds of dollars a month. Setup details are in &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-create-unused.html" rel="noopener noreferrer"&gt;creating an unused access analyzer&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Block public S3 access at the account level
&lt;/h3&gt;

&lt;p&gt;Bucket-level settings protect one bucket. The &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/configuring-block-public-access-account.html" rel="noopener noreferrer"&gt;account-level Block Public Access&lt;/a&gt; setting protects every bucket, including the next one somebody creates.&lt;/p&gt;

&lt;p&gt;Find your intentionally public buckets first&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Console&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3control put-public-access-block &lt;span class="nt"&gt;--account-id&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-access-block-configuration&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;BlockPublicAcls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,IgnorePublicAcls&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,BlockPublicPolicy&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;,RestrictPublicBuckets&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify with &lt;code&gt;aws s3control get-public-access-block --account-id ${ACCOUNT_ID}&lt;/code&gt;. The output will look similar to:&lt;/p&gt;

&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PublicAccessBlockConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"BlockPublicAcls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"IgnorePublicAcls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"BlockPublicPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"RestrictPublicBuckets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  11. Encrypt new EBS volumes by default
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/ebs/latest/userguide/encryption-by-default.html" rel="noopener noreferrer"&gt;EBS encryption by default&lt;/a&gt; makes every new volume and snapshot copy encrypted without anyone remembering a flag. It's a per-Region setting, and existing volumes stay as they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the EC2 console, select the Region, choose &lt;strong&gt;Settings&lt;/strong&gt; in the navigation pane, then the &lt;strong&gt;Data protection and security&lt;/strong&gt; tab. In the &lt;strong&gt;EBS encryption&lt;/strong&gt; section, choose &lt;strong&gt;Manage&lt;/strong&gt; , select &lt;strong&gt;Enable&lt;/strong&gt; , then choose &lt;strong&gt;Update EBS encryption&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 enable-ebs-encryption-by-default &lt;span class="nt"&gt;--region&lt;/span&gt; your-region
aws ec2 get-ebs-encryption-by-default &lt;span class="nt"&gt;--region&lt;/span&gt; your-region &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; EbsEncryptionByDefault

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output will look similar to:&lt;/p&gt;

&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat for every Region you use. Cost: free.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Make IMDSv2 the account default
&lt;/h3&gt;

&lt;p&gt;The instance metadata service hands an EC2 instance its role credentials, and IMDSv2 requires a session token for every request. You can set it as the &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html" rel="noopener noreferrer"&gt;account default for new instances&lt;/a&gt;, per Region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-instance-metadata-defaults &lt;span class="nt"&gt;--region&lt;/span&gt; your-region &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--http-tokens&lt;/span&gt; required &lt;span class="nt"&gt;--http-put-response-hop-limit&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
aws ec2 get-instance-metadata-defaults &lt;span class="nt"&gt;--region&lt;/span&gt; your-region &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; AccountLevel.HttpTokens

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output will look similar to:&lt;/p&gt;

&lt;p&gt;Output&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"required"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Enforcement makes launches fail&lt;/p&gt;

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

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

&lt;h3&gt;
  
  
  13. Move secrets out of code and environment files
&lt;/h3&gt;

&lt;p&gt;Database passwords and API tokens in a repository or a committed &lt;code&gt;.env&lt;/code&gt; file outlive every rotation policy you write. Move them to &lt;a href="https://aws.amazon.com/secrets-manager/pricing/" rel="noopener noreferrer"&gt;Secrets Manager&lt;/a&gt; at $0.40 per secret per month, or to Parameter Store &lt;code&gt;SecureString&lt;/code&gt; parameters, where &lt;a href="https://aws.amazon.com/systems-manager/pricing/" rel="noopener noreferrer"&gt;standard parameters are free&lt;/a&gt;. I'd use Secrets Manager where you want automatic rotation. To check a repository's history for secrets already committed, run a scanner such as gitleaks (&lt;code&gt;gitleaks detect&lt;/code&gt; in the repository).&lt;/p&gt;

&lt;h3&gt;
  
  
  14. Turn on Security Hub CSPM with standards you chose
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/securityhub/latest/userguide/what-is-securityhub.html" rel="noopener noreferrer"&gt;Security Hub CSPM&lt;/a&gt; (cloud security posture management) runs continuous checks against your configuration using AWS Config, and gathers findings from GuardDuty and Access Analyzer in one place. It needs the AWS Config recorder on in each Region, with IAM and other global resources recorded in one Region only, as the &lt;a href="https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-setup-prereqs.html" rel="noopener noreferrer"&gt;Security Hub prerequisites&lt;/a&gt; describe. Two settings then decide whether it's useful.&lt;/p&gt;

&lt;p&gt;The first is the standards. Turning CSPM on with &lt;a href="https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-auto-enabled-standards.html" rel="noopener noreferrer"&gt;default standards&lt;/a&gt; enables AWS Foundational Security Best Practices and CIS v1.2.0, even though &lt;a href="https://docs.aws.amazon.com/securityhub/latest/userguide/cis-aws-foundations-benchmark.html" rel="noopener noreferrer"&gt;CIS v1.4.0, v3.0.0 and v5.0.0&lt;/a&gt; are available. Decline the defaults and pick the versions you actually want to be measured against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws securityhub enable-security-hub &lt;span class="nt"&gt;--no-enable-default-standards&lt;/span&gt;
aws securityhub describe-standards &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Standards[].StandardsArn'&lt;/span&gt;
aws securityhub batch-enable-standards &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--standards-subscription-requests&lt;/span&gt; &lt;span class="nv"&gt;StandardsArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STANDARDS_ARN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass each ARN you want from the &lt;code&gt;describe-standards&lt;/code&gt; output. The second setting is the home Region.&lt;/p&gt;

&lt;p&gt;Match the home Region to where IAM is recorded&lt;/p&gt;

&lt;p&gt;If you use central configuration or cross-Region aggregation, and your &lt;a href="https://docs.aws.amazon.com/securityhub/latest/userguide/finding-aggregation.html" rel="noopener noreferrer"&gt;home Region&lt;/a&gt; differs from the Region recording global resources, the IAM controls sit at WARNING or DISABLED and never evaluate. On the estate I worked on, that is exactly where they sat until the home Region was moved to match.&lt;/p&gt;

&lt;p&gt;Verify that the &lt;code&gt;IAM.&lt;/code&gt; controls show PASSED or FAILED; DISABLED or WARNING means they aren't running.&lt;/p&gt;

&lt;p&gt;CSPM costs $0.0010 per check for the first 100,000 checks, and the first 10,000 finding ingestions a month are free, per &lt;a href="https://aws.amazon.com/security-hub/cspm/pricing/" rel="noopener noreferrer"&gt;CSPM pricing&lt;/a&gt;. AWS Config is billed separately at $0.003 per configuration item recorded (&lt;a href="https://aws.amazon.com/config/pricing/" rel="noopener noreferrer"&gt;Config pricing&lt;/a&gt;). There's a 30-day trial, and the console shows estimated costs during the trial.&lt;/p&gt;

&lt;p&gt;AWS also sells a newer unified Security Hub, priced differently: the Essentials plan is $3.75 per resource unit, where one unit is one EC2 instance, 12 Lambda functions, 18 ECR images or 125 IAM users and roles (&lt;a href="https://aws.amazon.com/security-hub/pricing/" rel="noopener noreferrer"&gt;unified pricing&lt;/a&gt;). Make sure you're signing up for the product you meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do as you grow: multi-account guardrails
&lt;/h2&gt;

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

&lt;h3&gt;
  
  
  15. Turn on centralized root access
&lt;/h3&gt;

&lt;p&gt;Your security tooling will flag "root MFA missing" on member accounts. Accounts created in Organizations now start with no root credentials, but older and invited accounts often still have a password, and anyone who controls that root email inbox can reset it. The fix is &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html" rel="noopener noreferrer"&gt;centralized root access&lt;/a&gt;, which removes member root credentials and lets the management account run &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user-privileged-task.html" rel="noopener noreferrer"&gt;privileged root tasks&lt;/a&gt; when one is needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws organizations enable-aws-service-access &lt;span class="nt"&gt;--service-principal&lt;/span&gt; iam.amazonaws.com
aws iam enable-organizations-root-credentials-management
aws iam enable-organizations-root-sessions

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  16. Attach a baseline SCP
&lt;/h3&gt;

&lt;p&gt;A service control policy (SCP) caps what principals in member accounts can do, whatever their IAM permissions say. A baseline SCP stops anyone, including an attacker with an admin role, from leaving the organization, switching off detection or acting as root. The &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html" rel="noopener noreferrer"&gt;SCP guide&lt;/a&gt; covers the mechanics, and AWS's &lt;a href="https://github.com/aws-samples/service-control-policy-examples/tree/main/Deny-changes-to-security-services" rel="noopener noreferrer"&gt;deny-changes-to-security-services examples&lt;/a&gt; go further than this one.&lt;/p&gt;

&lt;p&gt;baseline-scp.json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyLeaveOrg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"organizations:LeaveOrganization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ProtectDetection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:StopLogging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:DeleteTrail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:UpdateTrail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cloudtrail:PutEventSelectors"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"guardduty:DeleteDetector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"guardduty:UpdateDetector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"guardduty:DisassociateFromMasterAccount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"securityhub:DisableSecurityHub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"securityhub:DisassociateFromAdministratorAccount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"access-analyzer:DeleteAnalyzer"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/your-security-admin-role"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DenyRootUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:root"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Null"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:AssumedRoot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Null&lt;/code&gt; condition exempts privileged root sessions started from the management account (item 15), so centralized root tasks keep working. The root statement follows &lt;a href="https://github.com/aws-samples/service-control-policy-examples/blob/main/Privileged-access-controls/Prevent-root-credentials-management-in-member-accounts-in-AWS-Organizations.json" rel="noopener noreferrer"&gt;AWS's own example&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Control Tower has one more catch: registering an OU attaches its own guardrails, not your baseline SCP, so compare attached policies with a governed OU. I cover that gap in &lt;a href="https://shahidyousuf.dev/blog/move-aws-account-to-another-organization/" rel="noopener noreferrer"&gt;how to move an AWS account to another organization&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  17. Restrict Regions with an SCP
&lt;/h3&gt;

&lt;p&gt;A Region-deny SCP limits accounts to an allowed list, so resources can't appear in Regions nobody watches. Start from the &lt;code&gt;Region-controls/Deny-access-to-AWS-based-on-the-requested-AWS-region.json&lt;/code&gt; example in the &lt;a href="https://github.com/aws-samples/service-control-policy-examples" rel="noopener noreferrer"&gt;aws-samples SCP examples repository&lt;/a&gt;, the &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-requested-region.html" rel="noopener noreferrer"&gt;IAM Region-deny example&lt;/a&gt;, or the &lt;a href="https://docs.aws.amazon.com/controltower/latest/controlreference/primary-region-deny-policy.html" rel="noopener noreferrer"&gt;Control Tower Region deny control&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Inventory S3 and every Regional service, not only EC2&lt;/p&gt;

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

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

&lt;h3&gt;
  
  
  18. Check RDS snapshot sharing
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws rds describe-db-snapshots &lt;span class="nt"&gt;--snapshot-type&lt;/span&gt; manual &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBSnapshots[].DBSnapshotIdentifier'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;aws rds describe-db-snapshot-attributes &lt;span class="nt"&gt;--db-snapshot-identifier&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"DBSnapshotAttributesResult.DBSnapshotAttributes[?AttributeName=='restore'].AttributeValues[]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qw&lt;/span&gt; all &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PUBLIC: &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;restore&lt;/code&gt; attribute value of &lt;code&gt;all&lt;/code&gt; &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/rds/describe-db-snapshot-attributes.html" rel="noopener noreferrer"&gt;means the snapshot is public&lt;/a&gt;. For Aurora, run the same check with &lt;code&gt;describe-db-cluster-snapshots&lt;/code&gt; and &lt;code&gt;describe-db-cluster-snapshot-attributes&lt;/code&gt;. Repeat per Region.&lt;/p&gt;

&lt;h3&gt;
  
  
  19. Hold settings in place with declarative policies
&lt;/h3&gt;

&lt;p&gt;Some controls are states, not actions: S3 Block Public Access on, IMDSv2 required, AMIs and snapshots never shared publicly. &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_declarative_policies.html" rel="noopener noreferrer"&gt;Declarative policies&lt;/a&gt; hold those settings at a fixed value across the organization, enforced by the service itself. &lt;a href="https://shahidyousuf.dev/blog/scp-vs-rcp-declarative-policies/" rel="noopener noreferrer"&gt;SCP vs RCP vs declarative policies&lt;/a&gt; compares all three policy types, RCPs included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the controls actually work
&lt;/h2&gt;

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

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

&lt;p&gt;A small helper for the first habit:&lt;/p&gt;

&lt;p&gt;normalize_policy.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursively sort dict keys and list items so two policies compare equal.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;same_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deployed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deployed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;What you pay for&lt;/th&gt;
&lt;th&gt;Free allowance or trial&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/cloudtrail/pricing/" rel="noopener noreferrer"&gt;CloudTrail&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Additional event copies, S3 storage&lt;/td&gt;
&lt;td&gt;First copy of management events free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/guardduty/pricing/" rel="noopener noreferrer"&gt;GuardDuty&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$4.00 per million CloudTrail events, $1.00 per GB flow and DNS logs (first tier, us-east-1), plus protection plans&lt;/td&gt;
&lt;td&gt;30-day trial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/iam/access-analyzer/pricing/" rel="noopener noreferrer"&gt;Access Analyzer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Unused access: $0.20 per role or user per month&lt;/td&gt;
&lt;td&gt;External access analysis free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/security-hub/cspm/pricing/" rel="noopener noreferrer"&gt;Security Hub CSPM&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.0010 per check (first 100,000)&lt;/td&gt;
&lt;td&gt;10,000 finding ingestions a month free; 30-day trial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/config/pricing/" rel="noopener noreferrer"&gt;AWS Config&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.003 per configuration item recorded&lt;/td&gt;
&lt;td&gt;No free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/secrets-manager/pricing/" rel="noopener noreferrer"&gt;Secrets Manager&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$0.40 per secret per month&lt;/td&gt;
&lt;td&gt;Parameter Store standard parameters free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/aws-cost-management/aws-budgets/pricing/" rel="noopener noreferrer"&gt;AWS Budgets&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Action-enabled budgets beyond the first two&lt;/td&gt;
&lt;td&gt;Monitoring free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Pricing models only. Your total depends on activity, account count and Regions.&lt;/em&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  The full checklist
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

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

&lt;p&gt;Whatever you turn on, run its verify step. A control that is enabled but not evaluating looks exactly like one that works, right up until you need it. If you're moving to more than one account, read &lt;a href="https://shahidyousuf.dev/blog/scp-vs-rcp-declarative-policies/" rel="noopener noreferrer"&gt;SCP vs RCP vs declarative policies&lt;/a&gt; before writing your first guardrail, and &lt;a href="https://shahidyousuf.dev/blog/github-actions-oidc-aws-trust-policy/" rel="noopener noreferrer"&gt;GitHub Actions OIDC trust policy for AWS&lt;/a&gt; before retiring your CI keys.&lt;/p&gt;

&lt;p&gt;If you'd rather have someone walk your accounts against this list, I do short AWS security and governance reviews: written findings, prioritized fixes and the commands to verify each one. Consulting starts at $75 an hour (&lt;a href="https://shahidyousuf.dev/work/#rates" rel="noopener noreferrer"&gt;see rates&lt;/a&gt;). The first step is a &lt;a href="https://shahidyousuf.dev/book/" rel="noopener noreferrer"&gt;free 20-minute intro call&lt;/a&gt;, or you can &lt;a href="https://shahidyousuf.dev/contact/?engagement=consulting&amp;amp;kind=consulting" rel="noopener noreferrer"&gt;request a quote for a security review&lt;/a&gt; directly.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>🚀 Embracing the Inevitable: Change as the Sole Constant in Software</title>
      <dc:creator>Shahid Yousuf</dc:creator>
      <pubDate>Sat, 11 Nov 2023 15:08:51 +0000</pubDate>
      <link>https://dev.to/shahidyousuf/embracing-the-inevitable-change-as-the-sole-constant-in-software-41dd</link>
      <guid>https://dev.to/shahidyousuf/embracing-the-inevitable-change-as-the-sole-constant-in-software-41dd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ioor9yz9t30vjks8krm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ioor9yz9t30vjks8krm.jpg" alt="Changes ahead" width="600" height="410"&gt;&lt;/a&gt;&lt;br&gt;
In the exhilarating realm of software development, one fundamental truth prevails – change is the only constant. As technology hurtles forward, the evolution of design patterns and principles has become the compass guiding developers through the dynamic landscape of innovation&lt;/p&gt;

&lt;p&gt;In the ever-changing landscape of software design, the evolution of patterns and principles has been pivotal. Over the years, these design foundations have adapted to meet the demands of dynamic development. Here's a glimpse into this transformative journey:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Object-Oriented Design to SOLID Principles:&lt;/strong&gt; From the foundational principles of Object-Oriented Design (OOD), the industry embraced SOLID principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion). This shift refined the approach to building maintainable and scalable software by emphasizing modularity and flexibility.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Evolution of Gang of Four (GoF) Design Patterns:&lt;/strong&gt; The timeless Gang of Four design patterns (like Singleton, Factory, Observer) laid the groundwork for effective software design. As development needs evolved, so did these patterns. Modern applications often incorporate variations and adaptations, showcasing the dynamic nature of design patterns to address contemporary challenges.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Introduction of Domain-Driven Design (DDD):&lt;/strong&gt; DDD emerged as a paradigm shift, placing a strong focus on the domain itself. By aligning design with the business domain, DDD provides a strategic approach to modeling complex systems. This design evolution fosters a shared understanding between developers and domain experts, enhancing the effectiveness of software solutions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Event-Driven Architecture (EDA):&lt;/strong&gt; With the rise of scalable and distributed systems, Event-Driven Architecture gained prominence. Design patterns like Event Sourcing and CQRS (Command Query Responsibility Segregation) allow systems to handle events efficiently, enabling adaptability to changing business requirements and ensuring robustness in complex workflows.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Microservices and Design Patterns:&lt;/strong&gt; The shift towards microservices architecture introduced new design challenges and opportunities. Patterns like API Gateway, Service Mesh, and Circuit Breaker became crucial for managing the intricacies of microservices. These patterns enable the construction of resilient, scalable, and independently deployable services, aligning with the need for agile development.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Responsive UI with Frontend Design Patterns:&lt;/strong&gt; As user interfaces evolved, so did frontend design patterns. The advent of patterns like Redux for state management, and Component-Based Architecture for building reusable UI elements, showcased a shift towards more responsive and modular frontend design, accommodating the demands of modern user experiences.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In conclusion, the evolution of design patterns and principles mirrors the industry's commitment to refining approaches for building robust and adaptable software. Embracing new paradigms and adapting existing patterns ensures that software design remains responsive to the ever-changing demands of technology. 🌐&lt;/p&gt;

</description>
      <category>software</category>
      <category>design</category>
      <category>python</category>
      <category>java</category>
    </item>
  </channel>
</rss>
