✓ Human-authored analysis; AI used for formatting and proofreading.
Your Cognito identity pool maps authenticated users to an IAM role. That role was created with the permissions the app needed at launch. Over eighteen months, the app grew. The role's policy grew with it. Today it has s3:*, dynamodb:*, and iam:PassRole. These permissions were added one by one during feature sprints, each individually approved, each "just for this one thing."
Your scanner checks the role and reports "overpermissioned." Your team triages it as a MEDIUM. It's on the backlog.
What the scanner didn't check: every authenticated user in your application can assume this role. Not just admins or just internal users. Every user who self-registered, confirmed their email, and logged in through the app's public-facing sign-up flow. That MEDIUM is an open privilege escalation path from any authenticated user to near-admin AWS access.
The trust model nobody audits
Cognito identity pools map authenticated users to IAM roles via role mappings. The simplest mapping and the default is:
{
"authenticated": "arn:aws:iam::111122223333:role/Cognito_appAuth_Role"
}
Every user who authenticates through any configured identity provider (Cognito user pool, Google, Facebook, SAML) gets temporary credentials for Cognito_appAuth_Role. The role's trust policy allows cognito-identity.amazonaws.com to assume it. No per-user distinction or group-based mapping. Every authenticated user is equal in the eyes of IAM.
This is the gap: the identity provider has user groups, roles, and permissions. The identity pool flattens all authenticated users to one IAM role. Whatever that role can do, every user can do.
How escalation happens
The app's backend uses Cognito_appAuth_Role to access AWS services on behalf of the user. Over time, the role accumulated permissions:
Month 1: s3:GetObject on the app's public content bucket. Reasonable, the app serves images from S3.
Month 3: s3:PutObject on the user uploads bucket. Reasonable, users upload profile photos.
Month 6: dynamodb:* on the app's tables. The developer needed it for a feature sprint. The permission stayed.
Month 9: iam:PassRole for a Lambda function deployment feature. An internal tool required it. The condition restricting which roles could be passed was never added.
Month 12: s3:* on a new analytics bucket. The analytics pipeline needed to read and write. The resource was specified as arn:aws:s3:::analytics-* matching every bucket whose name starts with "analytics."
Month 18 (today): The role has broad S3 access, full DynamoDB access, and iam:PassRole. Any authenticated user can:
# Get credentials as any authenticated user
aws cognito-identity get-credentials-for-identity \
--identity-id <auth-identity-id> \
--logins '{"cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx": "<id-token>"}'
# List all accessible buckets
aws s3 ls
# Read from the analytics bucket
aws s3 cp s3://analytics-prod/reports/revenue-2025.csv .
# Enumerate DynamoDB tables
aws dynamodb list-tables
# Read any table
aws dynamodb scan --table-name users-prod
# PassRole to a Lambda (potential for further escalation)
aws lambda create-function \
--function-name escalate \
--role arn:aws:iam::111122223333:role/AdminRole \
--handler index.handler \
--runtime nodejs18.x \
--zip-file fileb://payload.zip
A regular user, someone who registered through the public sign-up form has access to revenue reports, production user data, and the ability to create Lambda functions that run with admin permissions. Because the authenticated role accumulated permissions over eighteen months and every authenticated user shares it.
What the scanner sees vs. what exists
| Scanner check | Result | The actual question |
|---|---|---|
| Is this IAM role overpermissioned? | ⚠️ MEDIUM | Who can assume this role? |
Does this role have iam:PassRole? |
⚠️ MEDIUM | Is PassRole scoped to specific target roles? |
| Is this identity pool correctly configured? | ✅ PASS | Does the authenticated role mapping match the app's authorization model? |
| Are Cognito user groups configured? | ✅ PASS | Are groups used for role differentiation in the identity pool? |
Four checks, four independent results. The scanner sees an overpermissioned role (MEDIUM) and a correctly configured identity pool (PASS). It doesn't connect them: the correctly configured identity pool maps every authenticated user to the overpermissioned role. The PASS and the MEDIUM together are a CRITICAL.
The three ingredients
The escalation requires three configurations to coexist:
1. Default role mapping (no per-group differentiation)
{
"IdentityPoolId": "us-east-1:abc123",
"Roles": {
"authenticated": "arn:aws:iam::111122223333:role/Cognito_appAuth_Role"
},
"RoleMappings": {}
}
Empty RoleMappings means Cognito uses the default authenticated role for every user. No group-based differentiation. An admin and a free-tier user get the same IAM permissions.
2. Overly broad authenticated role
{
"PolicyDocument": {
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*", "dynamodb:*", "iam:PassRole"],
"Resource": "*"
}
]
}
}
The role's policy is the accumulation of eighteen months of feature sprints. Each permission was added for a specific feature. None were removed when the feature was finished or the approach changed.
3. Self-registration enabled
{
"UserPoolId": "us-east-1_xxxxx",
"AdminCreateUserConfig": {
"AllowAdminCreateUserOnly": false
}
}
Anyone can create an account. The sign-up form is public. Email verification is the only gate. Once verified, the user authenticates and receives credentials for the overpermissioned role.
Each ingredient is independently defensible. Default role mapping is the Cognito default. Broad permissions accumulated gradually. Self-registration is a business requirement. The interaction between all three creates an open escalation path from public sign-up to near-admin AWS access.
What compound detection finds
Analyzing the full configuration consisting of identity pool, IAM role, and user pool together:
Individual findings:
[MEDIUM] IAM role has s3:* without resource restriction
[MEDIUM] IAM role has iam:PassRole without condition restriction
[MEDIUM] IAM role has dynamodb:* without resource restriction
[LOW] Identity pool uses default role mapping (no group differentiation)
[INFO] Self-registration is enabled (business decision, not a violation)
Four findings across three severity levels. The IAM findings are MEDIUMs. They go on the backlog. The identity pool finding is LOW. It's a configuration note. Self-registration is INFO. It's a business requirement.
Compound finding:
[CRITICAL] Authenticated role escalation chain
Self-registration enabled (any internet user can create an account)
+ default role mapping (every authenticated user gets the same role)
+ role has s3:*, dynamodb:*, iam:PassRole
= any self-registered user has near-admin AWS access
Attacker cost: $0 (create a free account, confirm email)
Fix: Add role-based mapping using Cognito groups ($0, 2 hours)
Impact: Each user group gets only the permissions it needs
The compound reframes five findings that collectively score MEDIUM-on-the-backlog into one CRITICAL-fix-this-week. The attacker cost is zero. Creating an account is free and public. The fix is free. Cognito groups and role mappings are a configuration change, not a code change.
The fix
Step 1: Create Cognito groups with scoped roles (the structural fix)
# Create groups with role mappings
aws cognito-idp create-group \
--user-pool-id us-east-1_xxxxx \
--group-name Admins \
--role-arn arn:aws:iam::111122223333:role/App_AdminRole
aws cognito-idp create-group \
--user-pool-id us-east-1_xxxxx \
--group-name Users \
--role-arn arn:aws:iam::111122223333:role/App_UserRole
Step 2: Configure role mapping in the identity pool
aws cognito-identity set-identity-pool-roles \
--identity-pool-id us-east-1:abc123 \
--roles '{"authenticated": "arn:aws:iam::111122223333:role/App_UserRole"}' \
--role-mappings '{
"cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxx": {
"Type": "Token",
"AmbiguousRoleResolution": "Deny"
}
}'
Now the identity pool reads the user's Cognito groups from the ID token and maps to the corresponding role. Admins get App_AdminRole. Regular users get App_UserRole. Users not in any group get denied (AmbiguousRoleResolution: Deny).
Step 3: Scope each role's permissions
# App_UserRole: only what regular users need
aws iam put-role-policy \
--role-name App_UserRole \
--policy-name user-policy \
--policy-document '{
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::user-uploads-prod/${cognito-identity.amazonaws.com:sub}/*"
}]
}'
The ${cognito-identity.amazonaws.com:sub} substitution scopes each user's S3 access to their own prefix. No user can read another user's uploads. No user can access the analytics bucket. iam:PassRole and dynamodb:* are removed from the user role entirely.
How to check your own environment
# For each identity pool, check the role mapping
for pool in $(aws cognito-identity list-identity-pools --max-results 20 \
| jq -r '.IdentityPools[].IdentityPoolId'); do
roles=$(aws cognito-identity get-identity-pool-roles \
--identity-pool-id "$pool" 2>/dev/null)
auth_role=$(echo "$roles" | jq -r '.Roles.authenticated // "none"')
mappings=$(echo "$roles" | jq -r '.RoleMappings | length')
echo "Pool: $pool"
echo " Auth role: $auth_role"
echo " Role mappings: $mappings"
if [ "$mappings" -eq 0 ] && [ "$auth_role" != "none" ]; then
echo " ⚠️ DEFAULT MAPPING — every authenticated user shares one role"
# Check what that role can do
role_name=$(echo "$auth_role" | grep -oP '(?<=role/)[^/]+$')
policies=$(aws iam list-attached-role-policies --role-name "$role_name" \
| jq -r '.AttachedPolicies[].PolicyName')
inline=$(aws iam list-role-policies --role-name "$role_name" \
| jq -r '.PolicyNames[]')
echo " Attached policies: $policies"
echo " Inline policies: $inline"
for p in $inline; do
actions=$(aws iam get-role-policy --role-name "$role_name" --policy-name "$p" \
| jq -r '.PolicyDocument.Statement[].Action | if type == "array" then .[] else . end' 2>/dev/null)
for action in $actions; do
case "$action" in
*:*|*\**) echo " 🔴 BROAD ACTION: $action" ;;
iam:PassRole*) echo " 🔴 PASSROLE: $action" ;;
esac
done
done
fi
echo ""
done
If any pool prints DEFAULT MAPPING with BROAD ACTION or PASSROLE, you have the escalation path. Every user who self-registers gets those permissions.
The accumulation problem
This vulnerability doesn't start as a vulnerability. It starts as a correctly scoped role with minimal permissions. It becomes a vulnerability through accumulation by one permission per feature sprint, none removed when the feature ships.
The accumulation is invisible to point-in-time scanners because each permission addition is individually small and individually reasonable. Nobody adds s3:* on day one. They add s3:GetObject on one bucket in month one, s3:PutObject on another in month three, and by month eighteen the permission set has grown to cover actions and resources that were never part of the original design.
The compound check doesn't just catch the current state. It catches the interaction between the accumulated permissions and the trust model that maps every user to those permissions. That interaction turns eighteen months of individually reasonable permission additions into an open escalation path from public sign-up to admin-equivalent access.
No single-resource check catches accumulation. No single-resource check connects the accumulated role to the identity pool that maps every user to it. The compound is the finding. Everything else is backlog.
The scenarios in this article are modeled on real configurations found in production Cognito deployments and IAM security assessments. The analysis uses Stave, an open-source static analysis tool that evaluates cloud configurations via CEL predicates and exports standardized facts for consumption by external reasoning engines all from air-gapped snapshots with no cloud credentials required.
Top comments (0)