It’s a title that either makes you nervous-laugh or want to slam your laptop shut. Most organizations never get around to mapping out exactly who has access to the good bits of their AWS production environment. Then, the day someone finally does, jaws drop faster than the compliance team’s eyebrows when they see the permission sprawl.
Here’s a fun story: at one of my previous companies, we dug into the trust relationships in our AWS accounts, naively expecting a handful of folks to have admin or root-level access. After building out the entire spiderweb of roles, permissions, and trust lines, we realized that basically everyone—including the new hires, the interns, and yes, even random folks who were just there for an interview—had enough access to nuke production from orbit.
This isn’t just about hygiene or best practices. It’s about understanding who could end your business with a misclick or a compromised token. You can’t fix what you don’t understand—and in AWS, access is often granted invisibly, transitively, and by accident.
In this post, I’m going to walk you through how to piece together your own analysis—from identifying your crown jewel accounts to building a graph that shows who can do the unthinkable. I’ll share some code you can adapt or expand upon. It won’t be perfect, but it’ll get you started, and you’ll be able to extend it later by broadening your definition of “root” or incorporating privilege escalation and lateral movement paths. By the end, you’ll have a sense of who really holds the keys to your kingdom (whether you wanted them to or not). Because, trust me, there’s nothing quite like visualizing your entire organization’s AWS privileges—and then realizing half the staff is basically root in prod. Let’s fix that, shall we?
Identify your AWS accounts
You can’t secure what you don’t even know exists. If you’re using AWS Organizations, you likely have one management account plus a bunch of child accounts—some of which might be obviously named “Production” or “Dev,” while others are suspiciously cryptic (“Sandpit-Edge-42”?). Let’s get them all in one place.
Below is a quick snippet that uses boto3 to list every account in your Organization.
def list_aws_accounts():
org_client = boto3.client('organizations')
paginator = org_client.get_paginator('list_accounts')
accounts = []
for page in paginator.paginate():
for acct in page['Accounts']:
accounts.append(acct)
print(f"{acct['Id']}: {acct['Name']}")
Run this snippet, and you’ll get a straightforward list of all your AWS accounts. You’ll see IDs, names, and hopefully realize that “Marketing-Promotion1” isn’t just for marketing—someone might’ve stashed real production data in there.
Decide what is actually “prod”
It might sound simple—“the one with Production
in its name, obviously!”—but real life rarely cooperates. Maybe your “Dev” account is full of real user data. Maybe you have a random “POC” account that’s gone from side experiment to crucial data store. Identify all the accounts housing your critical data or essential workloads, no matter the label.
- Ask around: Which account, if vaporized, would lead to an emergency Slack channel meltdown?
- Peek at usage and billing: If a single account’s monthly cost rivals your rent, that’s probably production.
- Don’t trust labels alone: People rename accounts all the time, or create “temporary” ones that never go away.
Check your databases
For most organizations, wherever customer data lives is where production is. It doesn’t matter where you intended to put it or if its just one database. Attackers don’t care about your intentions. Eyeballing all of your databases, the ones you knew about and the ones you didn’t, and how much data they contain, is another way to discover production.
Your data might be spread across multiple regions, so don’t limit your search to just one. Below is some code that:
- Lists all AWS regions
- Fetches RDS instances and DynamoDB tables in each region
- Captures S3 buckets (which are global)
It writes the results to three files: rds_instances.txt
, dynamodb_tables.txt
, and s3_buckets.txt
. That way, you can skim through them later at your leisure—or panic accordingly if you spot something unexpected.
def get_all_regions():
return [region['RegionName'] for region in boto3.client('ec2').describe_regions()['Regions']]
def list_data_resources():
regions = get_all_regions()
with open('rds_instances.txt', 'w') as rds_file, \
open('dynamodb_tables.txt', 'w') as ddb_file, \
open('s3_buckets.txt', 'w') as s3_file:
try:
s3_client = boto3.client('s3')
for b in s3_client.list_buckets().get('Buckets', []):
s3_file.write(f"{b['Name']} | {b['CreationDate']}\n")
except Exception:
pass
for region in regions:
try:
rds = boto3.client('rds', region_name=region)
instances = rds.describe_db_instances().get('DBInstances', [])
if instances:
rds_file.write(f"\n{region}:\n")
for db in instances:
rds_file.write(f"{db['DBInstanceIdentifier']} | {db.get('AllocatedStorage')}GB\n")
except Exception:
pass
try:
ddb = boto3.client('dynamodb', region_name=region)
tables = ddb.list_tables().get('TableNames', [])
if tables:
ddb_file.write(f"\n{region}:\n")
for t in tables:
desc = ddb.describe_table(TableName=t)['Table']
ddb_file.write(f"{t} | {desc.get('ItemCount')} items | {desc.get('TableSizeBytes')} bytes\n")
except Exception:
pass
You’ll end up with three text files, each containing a region-by-region list of resources. If you see a suspiciously large DB instance or an unusually large DynamoDB table, it’s time to ask yourself: “Did I just discover another production environment?” If the answer is “yep,” well… time to add that AWS account to your list of prod accounts.
Figure out who has root
Every AWS account has a root user, the email-based login you set up on Day 1. This user can do anything—delete accounts, remove policies, drain your bank account for that matter. But most of us (hopefully) lock it away, protect it with MFA, and never use it for day-to-day tasks.
While the root user is the ultimate “god mode,” plenty of roles and users can achieve practically the same power through IAM policies—especially if they can manipulate IAM. So yes, root is scary, but in many AWS environments, you don’t even need root to effectively gain root-level control.
In other words: root is not the only root. There can be multiple “root equivalents” lurking in your accounts, wearing the disguise of admin roles, custom policies, or broad SSO permission sets.
Identify all principals with “AdministratorAccess”
The AWS-managed AdministratorAccess
policy is one of the easiest ways to give a user or role near-root privileges—often more casually assigned than it should be. Below is a more compact snippet that checks both roles and users for this policy. Keep in mind it will also pick up roles used by instance profiles if they have that policy attached.
def find_principals_with_admin_access():
iam = boto3.client('iam')
sts = boto3.client('sts')
admin_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
account_id = sts.get_caller_identity()['Account']
roles = iam.list_roles()['Roles']
for role in roles:
role_name = role['RoleName']
attached_policies = iam.list_attached_role_policies(RoleName=role_name)['AttachedPolicies']
for policy in attached_policies:
if policy['PolicyArn'] == admin_arn:
print(f"Role '{role_name}' has AdministratorAccess attached.")
path = os.path.join(account_id, 'admin-roles', role_name)
os.makedirs(path, exist_ok=True)
users = iam.list_users()['Users']
for user in users:
user_name = user['UserName']
attached_policies = iam.list_attached_user_policies(UserName=user_name)['AttachedPolicies']
for policy in attached_policies:
if policy['PolicyArn'] == admin_arn:
print(f"User '{user_name}' has AdministratorAccess attached.")
path = os.path.join(account_id, 'admin-users', user_name)
os.makedirs(path, exist_ok=True)
When you run this:
- You’ll see any IAM roles that have the managed
AdministratorAccess
policy. - You’ll see any IAM users with the same.
Yes, you might get some instance profiles in there, especially if someone decided to attach AdministratorAccess
to an EC2 instance role “just for convenience.” That’s not uncommon, and it’s definitely worth noting.
Identify SSO administrator users
If your organization uses AWS Single Sign-On (a.k.a. AWS IAM Identity Center) instead of local IAM users or roles, you’ll have permission sets that act like roles behind the scenes. A common one is AWSAdministratorAccess, granting near-root privileges. Anyone assigned to that set effectively wields “god mode” in your AWS account.
Unlike standard IAM users and roles, SSO relies on the identity store to manage users and groups. Each permission set can be assigned to either a user or a group. For groups, you’ll need to iterate through membership to find all the individual members who have administrator-level powers. Also note that SSO can theoretically run in multiple regions—hence the loop.
Below is some example code that discovers anyone with the administrator permission set and creates a local directory structure to note each user:
def find_sso_admin_users():
sts = boto3.client('sts')
account_id = sts.get_caller_identity()['Account']
for region in get_all_regions():
try:
sso = boto3.client('sso-admin', region_name=region)
instances = sso.list_instances()['Instances']
if not instances:
continue
identitystore = boto3.client('identitystore', region_name=region)
instance = instances[0]
instance_arn = instance['InstanceArn']
identity_store_id = instance['IdentityStoreId']
for permission_set_arn in sso.list_permission_sets(InstanceArn=instance_arn)['PermissionSets']:
policies = sso.list_managed_policies_in_permission_set(
InstanceArn=instance_arn,
PermissionSetArn=permission_set_arn
).get('AttachedManagedPolicies', [])
if any(p['Arn'].endswith('AdministratorAccess') for p in policies):
assignments = sso.list_account_assignments(
InstanceArn=instance_arn,
AccountId=account_id,
PermissionSetArn=permission_set_arn
)['AccountAssignments']
for assignment in assignments:
if assignment['PrincipalType'] == 'USER':
user = identitystore.describe_user(
IdentityStoreId=identity_store_id,
UserId=assignment['PrincipalId']
)
print(f"{region} {user['UserName']} {user['DisplayName']}")
os.makedirs(os.path.join(account_id, 'sso-admin-users', user['UserName']), exist_ok=True)
else:
# Expand group memberships to find individual users
group = identitystore.describe_group(
IdentityStoreId=identity_store_id,
GroupId=assignment['PrincipalId']
)
paginator = identitystore.get_paginator('list_group_memberships')
for page in paginator.paginate(
IdentityStoreId=identity_store_id,
GroupId=assignment['PrincipalId']
):
for membership in page['GroupMemberships']:
user = identitystore.describe_user(
IdentityStoreId=identity_store_id,
UserId=membership['MemberId']['UserId']
)
print(f"{region} {group['DisplayName']} {user['UserName']} {user['DisplayName']}")
os.makedirs(os.path.join(account_id, 'sso-admin-users', user['UserName']), exist_ok=True)
except Exception as e:
print(f"Error in region {region}: {str(e)}")
Run this with valid credentials for your organization’s management account (or wherever your SSO is configured), and it will:
- List each SSO instance in the discovered regions.
- Check each permission set for the AWSAdministratorAccess policy.
- Show you the SSO users or groups assigned, and for groups, expand to actual users.
It’s entirely possible you’ll find a bunch of folks who can do anything in your AWS environment—even if your local IAM user and role checks came up clean. Remember, SSO superusers are just as powerful as any “AdministratorAccess” or “root-equivalent” you’ve already uncovered.
Identify other versions of root
“AdministratorAccess” isn’t the only ticket to unstoppable power in AWS. There are other policies and privileges that can effectively grant the same destructive or exfiltration capabilities as root. For instance:
iam:*
on the right resources can be just as powerful as root, because if you can create or modify roles and users, you can ultimately grant yourself unrestricted access. In fact, justiam:PutRolePolicy
is often enough.- Broad data privileges like
s3:*
orrds:*
can be equally devastating. If a principal can read or delete all of your production data, that’s effectively root in practice—especially from a risk perspective. - “PowerUserAccess” is another AWS-managed policy that isn’t labeled “admin” but can still do enough damage (like managing most services) to sink an environment if misused.
- Inline policies can be attached directly to a single user or role—easy to miss if you’re only searching for attached managed policies. Sometimes an inline policy grants sweeping permissions that you’d never expect from the role’s name alone.
- Unmanaged (customer-managed) policies can be named anything—“DevTestPolicy,” “LegacyS3Access,” you name it. Some are clones of
AdministratorAccess
or contain broad*:*
privileges. If you’re not routinely checking them, you might be handing out root-like access without even realizing it.
The bottom line? A policy doesn’t need “admin” in its name to be root-level dangerous. If it can access or delete your crown-jewel data—or grant itself new privileges—it’s effectively root. I’ll leave it to you to adapt the earlier scripts to hunt for these inline and customer-managed policies that might be hiding terrifying permissions. If you see s3:*
or rds:*
combined with the power to manage IAM, you’ve got yourself another root in prod.
It’s never that simple
Now that you’ve identified which principals are basically root in your environment, it’s time for some bad news. Those aren’t the only things that have access to root because roles can be assumed by other principals in AWS. In fact, it’s best practice to set up roles and configure such trust relationships.
The next logical question is how those roles with high privileges can actually be assumed. That’s where trust policies come in. AWS lets you write a JSON document that says, “Principal X can call sts:AssumeRole on me.” If you chain enough of those statements together—across or even within the same account—someone who looks harmless (like your dev‑intern role) can suddenly morph into a production admin.
Here’s a bare‑bones trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/InternRole"
},
"Action": "sts:AssumeRole"
}
]
}
This means arn:aws:iam::123456789012:role/InternRole can call sts:AssumeRole on whichever role holds this policy, let’s pretend it’s SomeOtherRole. A good way to visualize these relationships is with a directed graph. Each role becomes a node, and if one role can assume another, you draw an arrow from the first to the second.

Trust policies aren’t limited to a single, neatly named role. The Principal
field can take an array of ARNs, a pattern with wildcards, or that notorious account‑wide ARN ending in :root
.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::123456789012:role/InternRole",
"arn:aws:iam::111111111111:role/DevRole",
"arn:aws:iam::222222222222:role/TestRole",
"arn:aws:iam::*:role/CI-*",
"arn:aws:iam::333333333333:root"
]
},
"Action": "sts:AssumeRole"
}
]
}
In one compact blob, that policy invites two specific roles, any role whose name starts with CI-
in any account, and everyone in account 333333333333. The :root
suffix is the real party crasher—it doesn’t refer to the literal root user logging in with a password. It means “any principal that already lives in this account and has permission to call sts:AssumeRole
.” In practice, that can be dozens or hundreds of roles and users you’ve never intended to mingle with this target role’s privileges. Swap the account ID and suddenly your staging account staff (or worse, an entire vendor account) can impersonate prod‑level roles.
Once a trust like this lands in your graph, the tidy node‑and‑edge picture explodes into a dense thicket of arrows. That’s why visualizing before and after adding a :root
principal is so effective—the difference between “one intern can assume one role” and “every principal in another account can assume it” is the difference between a single arrow and a firework.

There is one caveat, though: a trust policy only opens the door; the caller still needs permission to walk through it. The assumee (the principal doing the walking in) must have an identity policy that explicitly allows it to call sts:AssumeRole
. Without that line in their own policy the call will fizzle with an “AccessDeniedException.” In other words, a trust policy is necessary but not sufficient; you need both bits of the permission for the walk-in to succeed.
This is true even within the same account, if the :root
user is in the trust policy. However, it is NOT true if a principal is explicitly allowed within the trust policy and is in the same account. In that case, that side of the permission is sufficient to assume the role and walk in.
Figure out how to figure out who has access to root
To start, use your own concept code from the previous sections to pull down every role and user in each of your accounts, along with its trust policy. Don’t filter anything yet. This is just about having the raw data to run whatever analysis we want.
I’ve written some code (get-identiities.py) that will create the following directory structure:
.
├── roles/ # Base directory for all role data
│ └── {account_id}/ # Account-specific role directory
│ └── {role_name}.json # Individual role files containing:
│ ├── roleName
│ ├── roleArn
│ ├── trustPolicy
│ ├── inlinePolicies
│ ├── attachedPolicies
│ └── customerPolicies
│
├── users/ # Base directory for all user data
│ └── {account_id}/ # Account-specific user directory
│ └── {user_name}.json # Individual user files containing:
│ ├── userName
│ ├── userArn
│ ├── groups
│ ├── inlinePolicies
│ ├── attachedPolicies
│ └── customerPolicies
│
└── sso-users/ # Base directory for all SSO user data
└── {account_id}/ # Account-specific SSO user directory
└── {user_name}-{region}.json # Individual SSO user files containing:
├── userName
├── userId
├── email
├── groups
├── inlinePolicies
├── attachedPolicies
├── region
└── samlProviderArn
Then, figure out which of those roles actually should be the end nodes you care about—your admin roles, your root-adjacent ones, whatever your “oh no” tier looks like. Mark those as your targets.
Now walk the graph. For each role that can be assumed, follow the trail backward through its trust relationships. If a path exists from any other principal to one of your target roles, congrats: you’ve found another way to hit root.
If your graph is small, you’ve either got excellent isolation or you’re missing something. Most environments? The resulting map looks like a squid exploded in the CI/CD pipeline.
Of course, there are things this won’t catch—yet. You’re not accounting for service control policies. You’re not checking whether the assumer actually has an identity policy that lets them call sts:AssumeRole
. And you’re not digging into resource-based policies either. That’s fine. You’ll add those later. First, get the big picture.
Actually figure out who has access to root
Let’s make some pretty pictures together. Not the kind that end up in your Hidden Album.
This code (trust.py) will parse the directory structure created previously, ask you to pick a target admin role, and create a graph.
./trust.py
Complete graph has 3291 nodes and 6418 edges
Admin roles:
1. arn:aws:iam::123456789012:role/OrganizationAccountAccessRole
2. arn:aws:iam::123456789012:role/stacksets-exec
3. arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_AdministratorAccess
There are a few outputs.
1. Text file (trust-tree.txt)
This is the simplest representation of trusts. Starting with the chosen admin role. All of the principals that can assume it are listed one tab in. Then for each of those principals, if any are roles, the principals that can assume those are listed two tabs in. And so on.
Trust Tree for arn:aws:iam::111111111111:role/OrganizationAccountAccessRole
Admin Role: arn:aws:iam::111111111111:role/OrganizationAccountAccessRole (Account: 111111111111)
Account: 222222222222
Admin Role: arn:aws:iam::222222222222:role/AWSCloudFormationStackSetExecutionRole (Account: 222222222222)
Role: arn:aws:iam::222222222222:role/AWSCloudFormationStackSetAdministrationRole (Account: 222222222222)
Service Principal: cloudformation.amazonaws.com
Admin Role: arn:aws:iam::222222222222:role/aws-reserved/sso.amazonaws.com/ap-southeast-2/AWSReservedSSO_AdministratorAccess_48c2331c6f12c3dc (Account: 222222222222)
SAML Provider: arn:aws:iam::222222222222:saml-provider/AWSSSO_58eed3358c027019_DO_NOT_DELETE (Account: 222222222222)
I like this format because its both human readable and grep-able. It’s easy to look at and find interesting stuff, you can keep it as a snapshot for historical purposes, and you can’t really get overwhelmed by it.
2. Mermaid file (mermaid.md)
Mermaid lets you embed diagrams like flowcharts, sequence diagrams, and graphs directly in Markdown using a simple text-based syntax. Think of this as an export of the data you can pop into other tools for visualization, like Github.
These files get pretty hectic. You’re unlikely to work in them yourself.
3. Visualization
If you include the --visualize
flag to trust.py, it will make the aforementioned naughty but not dirty, pretty picture.

Each node on the graph is a principal. The different shapes indicate the types of principals. Different colors indicate different accounts. I did cheat a little bit and made accounts into nodes so it’s clear when there is an account level trust, even though accounts aren’t principals.

These diagrams can get pretty wild so buckle up. If there are a lot of account level trusts, the visualization might be useless. When the node number exceeds a certain limit, the edges are only drawn on mouseover, to give it the appearance of less chaos.

You can also try the --require-assume-role
flag to require some basic parsing of assumer principal policies for assume role permissions. This isn’t a perfect filter but it’s good enough to get an idea.

Things you are likely to find
Once you start pulling on the thread of trust relationships, a few familiar culprits show up again and again.
Deploy pipelines, for instance, tend to be alarmingly powerful. It’s easy to grant them admin so they can “just deploy,” and suddenly you’ve offloaded your access control problem to GitHub, GitLab, or whatever CI tool you picked because it had dark mode.

Now it doesn’t matter who has AssumeRole
. It matters who can merge a pull request. It matters who can modify the build runner. And if you’ve ever connected a GitHub Action with a juicy OIDC trust to a prod role, congrats—you’ve accidentally outsourced your AWS security perimeter to someone’s personal GitHub password.
I want you to really think about this deeply. Do you actually want everyone who has the ability to create or merge a pull request to have root access to production? Do you want all of their authenticated devices, including probably their home computers their kids play Fortnite on, to have root in prod? Would you ever design it that way?
You’ll also find roles you forgot existed. Legacy automation, deprecated services, old vendor integrations that were never cleaned up. And half of them will still have the keys to the kingdom because nobody ever came back to revoke access. Why would they? It still works.
Anti-patterns we see again and again
Full account trust is the obvious red flag. If a trust policy ends in :root
, you’re saying “let anyone in this account assume this role”—which, depending on your setup, might mean hundreds of users and roles, some of which you’ve never even reviewed. Sometimes it’s done to “simplify” access. What it really does is turn your security boundary into a group project.
Then there’s external full account trust—where a role in your account lets anyone in someone else’s account assume it. If you don’t own that other account, that’s just gifting privileges to someone else’s entire team, and then whatever else from there. You better hope they’re good at their jobs.
Even self-account trust can be dangerous. People assume that as long as it’s internal, it’s safe. But just because something’s in the same account doesn’t mean it’s trustworthy. That dev box with full sts:AssumeRole
? It’s in the same account. So is the random leftover automation role from 2018. If they have a trust path and an identity policy allowing the call, they’re in.
Here’s some we found in our own sandbox
We were poking around a sandbox account and found this beauty. It looks innocent enough—just a CloudFormation execution role—but look closer.
{
"roleName": "AWSCloudFormationStackSetExecutionRole",
"roleArn": "arn:aws:iam::123456789012:role/AWSCloudFormationExecutionRole",
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:root"
},
"Action": "sts:AssumeRole"
}
]
},
"inlinePolicies": {},
"attachedPolicies": [
"arn:aws:iam::aws:policy/AdministratorAccess"
]
}
So anyone in the account—any role or user with an identity policy allowing sts:AssumeRole
—can become this role. And that role? It has AdministratorAccess
. It doesn’t matter that it’s “just for CloudFormation.” This is root by another name.
We run a cloud security platform so we have accounts dedicated to testing misconfigurations. We misconfigure resources intentionally to make sure our product works. But even that has its limits, or so we thought until we found this gem.
{
"roleName": "public",
"roleArn": "arn:aws:iam::123456789012:role/public",
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "*"
},
"Action": "sts:AssumeRole",
"Condition": {}
}
]
},
"inlinePolicies": {},
"attachedPolicies": [
"arn:aws:iam::aws:policy/AdministratorAccess"
]
}
Ummmm…. Perhaps letting anyone in the world do everything to that account isn’t a good idea. Even with tight SCPs it’s asking for trouble.
Take it to its logical conclusion
If you’ve followed along this far, you might be wondering how much further can we take this? So put on your red team hacker hat. Part of the problem with this trust sprawl is it wildly increases attack surface. When you first start working in an AWS account, you only have to protect the root user to avert disaster.
How can that root user be hacked? Some options:
- Social engineer the CTO
- Hack the CTOs laptop
- Hack the company’s email, redirect the CTO’s email
- Hack the company’s DNS, redirect the CTO’s email
Maybe you hadn’t considered those things but at least the list is small and manageable. But it gets insane if you count all the end nodes of the trust relationships. Now you have to worry about all of those things for all of the users and machines that can assume the things to get to root.
Let’s zoom out for a second. If a deploy pipeline runs when a pull request is merged, and that pipeline has admin access, then what power does a pull request have? Who can make those PRs? Who can approve them? Who controls the GitHub org or the repo settings that enforce those checks?
Who has access to the email addresses tied to the AWS root user, or to the GitHub accounts managing your CI/CD? Can they reset passwords? Intercept MFA?
Who controls DNS? Not just Route 53—your actual domain registrar. Can someone change the MX records and hijack the email domain? Because once they do, they can reset almost anything.
Who manages the workstations that your SSO logins come from? If someone pops an admin box, do they get tokens for free? Is there an MDM system that can be used to push malicious profiles or extract session data?
The problem with root in prod isn’t just AWS. It’s the whole ecosystem of things that orbit around AWS and casually hold god-mode permissions without being called “admin” at all.
You might be doing everything right in IAM and still be one pull request, one DNS change, or one forgotten laptop away from disaster. Map those things out and then decide what to do.