<?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: Rafagross</title>
    <description>The latest articles on DEV Community by Rafagross (@rafagross).</description>
    <link>https://dev.to/rafagross</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%2F1041701%2F478e8253-afa1-41a1-be42-69758a77c15f.png</url>
      <title>DEV Community: Rafagross</title>
      <link>https://dev.to/rafagross</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rafagross"/>
    <language>en</language>
    <item>
      <title>Amazon Cognito Pricing: Tiers, MAU Counting, and the Defaults That Inflate the Bill</title>
      <dc:creator>Rafagross</dc:creator>
      <pubDate>Tue, 13 Oct 2026 04:58:42 +0000</pubDate>
      <link>https://dev.to/rafagross/amazon-cognito-pricing-tiers-mau-counting-and-the-defaults-that-inflate-the-bill-25hg</link>
      <guid>https://dev.to/rafagross/amazon-cognito-pricing-tiers-mau-counting-and-the-defaults-that-inflate-the-bill-25hg</guid>
      <description>&lt;p&gt;Cognito is one of those services nobody budgets for. The user pool goes in early, the free tier covers development, and for a while the line item on the bill is zero. Then the product grows, or someone runs a migration, or a batch job starts touching every user, and the Cognito charge becomes a number that needs explaining.&lt;/p&gt;

&lt;p&gt;Usually that number has little to do with traffic. It comes from decisions made once, early, by whoever created the pool: which tier it runs on, what the application does with tokens, how users were imported, and which jobs read user data. This post goes through the billing model, the places where it surprises people, and the settings that keep it predictable.&lt;/p&gt;

&lt;p&gt;All prices below come from the &lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Amazon Cognito pricing page&lt;/a&gt;, checked on October 12, 2026. Every dollar figure is calculated from those published rates, and the inputs are shown so you can redo the math with your own numbers. Rates change, so check the page before you quote any of this in a planning document.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Cognito Bills
&lt;/h2&gt;

&lt;p&gt;User pools are billed per monthly active user (MAU), and the rate depends on the pool's feature tier. There are three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Price per MAU above the free tier&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lite&lt;/td&gt;
&lt;td&gt;$0.0055 up to 100,000 MAU, then $0.0046&lt;/td&gt;
&lt;td&gt;10,000 MAU&lt;/td&gt;
&lt;td&gt;Password sign-in, social and SAML/OIDC federation, MFA with SMS or authenticator apps, Lambda triggers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Essentials&lt;/td&gt;
&lt;td&gt;$0.015&lt;/td&gt;
&lt;td&gt;10,000 MAU&lt;/td&gt;
&lt;td&gt;Managed login with the visual editor, passwordless and passkeys, email MFA, access token customization, refresh token rotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;$0.020&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Threat protection: adaptive authentication, compromised credential detection, exportable user activity logs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things sit outside that table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Federated users&lt;/strong&gt; who sign in through SAML 2.0 or OIDC are priced separately: $0.015 per MAU, with a free tier of 50 MAU, whatever tier the pool is on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine-to-machine&lt;/strong&gt; authorization is billed per successful token response, with no free tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity pools&lt;/strong&gt; are free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMS and email&lt;/strong&gt; go out through Amazon SNS and Amazon SES and are billed by those services, not by Cognito.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Default Tier Is Not the Cheap One
&lt;/h2&gt;

&lt;p&gt;New user pools are created on Essentials unless you say otherwise. Essentials costs $0.015 per MAU. Lite costs $0.0055, and $0.0046 past 100,000 MAU. The default is almost three times the price of the cheapest tier.&lt;/p&gt;

&lt;p&gt;At 100,000 MAU, after the free tier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lite&lt;/td&gt;
&lt;td&gt;90,000 × $0.0055&lt;/td&gt;
&lt;td&gt;$495&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Essentials&lt;/td&gt;
&lt;td&gt;90,000 × $0.015&lt;/td&gt;
&lt;td&gt;$1,350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;100,000 × $0.020&lt;/td&gt;
&lt;td&gt;$2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhev2gq4y45i8dcs8btci.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhev2gq4y45i8dcs8btci.png" alt="Line chart of monthly Cognito cost from 0 to 500,000 MAU. At 500,000 MAU: Lite $2,335, Essentials $7,350 (the default for new pools), Plus $10,000, Lite with legacy advanced security features $14,585" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Essentials is a reasonable choice when you use what it adds. If your application signs users in with a password or a social provider and you built your own login screens, you are probably paying for passkeys and a managed login editor that no one uses. Check the tier on existing pools before anything else in this post. It is the cheapest fix there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a User Billable
&lt;/h2&gt;

&lt;p&gt;This is the part that produces the bills people don't expect. The pricing page defines an MAU as a user for whom your application generates an identity operation during the calendar month. The list is longer than most people assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sign-up, sign-in, and sign-out&lt;/li&gt;
&lt;li&gt;token refresh&lt;/li&gt;
&lt;li&gt;password change and attribute updates&lt;/li&gt;
&lt;li&gt;administrative creation or update of the user&lt;/li&gt;
&lt;li&gt;an attribute query on the user through &lt;code&gt;AdminGetUser&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once a user is counted, nothing else about them adds cost that month. Subsequent sessions are free, users with no identity operation are free, and with multi-Region replication the same user is counted once across Regions.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s20xx6ov7phqyusw603.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s20xx6ov7phqyusw603.png" alt="Log view of six identity operations for three users in one month. Only one user opened the app, but a nightly AdminGetUser sync and a background token refresh made the other two billable, for 3 MAU in total" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the last two items on that list again. They are things your code does, not things your users do. That is where the four patterns below come from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch jobs that read every user
&lt;/h3&gt;

&lt;p&gt;A nightly job enriches a reporting table, or syncs user attributes to a CRM, and it calls &lt;code&gt;AdminGetUser&lt;/code&gt; once per user. Every user it reads becomes an MAU for the month, including the ones who haven't opened the app in a year.&lt;/p&gt;

&lt;p&gt;Take a pool with 300,000 users, of whom 60,000 actually sign in during a given month:&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;Essentials&lt;/th&gt;
&lt;th&gt;Lite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only real users counted (60,000)&lt;/td&gt;
&lt;td&gt;$750&lt;/td&gt;
&lt;td&gt;$275&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nightly job reads all 300,000&lt;/td&gt;
&lt;td&gt;$4,350&lt;/td&gt;
&lt;td&gt;$1,415&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is to stop asking Cognito for data you already have. The ID token carries the user's attributes at sign-in. If a job needs attributes for users who are not signed in, keep your own copy in a table and update it from a Lambda trigger or from your application when attributes change.&lt;/p&gt;

&lt;p&gt;The pricing page names &lt;code&gt;AdminGetUser&lt;/code&gt; explicitly. It says nothing either way about &lt;code&gt;ListUsers&lt;/code&gt;. I would not switch a job to &lt;code&gt;ListUsers&lt;/code&gt; and assume it's free without watching a full month of billing first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token refresh on a timer
&lt;/h3&gt;

&lt;p&gt;Token refresh counts as an identity operation. That's fine when a user is actually using the app, because they are already counted. It becomes a cost when the app refreshes tokens on its own. A mobile app that refreshes on launch or in a background task turns every installed copy into an MAU every month, whether the person did anything or not.&lt;/p&gt;

&lt;p&gt;Refresh when an API call needs a fresh token, not on launch and not on a schedule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrations that create everyone at once
&lt;/h3&gt;

&lt;p&gt;Administrative creation counts. If you move 500,000 users into a new pool with &lt;code&gt;AdminCreateUser&lt;/code&gt; in one month, that month bills 500,000 MAUs: $7,350 on Essentials, even if most of those users never come back.&lt;/p&gt;

&lt;p&gt;The Migrate User Lambda trigger avoids this. Cognito calls it when a user who doesn't exist in the pool tries to sign in or reset their password, and the trigger creates the user from your old system at that moment. You only ever create, and pay for, the users who return.&lt;/p&gt;

&lt;p&gt;There is a catch. The trigger needs the user's password, so it only fires for password flows that send it: &lt;code&gt;USER_PASSWORD_AUTH&lt;/code&gt;, &lt;code&gt;ADMIN_USER_PASSWORD_AUTH&lt;/code&gt;, and &lt;code&gt;USER_AUTH&lt;/code&gt; with a password, plus forgot-password. It does not fire for SRP sign-in, which many client libraries use by default, or for passwordless sign-in. If your clients use SRP, they have to switch flows for the migration window, or the trigger never runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load tests against a real pool
&lt;/h3&gt;

&lt;p&gt;Synthetic users are users. A load test that signs in 50,000 test accounts after the free tier is used up costs $750 on Essentials for that month. Which leads to the next problem: the free tier probably isn't where you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Free Tier Is Smaller Than It Looks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It is defined per account or per AWS organization.&lt;/strong&gt; If your accounts sit in one organization, plan on staging, development, and production sharing a single 10,000 MAU allowance rather than getting 10,000 each. A load test in a sandbox account can use up the free tier that production was counting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plus has none.&lt;/strong&gt; On Plus you pay from the first user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Federated users get 50.&lt;/strong&gt; For users who sign in through SAML or OIDC, the free tier is 50 MAU and the rate is $0.015 on every tier, Lite included. A pool of 30,000 SSO users costs $449.25 a month. If someone estimated it with Lite's direct sign-in rate, they budgeted about $110.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Older pools have a better deal, and you can lose it.&lt;/strong&gt; User pools created on or before November 22, 2024 that stay on Lite keep a free tier of 50,000 MAU. Accounts that had at least one MAU in the twelve months before that date can also create new Lite pools that count against the same 50,000.&lt;/p&gt;

&lt;p&gt;That makes "upgrading" an older pool expensive in a way that isn't obvious. A legacy pool with 60,000 MAU on Lite pays for 10,000 users: $55. Move it to Essentials and it pays for 50,000: $750. Same users, about thirteen times the bill. There was a promotion that let existing accounts move to Essentials at the old price, but it ended on November 30, 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching Tiers Mid-Month
&lt;/h2&gt;

&lt;p&gt;When a pool changes tier during a month, each MAU is billed at the highest-priced tier that was enabled while that user was active.&lt;/p&gt;

&lt;p&gt;That turns "let's try Plus for a couple of days" into an expensive experiment. Take a Lite pool with 400,000 MAU. Someone enables Plus on day 12 to evaluate threat protection and switches back on day 14. 150,000 users happened to sign in during those two days.&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;Calculation&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;150,000 users active on days 12 or 13&lt;/td&gt;
&lt;td&gt;Plus, no free tier: 150,000 × $0.020&lt;/td&gt;
&lt;td&gt;$3,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;250,000 users active only on other days&lt;/td&gt;
&lt;td&gt;Lite: 90,000 × $0.0055 + 150,000 × $0.0046&lt;/td&gt;
&lt;td&gt;$1,185&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$4,185&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same month, Lite throughout&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;$1,875&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjgzhrilpnov50vj63uys.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjgzhrilpnov50vj63uys.png" alt="November 2026 calendar for a 400,000 MAU Lite pool with Plus enabled on November 12 and 13. The month bills $4,185 instead of $1,875" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Evaluate tiers in a separate pool with test users. Never in the production pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Machine-to-Machine Tokens
&lt;/h2&gt;

&lt;p&gt;For the client credentials flow, Cognito charges per successful token response: $0.00225 in US East (N. Virginia), with no free tier. The pricing page says there's no additional charge for the number of app clients registered. What moves the bill is how often they ask for a token.&lt;/p&gt;

&lt;p&gt;The most expensive version of this is a service that requests a new token before every call instead of caching it. At 10 requests per second, that's about 25.9 million token responses in a 30-day month, or $58,320.&lt;/p&gt;

&lt;p&gt;Even with caching, token lifetime moves the bill. Take 200 workers that each fetch a token when the previous one expires:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Access token validity&lt;/th&gt;
&lt;th&gt;Token responses per month&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1,728,000&lt;/td&gt;
&lt;td&gt;$3,888&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;576,000&lt;/td&gt;
&lt;td&gt;$1,296&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 minutes&lt;/td&gt;
&lt;td&gt;144,000&lt;/td&gt;
&lt;td&gt;$324&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Access token validity is configured per app client. Pick it on purpose, cache the token until shortly before it expires, and if you run many workers for the same client, share one cached token between them instead of letting each fetch its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Costs That Don't Show Up on the Cognito Line
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SMS
&lt;/h3&gt;

&lt;p&gt;SMS for MFA, sign-up confirmation, password recovery, and phone verification is sent through Amazon SNS and billed at SNS rates, which vary a lot by destination country. None of it appears in a Cognito estimate.&lt;/p&gt;

&lt;p&gt;SMS also attracts abuse. Sign-up and forgot-password flows that send a text message can be driven by bots to generate messages to numbers the attacker profits from. Three things help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set the SNS account spend limit for SMS deliberately. The default is $1 a month, which protects you right up until someone raises it to fix a delivery problem and doesn't think about the new number. SNS can take a few minutes to stop sending once the limit is reached, so expect a little overshoot.&lt;/li&gt;
&lt;li&gt;Associate AWS WAF with the user pool and rate limit the sign-up and recovery endpoints.&lt;/li&gt;
&lt;li&gt;Prefer authenticator apps or email codes over SMS where your users allow it. Email one-time codes for MFA need Essentials or Plus, so weigh that against what you'd save on SMS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Legacy advanced security features
&lt;/h3&gt;

&lt;p&gt;Pools that enabled advanced security features (ASF) before the tiers existed still pay ASF as an add-on to Lite, with no free tier: $0.05 per MAU for the first 50,000, $0.035 for the next 50,000, and $0.02 after that.&lt;/p&gt;

&lt;p&gt;At 100,000 MAU that is $495 for Lite plus $4,250 for ASF, $4,745 in total. Plus includes the same protections for $2,000. AWS's own example at 950,000 MAU puts Lite with ASF at $25,655 and Plus at $19,000. If any pool still has &lt;code&gt;AdvancedSecurityMode&lt;/code&gt; set to &lt;code&gt;AUDIT&lt;/code&gt; or &lt;code&gt;ENFORCED&lt;/code&gt; on Lite, moving it to Plus is a straight saving.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Region replication
&lt;/h3&gt;

&lt;p&gt;Replication is an add-on billed per MAU, $0.0045 on Essentials, and it is charged whether or not you ever fail over. For a pool with 500,000 MAU, that's $2,250 a month on top of a $7,350 base, about 31% more. It may be worth it. It should be a decision, not something that was switched on during a resilience review and forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Set the tier in code
&lt;/h3&gt;

&lt;p&gt;Leaving the tier out means Essentials. Write it down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cognito_user_pool"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main-users"&lt;/span&gt;
  &lt;span class="nx"&gt;user_pool_tier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LITE"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cognito-idp update-user-pool &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-id&lt;/span&gt; us-east-1_EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-pool-tier&lt;/span&gt; LITE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;update-user-pool&lt;/code&gt; replaces any settings you don't pass with their defaults, so run it with the pool's full current configuration or make the change in your infrastructure code instead. In the AWS CDK, the property is &lt;code&gt;featurePlan: cognito.FeaturePlan.LITE&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find out what you have
&lt;/h3&gt;

&lt;p&gt;This lists every pool in a Region with its tier and any legacy ASF setting:&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 cognito-idp list-user-pools &lt;span class="nt"&gt;--max-results&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'UserPools[].Id'&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 cognito-idp describe-user-pool &lt;span class="nt"&gt;--user-pool-id&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="s1"&gt;'UserPool.[Id,Name,UserPoolTier,UserPoolAddOns.AdvancedSecurityMode]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User pools are regional, so repeat it with &lt;code&gt;--region&lt;/code&gt; for every Region you use. Anything on Essentials or Plus should have a reason. Anything on Lite with ASF set to &lt;code&gt;AUDIT&lt;/code&gt; or &lt;code&gt;ENFORCED&lt;/code&gt; should probably be on Plus.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put a number on it
&lt;/h3&gt;

&lt;p&gt;In the console, go to &lt;strong&gt;Billing and Cost Management → Budgets → Create budget&lt;/strong&gt;, choose a cost budget, and filter by the service &lt;strong&gt;Amazon Cognito&lt;/strong&gt;. Set the alert a little above your normal month. Most of the patterns in this post show up as a step change, so a budget alert catches them in the month they happen rather than when the invoice lands. AWS Cost Anomaly Detection is worth enabling alongside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Every user pool has its tier set in code, and the tier matches the features it uses.&lt;/li&gt;
&lt;li&gt;No scheduled job calls &lt;code&gt;AdminGetUser&lt;/code&gt; across the user base.&lt;/li&gt;
&lt;li&gt;Clients refresh tokens when they need them, not on launch or on a timer.&lt;/li&gt;
&lt;li&gt;Bulk migrations use the Migrate User trigger instead of &lt;code&gt;AdminCreateUser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tier evaluations and load tests run against a separate pool, and nobody assumes each account has its own free tier.&lt;/li&gt;
&lt;li&gt;M2M clients cache tokens, and the access token validity was chosen on purpose.&lt;/li&gt;
&lt;li&gt;No pool runs Lite with legacy ASF, SMS has a spending limit, and the pool has WAF in front of sign-up.&lt;/li&gt;
&lt;/ol&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0axdih6o4un3p83t7flo.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0axdih6o4un3p83t7flo.png" alt="Checklist card in the style of an aviation normal procedure, grouping Cognito cost controls into before creating a pool, before go-live, before a migration, and monthly" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nearly every item on that list is a one-time decision. That's the point of making them early. A Cognito bill rarely grows because more people use the product. It grows because of what the pool was set up to count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Amazon Cognito pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html" rel="noopener noreferrer"&gt;User pool feature plans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html" rel="noopener noreferrer"&gt;Migrate user Lambda trigger&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html" rel="noopener noreferrer"&gt;Machine-to-machine authorization with the client credentials grant&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-waf.html" rel="noopener noreferrer"&gt;Associating an AWS WAF web ACL with a user pool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/sns/latest/dg/sms_preferences.html" rel="noopener noreferrer"&gt;SMS spending limits in Amazon SNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool" rel="noopener noreferrer"&gt;Terraform: aws_cognito_user_pool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.FeaturePlan.html" rel="noopener noreferrer"&gt;AWS CDK: FeaturePlan&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>cognito</category>
      <category>finops</category>
      <category>security</category>
    </item>
    <item>
      <title>The Confused Deputy Problem in AWS: ExternalId, aws:Source* Keys, and RCPs</title>
      <dc:creator>Rafagross</dc:creator>
      <pubDate>Thu, 08 Oct 2026 21:33:31 +0000</pubDate>
      <link>https://dev.to/rafagross/the-confused-deputy-problem-in-aws-externalid-awssource-keys-and-rcps-47nk</link>
      <guid>https://dev.to/rafagross/the-confused-deputy-problem-in-aws-externalid-awssource-keys-and-rcps-47nk</guid>
      <description>&lt;p&gt;Most IAM incidents people talk about start with a leaked access key or a wildcard that was too generous. The confused deputy problem is different. Nothing leaks. Every API call is authorized. CloudTrail shows a party you trust doing exactly what your policy lets it do. The only thing wrong is who asked it to.&lt;/p&gt;

&lt;p&gt;That is what makes it easy to miss in a review. This post covers the two variants AWS documents, cross-account and cross-service, the policy conditions that fix each one, and the details that decide whether the fix works in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A confused deputy is a privileged party that gets talked into using its privileges for someone who doesn't have them. In AWS terms, an entity that is not allowed to perform an action coerces a more privileged entity into performing it.&lt;/p&gt;

&lt;p&gt;The root cause is the same in every version of it. An IAM policy answers one question: who is calling? It does not ask on whose behalf the caller is acting, unless you write a condition that does.&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;Cross-account&lt;/th&gt;
&lt;th&gt;Cross-service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The actor&lt;/td&gt;
&lt;td&gt;Another customer of the same vendor&lt;/td&gt;
&lt;td&gt;Any account that can configure the calling service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The deputy&lt;/td&gt;
&lt;td&gt;The vendor's AWS account&lt;/td&gt;
&lt;td&gt;An AWS service principal such as &lt;code&gt;cloudtrail.amazonaws.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the policy checks&lt;/td&gt;
&lt;td&gt;Is the caller the vendor's account?&lt;/td&gt;
&lt;td&gt;Is the caller that service?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it never checks&lt;/td&gt;
&lt;td&gt;Which customer the vendor is acting for&lt;/td&gt;
&lt;td&gt;Which account or resource the service is acting for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sts:ExternalId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aws:SourceArn&lt;/code&gt;, &lt;code&gt;aws:SourceAccount&lt;/code&gt;, &lt;code&gt;aws:SourceOrgID&lt;/code&gt;, &lt;code&gt;aws:SourceOrgPaths&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Cross-Account: The Vendor as Deputy
&lt;/h2&gt;

&lt;p&gt;If you have onboarded a cost, monitoring, or security posture SaaS, you know the routine. You create a role in your account, trust the vendor's AWS account, and paste the role ARN into their console. From then on the vendor calls &lt;code&gt;sts:AssumeRole&lt;/code&gt; and reads what the role allows.&lt;/p&gt;

&lt;p&gt;The vendor does this for every customer, from the same AWS account. So picture another customer of that vendor, tenant B, pasting your role ARN into their own onboarding form. A role ARN is an identifier, not a credential. It shows up in tickets, in infrastructure code, in screenshots. Learning or guessing one is not hard.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaidd0iphkfnbe1jnfit.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaidd0iphkfnbe1jnfit.png" alt="Cross-account confused deputy attack sequence" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now look at the trust policy most onboarding guides hand you:&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="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::999988887777: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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the vendor assumes the role for tenant B, STS checks whether the caller is in the principal (yes, it is the vendor's account), whether the action is allowed (yes), and whether there is a condition to satisfy (there is none). The result is an Allow. The request the vendor sends for tenant B is identical to the one it sends for you. The policy has nothing to tell them apart with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: sts:ExternalId
&lt;/h2&gt;

&lt;p&gt;The external ID adds the missing fact to the request. The vendor assigns a unique value to each customer and passes that value on every &lt;code&gt;AssumeRole&lt;/code&gt; call it makes for that customer. Your trust policy requires yours:&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;"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::999988887777: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="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;"sts:ExternalId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7f3c2b1e-58d4-4c0a-9e6f-b2d1c4a7a91e"&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;And the vendor's side of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts assume-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-arn&lt;/span&gt; arn:aws:iam::111122223333:role/vendor-readonly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-session-name&lt;/span&gt; tenant-a-sync &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--external-id&lt;/span&gt; 7f3c2b1e-58d4-4c0a-9e6f-b2d1c4a7a91e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When tenant B submits your role ARN, the vendor sends tenant B's external ID, because that is who triggered the work. It doesn't match the condition on your role, no statement allows the call, and STS returns &lt;code&gt;AccessDenied&lt;/code&gt;.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwav9v9xn11g063tw7vz.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftwav9v9xn11g063tw7vz.png" alt="Same role ARN with and without the correct external ID" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things about external IDs that are worth getting right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The vendor generates it, not you.&lt;/strong&gt; If customers could pick their own, tenant B would simply pick yours. AWS recommends one random string per customer AWS account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a secret.&lt;/strong&gt; Anyone with permission to view the role can read it. Its job is to be unique per customer and outside the customer's control, not to be hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format.&lt;/strong&gt; Between 2 and 1,224 characters, alphanumeric, no whitespace, plus a handful of symbols such as &lt;code&gt;+ = , . @ : / -&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you are the vendor, test at onboarding.&lt;/strong&gt; Try to assume the customer's role with and without the correct external ID. If it works without it, the trust policy isn't enforcing anything and you should not store that ARN.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole scheme depends on the vendor always sending the ID of the customer that triggered the request. A condition in your trust policy only does its job if the vendor does theirs. That's a fair question to ask during vendor review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Service: An AWS Service as Deputy
&lt;/h2&gt;

&lt;p&gt;The second variant has no third party at all. The deputy is an AWS service.&lt;/p&gt;

&lt;p&gt;Many services reach into your resources using their service principal. CloudTrail writing to a central log bucket is the textbook case. The bucket policy has an Allow for &lt;code&gt;cloudtrail.amazonaws.com&lt;/code&gt;, and that feels specific. It isn't. That principal is CloudTrail for every AWS account, not CloudTrail for yours.&lt;/p&gt;

&lt;p&gt;So an account you have never heard of can create a trail, name your bucket as the destination, and CloudTrail will write there, because your bucket policy told S3 that CloudTrail is welcome.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9omfoxbm2h4gqdng1g4.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9omfoxbm2h4gqdng1g4.png" alt="Cross-service confused deputy with CloudTrail and S3" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The useful part is that the service already tells the target on whose behalf it is acting. Requests made by AWS service principals carry that context in global condition keys. Your policy only has to test it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;The service is acting on behalf of&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:SourceArn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One specific resource&lt;/td&gt;
&lt;td&gt;You know the exact ARN: one trail, one topic, one fleet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:SourceAccount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any resource in one account&lt;/td&gt;
&lt;td&gt;The ARN has no account ID, or many resources share one grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:SourceOrgID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any account in one organization&lt;/td&gt;
&lt;td&gt;A central resource serves every account, or you enforce it in an RCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:SourceOrgPaths&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Accounts under an Organizations path&lt;/td&gt;
&lt;td&gt;Only part of the organization, such as one OU, should reach it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  CloudTrail to S3: pin the bucket to one trail
&lt;/h3&gt;

&lt;p&gt;This is the statement the CloudTrail documentation recommends, with &lt;code&gt;aws:SourceArn&lt;/code&gt; set to the trail:&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;"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;"AWSCloudTrailWrite"&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;"Service"&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.amazonaws.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;"s3:PutObject"&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:s3:::example-org-trail-logs/AWSLogs/111122223333/*"&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;"s3:x-amz-acl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bucket-owner-full-control"&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:cloudtrail:us-east-1:111122223333:trail/org-trail"&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;Put the same &lt;code&gt;aws:SourceArn&lt;/code&gt; condition on the &lt;code&gt;s3:GetBucketAcl&lt;/code&gt; statement next to it. For an organization trail, the ARN is the trail in the management account, not in the member accounts.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 events to Lambda: when the ARN has no account ID
&lt;/h3&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;"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;"allow-s3"&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;"Service"&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.amazonaws.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;"lambda:InvokeFunction"&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:lambda:us-east-2:111122223333:function:ingest"&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;"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;"111122223333"&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="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;Both keys are there for a reason. An S3 bucket ARN carries no account ID, and bucket names are global. If that bucket is ever deleted, another account can create one with the same name, and its events would still match &lt;code&gt;aws:SourceArn&lt;/code&gt;. &lt;code&gt;aws:SourceAccount&lt;/code&gt; ties the grant to the owner, not just the name. The CLI makes this easy to do properly: &lt;code&gt;aws lambda add-permission&lt;/code&gt; takes both &lt;code&gt;--source-arn&lt;/code&gt; and &lt;code&gt;--source-account&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service roles need it too
&lt;/h3&gt;

&lt;p&gt;The trust policy of a role that a service assumes is the same kind of grant. Without a condition it says only "Systems Manager may assume me." The Systems Manager documentation recommends scoping it:&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;"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;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ssm.amazonaws.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:AssumeRole"&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;"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;"111122223333"&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:ssm:us-east-1:111122223333:*"&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;When you don't know the full ARN, or several resources use the role, keep the account and Region and use a wildcard for the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforcing It Org-Wide with an RCP
&lt;/h2&gt;

&lt;p&gt;Fixing bucket policies one by one doesn't scale, and it does nothing for the bucket someone creates next month. A resource control policy applies the rule centrally. This is the example from the IAM documentation:&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;"RCPEnforceConfusedDeputyProtectionForS3"&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="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="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;"StringNotEqualsIfExists"&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:SourceOrgID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"o-exampleorgid"&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: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;"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="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;"aws:PrincipalIsAWSService"&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 three conditions are evaluated together, and each one has a job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws:PrincipalIsAWSService&lt;/code&gt; limits the deny to calls made by service principals. Your own users and roles are not affected.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Null&lt;/code&gt; check on &lt;code&gt;aws:SourceAccount&lt;/code&gt; applies the deny only when the service sends that key, so integrations that don't use it keep working.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws:SourceOrgID&lt;/code&gt; is the actual test: deny when the service is not acting for your organization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;Null&lt;/code&gt; check uses &lt;code&gt;aws:SourceAccount&lt;/code&gt; rather than &lt;code&gt;aws:SourceOrgID&lt;/code&gt; on purpose. If the request comes from an account that belongs to no organization, &lt;code&gt;aws:SourceOrgID&lt;/code&gt; is absent. Testing for the account key keeps the control in force for that case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An RCP never grants access.&lt;/strong&gt; It sets a ceiling. Each resource policy still needs its own Allow for the service principal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RCPs skip the management account.&lt;/strong&gt; Resources there still need the condition in their own policies. RCPs also don't change the permissions of service-linked roles and don't apply to AWS managed KMS keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RCPs cover supported services only.&lt;/strong&gt; The list has grown well past the original S3, STS, KMS, SQS, and Secrets Manager, but check it before you rely on one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both keys in one statement must agree.&lt;/strong&gt; If the &lt;code&gt;aws:SourceArn&lt;/code&gt; value contains an account ID, it has to be the same account as &lt;code&gt;aws:SourceAccount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every integration sends these keys.&lt;/strong&gt; Requiring a key that a service doesn't populate breaks that integration. The service's own documentation says which keys it supports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some services have their own mechanism.&lt;/strong&gt; AWS KMS, for example, uses the encryption context together with the key grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The external ID is only as good as the vendor's handling of it.&lt;/strong&gt; A vendor that lets customers type their own, or that sends a shared value, has not solved anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding What You Already Have
&lt;/h2&gt;

&lt;p&gt;A quick way to list roles that trust an AWS principal without requiring an external ID:&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="nt"&gt;--output&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Roles[]
  | select(any(.AssumeRolePolicyDocument.Statement[];
      .Effect == "Allow" and (.Principal.AWS? != null)
      and ((.Condition // {} | tostring) | contains("sts:ExternalId") | not)))
  | .Arn'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a starting point, not an audit. Roles trusted by your own accounts will show up and are usually fine. Any role that trusts a vendor's account should carry the condition.&lt;/p&gt;

&lt;p&gt;Two more places to look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IAM Access Analyzer.&lt;/strong&gt; External access findings cover role trust policies, S3 buckets, KMS keys, Lambda functions, SQS queues, and SNS topics, among others. Treat each finding as a prompt to read the policy's conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail.&lt;/strong&gt; A cross-account &lt;code&gt;AssumeRole&lt;/code&gt; is logged in both accounts, and &lt;code&gt;sharedEventID&lt;/code&gt; ties the two records together. That is how you confirm which external principal used a role, and when.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Every trust policy for a vendor account requires an &lt;code&gt;sts:ExternalId&lt;/code&gt; that the vendor issued.&lt;/li&gt;
&lt;li&gt;Every resource policy and service-role trust policy that names a service principal has an &lt;code&gt;aws:SourceArn&lt;/code&gt;, &lt;code&gt;aws:SourceAccount&lt;/code&gt;, &lt;code&gt;aws:SourceOrgID&lt;/code&gt;, or &lt;code&gt;aws:SourceOrgPaths&lt;/code&gt; condition.&lt;/li&gt;
&lt;li&gt;An RCP enforces &lt;code&gt;aws:SourceOrgID&lt;/code&gt; for the services that support it, and the management account is handled separately.&lt;/li&gt;
&lt;li&gt;For each service in use, the documentation confirms which keys it supports and whether it has its own mechanism.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The principle underneath all four is short. A policy that trusts a deputy should also say on whose behalf the deputy may act.&lt;/p&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbnvausg396hillsl568.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbnvausg396hillsl568.png" alt="Five rules for aws:Source condition keys" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html" rel="noopener noreferrer"&gt;The confused deputy problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html" rel="noopener noreferrer"&gt;Access to AWS accounts owned by third parties&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html" rel="noopener noreferrer"&gt;AWS global condition context keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_rcps.html" rel="noopener noreferrer"&gt;Resource control policies (RCPs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/create-s3-bucket-policy-for-cloudtrail.html" rel="noopener noreferrer"&gt;Amazon S3 bucket policy for CloudTrail&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/permissions-function-services.html" rel="noopener noreferrer"&gt;Granting Lambda function access to AWS services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/cross-service-confused-deputy-prevention.html" rel="noopener noreferrer"&gt;Cross-service confused deputy prevention in Systems Manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/cloudtrail-integration.html" rel="noopener noreferrer"&gt;Logging IAM and AWS STS API calls with AWS CloudTrail&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-resources.html" rel="noopener noreferrer"&gt;IAM Access Analyzer resource types for external access&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>iam</category>
      <category>devops</category>
    </item>
    <item>
      <title>AWS Organizations in Production: Structure, SCP Evaluation, and Landing Zone Gotchas</title>
      <dc:creator>Rafagross</dc:creator>
      <pubDate>Sun, 27 Sep 2026 05:20:57 +0000</pubDate>
      <link>https://dev.to/rafagross/aws-organizations-in-production-structure-scp-evaluation-and-landing-zone-gotchas-5c0j</link>
      <guid>https://dev.to/rafagross/aws-organizations-in-production-structure-scp-evaluation-and-landing-zone-gotchas-5c0j</guid>
      <description>&lt;p&gt;Most teams adopt AWS Organizations for consolidated billing and stop there. The real value is the control plane: a tree of accounts where guardrails attached at the top apply to everything below. That same tree is also where most "why is this denied?" tickets come from, because the evaluation rules are less intuitive than IAM alone.&lt;/p&gt;

&lt;p&gt;This post covers the structure, how policies are evaluated, and the gotchas worth knowing before you run it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Account Is the Boundary
&lt;/h2&gt;

&lt;p&gt;An AWS account is the hard boundary for resources, security, and billing. IAM manages identities &lt;em&gt;inside&lt;/em&gt; an account. Organizations manages the accounts themselves.&lt;/p&gt;

&lt;p&gt;That distinction drives the whole multi-account model: separate accounts give you blast-radius isolation, clean cost attribution, and a place to attach controls that no one inside the account can override.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structure
&lt;/h2&gt;

&lt;p&gt;An organization has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One management account.&lt;/strong&gt; It owns the organization and is the payer account. It should run org and billing tasks only. No workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One root.&lt;/strong&gt; The top of the tree. Policies attached here apply to every account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Organizational units (OUs).&lt;/strong&gt; Containers for accounts. OUs can nest up to five levels deep under the root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Member accounts.&lt;/strong&gt; Where workloads, logging, and security tooling live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies.&lt;/strong&gt; Attached at the root, an OU, or an individual account.&lt;/li&gt;
&lt;/ul&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywtmf5pi85idpcbwwfkq.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywtmf5pi85idpcbwwfkq.png" alt="AWS Organizations account hierarchy: management account, root, Security, Infrastructure and Workloads OUs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A common baseline layout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OU&lt;/th&gt;
&lt;th&gt;Accounts&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Log Archive, Security Tooling (Audit)&lt;/td&gt;
&lt;td&gt;Centralized logs, delegated admin for security services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Network, Shared Services&lt;/td&gt;
&lt;td&gt;Transit, DNS, shared tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workloads&lt;/td&gt;
&lt;td&gt;Prod, NonProd&lt;/td&gt;
&lt;td&gt;Application accounts, grouped by lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandbox&lt;/td&gt;
&lt;td&gt;Per-developer or per-team&lt;/td&gt;
&lt;td&gt;Experimentation with looser controls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suspended&lt;/td&gt;
&lt;td&gt;Accounts pending closure&lt;/td&gt;
&lt;td&gt;Deny-all quarantine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Design OUs around the controls and lifecycle you want to apply, not around the org chart. Teams get reorganized; "prod needs stricter guardrails than sandbox" does not change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Enable All Features
&lt;/h2&gt;

&lt;p&gt;Organizations has two feature sets. Consolidated-billing mode only merges invoices. &lt;strong&gt;All features&lt;/strong&gt; adds the authorization policies (SCPs, RCPs), management policies (tag, backup, EC2), trusted access for service integrations, and delegated administrators. If you are building a landing zone, you need all features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy Types That Matter Day to Day
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SCP&lt;/strong&gt; (service control policy)&lt;/td&gt;
&lt;td&gt;Sets the maximum permissions for IAM users and roles in member accounts. Grants nothing on its own.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;RCP&lt;/strong&gt; (resource control policy)&lt;/td&gt;
&lt;td&gt;Sets the maximum permissions on resources in member accounts (S3, KMS, Secrets Manager, SQS, and many more), regardless of who is calling. Grants nothing on its own.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tag policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Standardizes tag keys and values so cost allocation and ABAC stay consistent.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backup policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deploys AWS Backup plans centrally across accounts and Regions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;EC2 policy&lt;/strong&gt; (declarative)&lt;/td&gt;
&lt;td&gt;Enforces EC2, VPC, and EBS baselines org-wide, such as IMDS defaults, AMI and EBS snapshot block public access, serial console access, and VPC Block Public Access. The configuration is maintained even as the service adds new APIs.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9crbszm8q5gzl06lf1eg.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9crbszm8q5gzl06lf1eg.png" alt="SCP inheritance and request evaluation flow across identity policies, SCPs and RCPs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Inheritance Actually Works
&lt;/h2&gt;

&lt;p&gt;This is the part that trips people up.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Deny&lt;/strong&gt; is simple: attach it anywhere and it applies to everything below that point.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;Allow&lt;/strong&gt; is not inherited the way people expect. For an action to be permitted in an account, an SCP must allow it at &lt;strong&gt;every&lt;/strong&gt; level from the root down to the account: the root, each OU in the path, and the account itself. If any level is missing the Allow, the action is implicitly denied, even if an IAM policy in the account grants &lt;code&gt;*:*&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Root            FullAWSAccess        -&amp;gt; allows *
  OU Workloads  AllowOnlyApprovedSvc -&amp;gt; allows ec2:*, s3:*, ... (no dynamodb:*)
    Prod acct   FullAWSAccess        -&amp;gt; allows *

Result in Prod: dynamodb:* is denied. The OU level never allowed it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the default &lt;code&gt;FullAWSAccess&lt;/code&gt; SCP exists at every level. If you replace it with an allow-list at the OU, that allow-list becomes the ceiling for every account underneath.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Most common SCP incident:&lt;/strong&gt; Someone detaches &lt;code&gt;FullAWSAccess&lt;/code&gt; from an OU while testing an allow-list SCP, and every account under that OU loses access to services that weren't on the list. Prefer deny-list SCPs (explicit &lt;code&gt;Deny&lt;/code&gt; statements on top of &lt;code&gt;FullAWSAccess&lt;/code&gt;) unless you have a specific reason to allow-list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How a Request Is Evaluated
&lt;/h2&gt;

&lt;p&gt;For a principal in a member account, access requires an Allow from every applicable layer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity-based policy&lt;/strong&gt; on the user or role must allow the action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCPs&lt;/strong&gt; in the account's path must allow it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RCPs&lt;/strong&gt; in the path must allow it for the target resource (if the service supports RCPs).&lt;/li&gt;
&lt;li&gt;Permission boundaries, session policies, and resource-based policies still apply as usual.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An &lt;strong&gt;explicit Deny at any layer wins.&lt;/strong&gt; SCPs and RCPs only ever reduce what is possible; they never grant access.&lt;/p&gt;

&lt;p&gt;A practical consequence: when you debug an &lt;code&gt;AccessDenied&lt;/code&gt; in a member account and the IAM policy looks correct, check the SCPs on every node in the account's path, not just the ones attached directly to the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landing Zone Pattern
&lt;/h2&gt;

&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fulbbzeflorebqrt6k01p.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fulbbzeflorebqrt6k01p.png" alt="Multi-account landing zone pattern and operating lifecycle" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal is to separate the governance plane from workloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Management account:&lt;/strong&gt; org and billing only. Tightly restricted human access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security OU:&lt;/strong&gt; Log Archive receives org-wide CloudTrail and Config data. Security Tooling (Control Tower calls it Audit) is the delegated administrator for GuardDuty, Security Hub, and similar services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure OU:&lt;/strong&gt; networking and shared services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workloads OU:&lt;/strong&gt; prod and non-prod split so guardrails can differ.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AWS Control Tower&lt;/strong&gt; orchestrates this on top of Organizations: Account Factory for provisioning, managed controls (preventive via SCPs/RCPs, detective via AWS Config, proactive via CloudFormation hooks), and drift detection. If you manage infrastructure as code, Account Factory for Terraform (AFT) provisions accounts through a Terraform pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operating Lifecycle
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity:&lt;/strong&gt; IAM Identity Center for human access, cross-account roles for automation, least privilege throughout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provisioning:&lt;/strong&gt; Account Factory, AFT, or the Organizations API. Never click-ops accounts in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baseline:&lt;/strong&gt; logging, Config, security services, and networking applied from day one, typically via StackSets or Control Tower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails:&lt;/strong&gt; preventive, detective, and proactive controls, plus drift detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle:&lt;/strong&gt; move accounts between OUs as their purpose changes. To retire one, move it to a Suspended OU with a deny-all SCP, then close it. A closed account can be reopened during the post-closure period (90 days).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Production Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SCPs and RCPs never restrict the management account.&lt;/strong&gt; Anything you run there is outside your guardrails. That is the main reason to keep workloads out of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCPs don't affect service-linked roles.&lt;/strong&gt; AWS services acting through service-linked roles are not limited by your SCPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCPs do apply to delegated administrator accounts.&lt;/strong&gt; They are member accounts. Make sure your guardrails don't block the security services you delegated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OUs nest at most five levels deep.&lt;/strong&gt; Deep hierarchies are also harder to reason about during an incident. Keep it shallow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegate security services to the Audit/Security Tooling account&lt;/strong&gt; instead of administering them from the management account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits:&lt;/strong&gt; SCP documents max out at 10,240 characters, and each root, OU, or account can have up to 10 SCPs attached. Plan statement consolidation early.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick Inventory Commands
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Root ID and enabled policy types&lt;/span&gt;
aws organizations list-roots

&lt;span class="c"&gt;# All accounts in the org&lt;/span&gt;
aws organizations list-accounts

&lt;span class="c"&gt;# OUs under a parent (root or OU)&lt;/span&gt;
aws organizations list-organizational-units-for-parent &lt;span class="nt"&gt;--parent-id&lt;/span&gt; r-examplerootid

&lt;span class="c"&gt;# SCPs attached to a specific target&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; 123456789012 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter&lt;/span&gt; SERVICE_CONTROL_POLICY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run these from the management account or a delegated administrator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html" rel="noopener noreferrer"&gt;What is AWS Organizations?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_getting-started_concepts.html" rel="noopener noreferrer"&gt;AWS Organizations terminology and concepts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html" rel="noopener noreferrer"&gt;Service control policies (SCPs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_rcps.html" rel="noopener noreferrer"&gt;Resource control policies (RCPs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_ec2.html" rel="noopener noreferrer"&gt;EC2 policies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_best-practices_mgmt-acct.html" rel="noopener noreferrer"&gt;Best practices for the management account&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_ous_best_practices.html" rel="noopener noreferrer"&gt;Best practices for organizational units&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_reference_limits.html" rel="noopener noreferrer"&gt;Quotas for AWS Organizations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/controltower/latest/userguide/what-is-control-tower.html" rel="noopener noreferrer"&gt;What is AWS Control Tower?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/controltower/latest/userguide/aws-multi-account-landing-zone.html" rel="noopener noreferrer"&gt;AWS multi-account landing zone&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>EC2 Instance Unreachable via SSM Session Manager</title>
      <dc:creator>Rafagross</dc:creator>
      <pubDate>Sun, 13 Sep 2026 03:12:04 +0000</pubDate>
      <link>https://dev.to/rafagross/ec2-instance-unreachable-via-ssm-session-manager-1fe9</link>
      <guid>https://dev.to/rafagross/ec2-instance-unreachable-via-ssm-session-manager-1fe9</guid>
      <description>&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This runbook resolves the situation where an EC2 instance is running and visible in the console but either does not appear in the Systems Manager Fleet Manager inventory, or returns a connection error when you attempt to start a Session Manager session.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Use This Runbook
&lt;/h2&gt;

&lt;p&gt;Use this runbook when you observe any of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session Manager shows "Instance not connected" or "Unable to start session"&lt;/li&gt;
&lt;li&gt;The instance does not appear in &lt;strong&gt;Systems Manager → Fleet Manager → Managed Nodes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssm:StartSession&lt;/code&gt; returns &lt;code&gt;TargetNotConnected&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A previously working instance stopped responding to SSM after a restart, IAM change, or network modification&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Verify Instance State
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; EC2 → Instances → [Instance ID]&lt;/p&gt;

&lt;p&gt;Confirm the instance is in &lt;code&gt;running&lt;/code&gt; state and that both &lt;strong&gt;Status checks&lt;/strong&gt; show 2/2 passed.&lt;/p&gt;

&lt;p&gt;If status checks are failing, stop here and use the EC2 Status Check runbook. SSM is irrelevant if the instance itself is impaired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; Instance state = &lt;code&gt;running&lt;/code&gt;, 2/2 status checks passed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Check SSM Managed Node Status
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; Systems Manager → Fleet Manager → Managed nodes&lt;/p&gt;

&lt;p&gt;Search for the instance ID. If it does not appear, or shows &lt;code&gt;Connection Lost&lt;/code&gt;, the issue is one of: missing IAM role, stopped SSM agent, or blocked network path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; Instance appears with status &lt;code&gt;Online&lt;/code&gt;. If missing or &lt;code&gt;Connection Lost&lt;/code&gt;, continue to Step 3.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Verify IAM Instance Profile
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; EC2 → Instances → [Instance ID] → Security tab → IAM Role&lt;/p&gt;

&lt;p&gt;The instance must have an IAM role attached with the &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; managed policy (or equivalent custom policy granting the minimum SSM actions).&lt;/p&gt;

&lt;p&gt;Minimum required IAM actions if using a custom policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssm:UpdateInstanceInformation
ssm:ListInstanceAssociations
ssm:DescribeInstanceProperties
ssm:DescribeDocumentParameters
ssmmessages:CreateControlChannel
ssmmessages:CreateDataChannel
ssmmessages:OpenControlChannel
ssmmessages:OpenDataChannel
ec2messages:AcknowledgeMessage
ec2messages:DeleteMessage
ec2messages:FailMessage
ec2messages:GetEndpoint
ec2messages:GetMessages
ec2messages:SendReply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; IAM propagation delay:&lt;br&gt;
IAM role changes applied to a running instance take effect on next SSM agent heartbeat, typically within 2-3 minutes. If you just updated the role, wait 5 minutes and re-check Fleet Manager before continuing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; IAM role attached with &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; or equivalent. If missing, attach the role and wait 5 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Check SSM Agent on the Instance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; EC2 → Instances → [Instance ID] → Actions → Monitor and troubleshoot → Get system log&lt;/p&gt;

&lt;p&gt;Look for lines referencing &lt;code&gt;amazon-ssm-agent&lt;/code&gt;. A healthy agent shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="err"&gt;amazon-ssm-agent.service:&lt;/span&gt; &lt;span class="err"&gt;active&lt;/span&gt; &lt;span class="err"&gt;(running)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors to look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Failed to start Amazon SSM Agent&lt;/code&gt; — agent start failure, likely OS-level issue&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Error connecting to endpoint&lt;/code&gt; — network path problem (proceed to Step 5)&lt;/li&gt;
&lt;li&gt;No SSM lines at all — agent not installed or not running&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the agent is not running, you can use EC2 Run Command with &lt;code&gt;AWS-RunShellScript&lt;/code&gt; to restart it — but only if the instance is already registered (partial connectivity). If it's fully unreachable, use EC2 Instance Connect or serial console (if available) to restart manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; SSM agent running. No connection errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Diagnose the Network Path
&lt;/h2&gt;

&lt;p&gt;SSM Session Manager requires outbound HTTPS (port 443) from the EC2 instance to three regional endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssm.{region}.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Agent registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssmmessages.{region}.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session data channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ec2messages.{region}.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;EC2 message delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For private instances (no NAT Gateway, VPC endpoint required):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; VPC → Endpoints&lt;/p&gt;

&lt;p&gt;Verify that all three interface endpoints exist, are associated with the correct VPC, and have status &lt;code&gt;available&lt;/code&gt;. Check that the endpoint security group allows inbound HTTPS from the instance's security group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; VPC → Security Groups → [Endpoint SG]&lt;/p&gt;

&lt;p&gt;Inbound rule required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS (443)&lt;/span&gt;
&lt;span class="py"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;[Instance security group ID]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For instances with NAT Gateway:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; VPC → Route Tables → [Instance subnet's route table]&lt;/p&gt;

&lt;p&gt;Confirm a &lt;code&gt;0.0.0.0/0&lt;/code&gt; route pointing to the NAT Gateway exists and the NAT Gateway is in &lt;code&gt;available&lt;/code&gt; state.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
If the instance has a public IP and is in a public subnet with an IGW route, outbound port 443 directly to AWS endpoints is sufficient. No VPC endpoints needed in that case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; Either VPC endpoints present and &lt;code&gt;available&lt;/code&gt;, or valid NAT/IGW route exists. Instance SG allows outbound 443.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Check Instance Security Group Outbound Rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; EC2 → Instances → [Instance ID] → Security tab → Security groups → [SG ID] → Outbound rules&lt;/p&gt;

&lt;p&gt;Confirm outbound HTTPS is allowed. The default AWS SG allows all outbound traffic. If rules have been tightened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS (443)&lt;/span&gt;
&lt;span class="py"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OR (preferred, more restrictive):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS (443)&lt;/span&gt;
&lt;span class="py"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;[VPC endpoint prefix list or specific endpoint IPs]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected result:&lt;/strong&gt; Outbound 443 allowed from instance security group.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Force SSM Agent Re-Registration (Last Resort)
&lt;/h2&gt;

&lt;p&gt;If all above checks pass but the instance still doesn't appear in Fleet Manager, the agent's registration may be stale (common after AMI snapshots or instance cloning).&lt;/p&gt;

&lt;p&gt;Use EC2 Run Command with &lt;code&gt;AWS-RunShellScript&lt;/code&gt;:&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;# Amazon Linux 2 / AL2023&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop amazon-ssm-agent
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/amazon/ssm/registration
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start amazon-ssm-agent
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status amazon-ssm-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait 2 minutes, then re-check Fleet Manager.&lt;/p&gt;




&lt;h2&gt;
  
  
  Validation Checks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;How to Verify&lt;/th&gt;
&lt;th&gt;Expected Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instance in Fleet Manager&lt;/td&gt;
&lt;td&gt;SSM → Fleet Manager → search instance ID&lt;/td&gt;
&lt;td&gt;Status: Online&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session starts&lt;/td&gt;
&lt;td&gt;SSM → Session Manager → Start session → select instance&lt;/td&gt;
&lt;td&gt;Terminal opens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent heartbeat&lt;/td&gt;
&lt;td&gt;CloudWatch Logs → /aws/ssm/amazon-ssm-agent&lt;/td&gt;
&lt;td&gt;Recent heartbeat log entries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;

&lt;p&gt;No destructive changes are made by this runbook. If you attached a new IAM role that you want to remove:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Console path:&lt;/strong&gt; EC2 → Instances → [Instance ID] → Actions → Security → Modify IAM role → No role&lt;/p&gt;




&lt;h2&gt;
  
  
  Escalation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Next Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All steps pass but instance still unreachable&lt;/td&gt;
&lt;td&gt;Open AWS Support case — provide instance ID, region, VPC endpoint IDs, and CloudWatch agent logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent crashes immediately on start&lt;/td&gt;
&lt;td&gt;Check OS disk space (&lt;code&gt;df -h&lt;/code&gt;), memory, and &lt;code&gt;/var/log/amazon/ssm/amazon-ssm-agent.log&lt;/code&gt; for errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance in private subnet, no VPC endpoints, no NAT&lt;/td&gt;
&lt;td&gt;Network path is broken by design — work with network team to add VPC endpoints or NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




</description>
      <category>aws</category>
      <category>devops</category>
      <category>linux</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
