{"id":546,"date":"2026-08-27T12:00:00","date_gmt":"2026-08-27T09:00:00","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=546"},"modified":"2026-09-14T16:13:07","modified_gmt":"2026-09-14T13:13:07","slug":"client-aws-account-access","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/","title":{"rendered":"Twenty Client AWS Accounts and Not One Shared Admin Credential"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A client&#8217;s auditor asks one question: list every person at your company who can sign in to our AWS account, and show us how you know. If the honest answer starts with &#8220;whoever has the shared vault,&#8221; that isn&#8217;t an answer. It&#8217;s a hope.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That question is what actually bites when you run infrastructure for twenty clients. Not a breach. An audit, or a resignation. Someone gives notice on a Friday and you spend Monday working out which accounts they still hold keys to, because the credential everybody shared was never attached to a person. You can&#8217;t revoke a person from a password. You can only rotate it and hope you got all twenty.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post covers how to design client AWS account access so that humans authenticate once against something you control, every client account holds a role that trusts exactly one thing, and every API call in a client&#8217;s CloudTrail can be traced back to a named engineer. It&#8217;s organised by failure family, because the mechanics are well documented and the failure modes are not.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The shape: one identity plane, twenty separate trust decisions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The instinct that causes all the trouble is putting credentials in the middle. Client gives you a login, you store it somewhere shared, engineers take it out when they need it. Everything downstream of that decision is damage control.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The shape that works is boring. One AWS account whose only job is identity. Your engineers exist there, once, in IAM Identity Center or federated from whatever directory you already pay for. Each client account holds an IAM role whose trust policy names one principal in that account and requires an external ID. Nobody stores a client credential, because there is no client credential. There&#8217;s a role your engineers can ask STS for a short-lived session against, and a client who can delete that role whenever they like.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second design worth knowing about: inviting client accounts into your own AWS Organization. It&#8217;s genuinely better in some ways. You get service control policies, resource control policies, and centralised root access management across the estate, all of which are organisation-scoped features you simply cannot use on an account you don&#8217;t own. It&#8217;s also a much bigger commitment. You take on consolidated billing, the client&#8217;s spend flows through you, and pulling an account back out of an organisation at the end of a contract is a real project rather than a policy change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For most consultancies and managed service providers, the client keeps their own organisation and you get a role. That&#8217;s the model the rest of this assumes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 1: credentials you can&#8217;t revoke<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The shared IAM user with an access key is the default because it works immediately. It keeps working right up until one of three moments: an engineer leaves, a client asks who did something, or a laptop gets stolen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Access keys have no expiry. They&#8217;re bearer tokens with an indefinite lifetime, and the only way to invalidate one is to go into each account and delete it. At twenty accounts that&#8217;s twenty deliberate actions performed under time pressure, and the one you forget is the one that matters. A role session, by contrast, expires on its own. That&#8217;s the entire argument.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A shared password manager still earns its place, just not for admin keys. Bitwarden or 1Password is the right home for per-client external IDs, account numbers, role ARNs and the client-side contacts you need when something breaks at two in the morning. Metadata, not credentials.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 2: a trust policy that trusts more than you meant<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the invisible one. The role gets created, the access works, everyone moves on, and the trust policy quietly grants far more than anyone intended.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two mistakes travel together. The first is naming your account root in the principal. Writing <code>arn:aws:iam::111122223333:root<\/code> does not mean &#8220;the root user of my account.&#8221; It means any principal in that account that has permission to call AssumeRole. Every engineer, every Lambda function, every CI runner, every future role you haven&#8217;t created yet. It delegates the decision to your own account&#8217;s IAM, which is fine when your IAM is tidy and quietly catastrophic when it isn&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is skipping the external ID. AWS calls the problem it solves the confused deputy: you are a third party that holds the ability to assume roles in many customer accounts, and without a shared secret in the condition, one customer who learns another customer&#8217;s role ARN could persuade your systems to act against it. The external ID is the value that makes each trust relationship specific rather than generic.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generate it yourself, one per client, as a UUID or similar unguessable value. The client must not be the one choosing it, because if they can choose it they can choose someone else&#8217;s, and the control does nothing.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"AWS\": \"arn:aws:iam::111122223333:role\/MspOperationsAccess\"\n      },\n      \"Action\": \"sts:AssumeRole\",\n      \"Condition\": {\n        \"StringEquals\": {\n          \"sts:ExternalId\": \"6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02\"\n        }\n      }\n    }\n  ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The principal names one role, not the account. If that role is compromised you have one thing to fix. If the account is named, you have an inventory problem you didn&#8217;t know you had.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 3: CloudTrail says the role, not the human<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;ve done everything above and a client emails asking who deleted the staging load balancer. You open their CloudTrail and every event attributes to the same assumed role. The role is shared by your whole team by design, so the log tells you your company did it. That&#8217;s not what they asked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are two knobs and they are not equivalent. The role session name is chosen freely by whoever calls AssumeRole, appears in the assumed-role ARN, and is trivially set to anything. It&#8217;s useful, but it&#8217;s a label, not evidence. Source identity is the one that carries weight: once set on a session it cannot be changed, it persists across chained role sessions, and it lands in CloudTrail in the session context of every subsequent call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The catch is that the role&#8217;s trust policy has to permit <code>sts:SetSourceIdentity<\/code> alongside <code>sts:AssumeRole<\/code>, and you can require it with a condition so a session without one is refused outright.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"Action\": [\n  \"sts:AssumeRole\",\n  \"sts:SetSourceIdentity\"\n],\n\"Condition\": {\n  \"StringEquals\": {\n    \"sts:ExternalId\": \"6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02\"\n  },\n  \"StringLike\": {\n    \"sts:SourceIdentity\": \"*@yourcompany.com\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If your engineers arrive through SAML federation, the identity provider can populate source identity from a directory attribute such as email, which means nobody has to remember to pass it. For direct CLI use it&#8217;s an explicit flag:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>aws sts assume-role \n  --role-arn arn:aws:iam::444455556666:role\/MspEngineerAccess \n  --role-session-name acme-investigation \n  --external-id 6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02 \n  --source-identity jane.doe@yourcompany.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Getting this right is what turns &#8220;we take security seriously&#8221; into a sentence you can back with a log query. It is also, in my experience of what clients actually ask for, the single most persuasive thing on the list.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 4: one identity, twenty blast radii<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A hub account is a concentration of risk. That&#8217;s the trade you&#8217;re making, and it&#8217;s worth making, but only if the hub is held to a higher standard than the accounts it reaches into.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Practically: not every engineer gets permission to assume every client&#8217;s role. Group permissions in the hub so the people on the Acme engagement reach Acme and nothing else. Use per-client role names rather than one shared name across all twenty, so a policy in the hub can&#8217;t wildcard its way into a client you stopped working with a year ago.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tier the roles by what they&#8217;re for, not by who&#8217;s asking. A read-only role for investigation covers most of the work most days. A change role carries the write permissions the engagement actually needs. Break-glass sits separate, with a short maximum session duration and an alarm on every use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two extras are worth knowing about even if you don&#8217;t reach for them immediately. Session policies let the caller narrow a session further at assume time, which is useful for automation that only needs one bucket. Permissions boundaries cap what any role or user created inside the client account can do, which matters when part of your job is creating IAM there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your team already routes through a business VPN or zero trust gateway with stable egress addresses, a source IP condition on the trust policy is a cheap extra layer. NordLayer, Tailscale and Cloudflare&#8217;s zero trust products all give you predictable egress. Be honest about the cost, though: the moment something runs from CI, a client&#8217;s network or a laptop on hotel wifi, an IP condition becomes a support ticket. I&#8217;d add it once everything else works, not before.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 5: standing administrator access nobody remembers granting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Somewhere in the twenty accounts there is a role with AdministratorAccess attached, created during a migration, described in the ticket as temporary. It&#8217;s been eighteen months.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the honest part: AWS does not give you a native just-in-time approval workflow for cross-account role assumption. There&#8217;s no built-in &#8220;request four hours of write access, get approved in Slack, access expires automatically&#8221; for this pattern. You either build that on top of STS and your ticketing system, or you accept standing access and compensate with tight session durations, aggressive monitoring and a genuine periodic review. Both are defensible. Pretending you have JIT when you have a wiki page is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The cheap version that actually helps: set the maximum session duration on client roles to something short, alert on assumption of the break-glass role, and run a quarterly pass over service last accessed data to strip permissions nobody has used. IAM can generate that report per role.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Kick off the report, then fetch it with the returned job ID\naws iam generate-service-last-accessed-details \n  --arn arn:aws:iam::444455556666:role\/MspEngineerAccess\n\naws iam get-service-last-accessed-details --job-id JOB_ID_FROM_ABOVE<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run that in each client account and the conversation about narrowing permissions stops being theoretical. You&#8217;re not guessing what the engagement needs; you&#8217;re reading it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure family 6: offboarding that only half happens<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The whole point of the hub is that offboarding is one action. Disable the person in your directory and every path into every client account closes at once, because there was never a credential in a client account with their name on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s true only if nothing crept in sideways. Things that creep in sideways:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>IAM users created directly in a client account during an incident, because it was faster<\/li>\n\n<li>Access keys minted under a role by automation and never inventoried<\/li>\n\n<li>A client&#8217;s own console user shared with your team &#8220;just for the migration&#8221;<\/li>\n\n<li>Third party tools connected with their own cross-account roles that outlived the trial<\/li>\n\n<li>A departing engineer&#8217;s laptop with cached SSO tokens and stale profiles still on disk<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">IAM Access Analyzer is the tool that finds most of the first four. Its external access findings show every principal outside the account that can reach into it, which is exactly the list a client wants to see, and it&#8217;s also the list that reveals the monitoring vendor nobody remembers signing up for. For the laptop, whatever your hardware disposal process is, make sure it involves an actual wipe rather than a factory reset; tools like O&amp;O SafeErase exist for that reason and are cheap next to the alternative.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Rolling client AWS account access out across twenty accounts<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Doing this by hand twenty times guarantees twenty subtly different configurations. Make it a template and a checklist.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Write one CloudFormation template that creates the role, its trust policy and its permissions, parameterised by external ID and your operations account ID.<\/li>\n\n<li>Generate a unique external ID per client and store it with the client&#8217;s account metadata, not in the template.<\/li>\n\n<li>Send the client a launch stack link rather than asking for credentials. They deploy it in their own account, in their own console, and can read exactly what they&#8217;re granting.<\/li>\n\n<li>Record the account ID, role ARN, external ID location, engagement scope and review date in a single inventory file in version control.<\/li>\n\n<li>Generate everyone&#8217;s local AWS profiles from that inventory so nobody hand-edits a config file.<\/li>\n\n<li>Verify each one with a call that proves identity and nothing else before you touch anything real.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The generated profile chains from your federated session into the client role. The AWS CLI reads these from the shared config file and handles the assume-role call for you:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># ~\/.aws\/config\n[profile msp-hub]\nsso_session = company\nsso_account_id = 111122223333\nsso_role_name = MspOperationsAccess\nregion = eu-west-1\n\n[profile acme-readonly]\nrole_arn = arn:aws:iam::444455556666:role\/MspEngineerReadOnly\nsource_profile = msp-hub\nexternal_id = 6f1c2e94-3a77-4c2b-9f0d-8e5a1b7c4d02\nrole_session_name = jane-doe\nduration_seconds = 3600\nregion = eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then the two-second sanity check that has saved me from more mistakes than any other command:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>aws sts get-caller-identity --profile acme-readonly<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It returns the account ID and the assumed-role ARN. Read the account number before you run anything destructive. Twenty profiles in one config file is exactly the situation where muscle memory picks the wrong one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For scheduled work that runs without a human, the same trust relationship serves a Lambda function or an EC2 instance role in your hub. A low-cost VPS from Contabo or InterServer will also do the job, but note the trade: an instance profile inside AWS gets rotated credentials automatically, while a box outside AWS needs a long-lived credential to bootstrap from, which reintroduces exactly what this design removes. Inside AWS is the cleaner answer.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting the failures you will actually hit<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">AccessDenied on AssumeRole<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Three separate causes produce nearly the same message, and they need different fixes. Either the client&#8217;s trust policy doesn&#8217;t name your principal, or the external ID you sent doesn&#8217;t match what the condition expects, or your own identity in the hub lacks permission to call AssumeRole on that role ARN. Check them in that order, because the first two require the client and the third doesn&#8217;t.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Switch Role in the console silently fails<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This one wastes an afternoon if you don&#8217;t know it. The console&#8217;s switch role feature does not support roles that require an external ID. It&#8217;s a documented limitation, not a bug in your policy. Engineers who need console access to those accounts should reach them through federation rather than the switch role box.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Sessions expiring after an hour when you asked for longer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When one role session assumes another role, that&#8217;s role chaining, and a chained session is capped at one hour regardless of the role&#8217;s maximum session duration setting. Since your engineers assume the client role from an already-assumed session in the hub, you&#8217;re chaining by definition. Plan around the hour rather than fighting it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">An authorization message you can&#8217;t read<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Some denials come back with a long encoded blob attached. STS will decode it if your identity has permission to, and the decoded version usually names the exact statement that denied you.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>aws sts decode-authorization-message --encoded-message &lt;encoded-blob&gt;<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Naming the account root as the trust principal instead of the specific operations role<\/li>\n\n<li>Letting the client pick the external ID, or reusing one value across several clients<\/li>\n\n<li>Treating the role session name as an audit trail when it&#8217;s a caller-supplied string<\/li>\n\n<li>Asking a client for console credentials because a template felt like too much effort<\/li>\n\n<li>Granting AdministratorAccess for a migration and never revisiting it<\/li>\n\n<li>Building the hub properly and then leaving one legacy IAM user in one account as a fallback<\/li>\n\n<li>Assuming that removing someone from your directory also removed the keys they created by hand<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Best practices worth the effort<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>One identity plane, federated, with MFA enforced at the point the human authenticates<\/li>\n\n<li>A unique unguessable external ID per client, generated by you and stored with client metadata<\/li>\n\n<li>Source identity required in the trust policy so CloudTrail names a person, not a role<\/li>\n\n<li>Roles tiered by purpose: read-only for investigation, change for the engagement, break-glass alarmed<\/li>\n\n<li>Client-owned policies, deployed by the client from your template, revocable by the client in one action<\/li>\n\n<li>A version-controlled inventory of every account, role, scope and review date<\/li>\n\n<li>Access Analyzer external access findings reviewed on a schedule, in every client account<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can&#8217;t I just ask each client to create an IAM user for me?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You can, and it&#8217;ll work on day one. The problem is day four hundred. That user&#8217;s access key never expires, it isn&#8217;t attached to any individual on your team, and revoking it means someone in the client&#8217;s organisation remembering to act. A role costs the client the same five minutes to create and hands them a control they can exercise unilaterally.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a different external ID for every client?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. A shared external ID across your client base defeats the purpose, because the value&#8217;s entire job is to be specific to one trust relationship. Generate a UUID per client, treat it as a secret, and never let the client choose it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I bring client accounts into my own AWS Organization?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Only if you&#8217;re also taking on their billing and the commercial relationship supports it. Inside your organisation you get service control policies, resource control policies and centralised root access management, which are real advantages. Outside it, those stay the client&#8217;s tools and your job is to recommend them well. Most consulting engagements sit outside.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I prove a departed engineer no longer has access?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Show the directory record disabling them, then show that no IAM user or access key in the client account carries their name, then show Access Analyzer external access findings listing only your operations role as an external principal. Three artefacts, all exportable. That&#8217;s the evidence pack.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does MFA carry across the cross-account role assumption?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t assume it does. The reliable place to enforce MFA is where the human authenticates in your identity plane, and the reliable control at the client boundary is the combination of a narrowly scoped principal and the external ID condition. If you want an MFA condition in the client&#8217;s trust policy as well, test it end to end against your actual federation path before you rely on it, because the request context differs between direct assumption and chained sessions.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s the minimum viable version if I only have three clients?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A hub account with federated engineers, one role per client scoped to a named principal with a unique external ID, and source identity required. Skip the tiering, skip the IP conditions, skip the JIT ambitions. Those three things give you revocation, attribution and a clean answer for the auditor, which is most of the value.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing to take away<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Client AWS account access is not really a permissions problem. It&#8217;s a revocation and attribution problem wearing a permissions costume. Any design where a credential exists that isn&#8217;t tied to a specific human will fail you at exactly the wrong moment, and no amount of policy tuning fixes that, because the gap isn&#8217;t in the policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put the humans in one place you control. Put a narrowly scoped, externally identified role in each client account. Require source identity so the log names a person. Everything else on this page is refinement; those three are the structure. And the pleasant side effect is that &#8220;here&#8217;s how you revoke us, unilaterally, in one action&#8221; is a genuinely good thing to be able to say in a sales conversation.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Need a second pair of eyes on your client access model?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the kind of work I do for consultancies and managed service providers who&#8217;ve grown past the point where a shared vault is survivable. Specifically:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Reviewing existing cross-account trust policies for over-broad principals and missing external IDs<\/li>\n\n<li>Designing a hub account and federation model that scales past a handful of clients without per-client special cases<\/li>\n\n<li>Building the CloudFormation or Terraform template clients deploy themselves, so onboarding stops involving credentials<\/li>\n\n<li>Turning on source identity end to end, including the identity provider side, so CloudTrail attributes to named engineers<\/li>\n\n<li>Producing the offboarding evidence pack clients and auditors ask for, and automating it so it isn&#8217;t a scramble<\/li>\n\n<li>Narrowing long-standing administrator roles down using service last accessed data, without breaking the engagement<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If you want a concrete starting point, send me one trust policy and the AccessDenied message it produces. That&#8217;s usually enough to tell where the model is leaking.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Shared admin credentials work right up until someone resigns or an auditor asks who did what. Here&#8217;s how to design client AWS account access around cross-account roles, unique external IDs and per-engineer attribution, so twenty accounts need zero shared keys and revocation is one action.<\/p>\n","protected":false},"author":1,"featured_media":547,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[25,612,24],"tags":[287,693,297,480,818,477,615,99,627,475,613,817,473,478,694,614,820,690,819],"class_list":["post-546","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-cloud-computing","category-cloud-security","category-devops","tag-aws-organizations","tag-aws-sts","tag-client-onboarding","tag-cloudtrail","tag-confused-deputy","tag-cross-account-access","tag-external-id","tag-iam","tag-iam-access-analyzer","tag-iam-identity-center","tag-least-privilege","tag-managed-service-providers","tag-mfa","tag-multi-account-strategy","tag-offboarding","tag-permissions-boundaries","tag-role-chaining","tag-session-policies","tag-source-identity","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.4 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Client AWS Account Access Without Shared Admin Keys<\/title>\n<meta name=\"description\" content=\"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Client AWS Account Access Without Shared Admin Keys\" \/>\n<meta property=\"og:description\" content=\"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-27T09:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-09-14T13:13:07+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"627\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"15 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"Twenty Client AWS Accounts and Not One Shared Admin Credential\",\"datePublished\":\"2026-08-27T09:00:00+00:00\",\"dateModified\":\"2026-09-14T13:13:07+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/\"},\"wordCount\":3314,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/client-aws-account-access-revocation.png\",\"keywords\":[\"AWS Organizations\",\"AWS STS\",\"Client Onboarding\",\"CloudTrail\",\"Confused Deputy\",\"Cross-Account Access\",\"External ID\",\"IAM\",\"IAM Access Analyzer\",\"IAM Identity Center\",\"Least Privilege\",\"Managed Service Providers\",\"MFA\",\"Multi-Account Strategy\",\"Offboarding\",\"Permissions Boundaries\",\"Role Chaining\",\"Session Policies\",\"Source Identity\"],\"articleSection\":[\"Cloud Computing\",\"Cloud Security\",\"DevOps\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/\",\"name\":\"Client AWS Account Access Without Shared Admin Keys\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/client-aws-account-access-revocation.png\",\"datePublished\":\"2026-08-27T09:00:00+00:00\",\"dateModified\":\"2026-09-14T13:13:07+00:00\",\"description\":\"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/client-aws-account-access-revocation.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/client-aws-account-access-revocation.png\",\"width\":1200,\"height\":627,\"caption\":\"Diagram comparing what happens to twenty client AWS accounts when an engineer leaves: a shared admin key leaves every account still reachable, while a federated role closes all of them from one directory record.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/client-aws-account-access\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Twenty Client AWS Accounts and Not One Shared Admin Credential\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Client AWS Account Access Without Shared Admin Keys","description":"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/","og_locale":"en_US","og_type":"article","og_title":"Client AWS Account Access Without Shared Admin Keys","og_description":"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/","og_site_name":"John Nessime","article_published_time":"2026-08-27T09:00:00+00:00","article_modified_time":"2026-09-14T13:13:07+00:00","og_image":[{"width":1200,"height":627,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"15 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"Twenty Client AWS Accounts and Not One Shared Admin Credential","datePublished":"2026-08-27T09:00:00+00:00","dateModified":"2026-09-14T13:13:07+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/"},"wordCount":3314,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png","keywords":["AWS Organizations","AWS STS","Client Onboarding","CloudTrail","Confused Deputy","Cross-Account Access","External ID","IAM","IAM Access Analyzer","IAM Identity Center","Least Privilege","Managed Service Providers","MFA","Multi-Account Strategy","Offboarding","Permissions Boundaries","Role Chaining","Session Policies","Source Identity"],"articleSection":["Cloud Computing","Cloud Security","DevOps"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/","url":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/","name":"Client AWS Account Access Without Shared Admin Keys","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png","datePublished":"2026-08-27T09:00:00+00:00","dateModified":"2026-09-14T13:13:07+00:00","description":"How MSPs and consultancies design client AWS account access with cross-account roles, external IDs and per-engineer attribution instead of shared admin keys.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/client-aws-account-access-revocation.png","width":1200,"height":627,"caption":"Diagram comparing what happens to twenty client AWS accounts when an engineer leaves: a shared admin key leaves every account still reachable, while a federated role closes all of them from one directory record."},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/client-aws-account-access\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Twenty Client AWS Accounts and Not One Shared Admin Credential"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/546","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=546"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/546\/revisions"}],"predecessor-version":[{"id":561,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/546\/revisions\/561"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/547"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=546"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=546"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=546"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}