IAM Policies Deep Dive: Least Privilege at Enterprise Scale
Designing IAM policies, SCPs, and permission boundaries that enforce least privilege across hundreds of AWS accounts
AWS Identity and Access Management is simultaneously the most important and most misunderstood service in the entire AWS ecosystem. I have seen IAM policies that grant *:* on * because someone needed to ship a feature and did not have time to figure out the correct permissions. I have also seen IAM policies so restrictive that developers could not deploy their own applications without filing a ticket and waiting three days.
Both extremes are failures. The first is a security incident waiting to happen. The second is a productivity tax that drives engineers to find workarounds, which usually means sharing credentials or escalating privileges in ways that are even less secure than the original permissive policy.
At a major entertainment company, I have been working on IAM architecture that achieves least privilege without crippling developer velocity. Here is what I have learned.
The Principle of Least Privilege
Least privilege means granting exactly the permissions required to perform a task, and nothing more. It sounds simple. In practice, implementing it across an enterprise with hundreds of AWS accounts, thousands of IAM roles, and dozens of teams is extraordinarily complex.
The complexity comes from several sources:
- AWS has over 200 services, each with dozens to hundreds of individual IAM actions. The total action space is enormous.
- Permission requirements change over time. A service that only needed S3 access last month now needs DynamoDB access because the team added a caching layer.
- Cross-account access patterns create trust relationships that must be carefully managed.
- Service-linked roles and resource-based policies interact with identity-based policies in ways that require careful analysis.
IAM Policy Structure
An IAM policy is a JSON document with four key elements:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"s3:prefix": ["data/", "logs/"]
}
}
}
]
}
Effect is either Allow or Deny. Action specifies the AWS API calls permitted. Resource scopes the actions to specific ARNs. Condition adds contextual constraints.
The most common mistake I see is omitting the Resource element or setting it to *. An action like s3:PutObject scoped to * means the role can write to every S3 bucket in the account. Scoped to a specific bucket ARN, it can only write to that bucket. The difference between these two policies is the difference between a contained permission and an exploitable one.
Service Control Policies
Service Control Policies (SCPs) are the guardrails of AWS Organizations. They define the maximum permissions available to any principal in an account, regardless of what IAM policies grant.
We use SCPs to enforce organization-wide constraints:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyNonApprovedRegions",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2"
]
}
}
},
{
"Sid": "DenyLeaveOrganization",
"Effect": "Deny",
"Action": "organizations:LeaveOrganization",
"Resource": "*"
},
{
"Sid": "ProtectCloudTrail",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail"
],
"Resource": "*"
}
]
}
This SCP ensures that no one in any account, regardless of their IAM permissions, can provision resources outside approved regions, leave the organization, or disable CloudTrail logging. These are non-negotiable security baselines.
SCPs are powerful because they create a ceiling. An IAM policy can grant ec2:* in eu-west-1, but if the SCP denies actions outside us-east-1 and us-west-2, the effective permission is denied. This layered approach lets teams manage their own IAM policies with less risk; the SCP catches dangerous outliers.
Permission Boundaries
Permission boundaries are a newer IAM feature that solves a specific problem: how do you let developers create IAM roles without giving them the ability to escalate their own privileges?
Without permission boundaries, a developer with iam:CreateRole and iam:AttachRolePolicy can create a role with AdministratorAccess and assume it. This is privilege escalation, and it is surprisingly common in organizations that have not implemented boundaries.
A permission boundary is a managed policy attached to a role that caps the maximum permissions the role can have. When a role has both an identity policy and a permission boundary, the effective permissions are the intersection of the two.
We use permission boundaries to create a "developer sandbox" pattern:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCommonServices",
"Effect": "Allow",
"Action": [
"s3:*",
"dynamodb:*",
"lambda:*",
"sqs:*",
"sns:*",
"logs:*",
"cloudwatch:*",
"xray:*"
],
"Resource": "*"
},
{
"Sid": "DenySecurityServices",
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:DeleteUser",
"iam:CreateGroup",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}
Developers can create Lambda functions, manage S3 buckets, and use DynamoDB without restriction. But they cannot create IAM users, modify organization settings, or access account-level controls. The permission boundary is the ceiling; their individual IAM policies operate within it.
Cross-Account Access Patterns
Our organization uses a multi-account strategy with roughly 50 AWS accounts organized into OUs: development, staging, production, security, shared services, and sandbox. Cross-account access is unavoidable and must be carefully controlled.
The primary pattern we use is the assume role pattern:
- The source account has an IAM role with a policy that allows
sts:AssumeRoleon the target account role ARN. - The target account has an IAM role with a trust policy that allows the source account to assume it.
- The target role has an identity policy that grants only the necessary permissions.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ci-cd-pipeline"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "deployment-pipeline-2018"
}
}
}
]
}
The ExternalId condition prevents the confused deputy problem, where a third party could trick a service into assuming a role on their behalf. It is a small detail that prevents a specific class of privilege escalation attacks.
Policy Analysis and Auditing
Writing good policies is necessary but not sufficient. You also need to verify that policies in production match your intent.
IAM Access Analyzer is not yet available (AWS has hinted at it), so we built our own analysis pipeline:
- A Lambda function runs daily, calling
iam:ListPolicies,iam:GetPolicy, andiam:GetPolicyVersionto collect all IAM policies across all accounts. - The policies are analyzed against a set of rules: detect wildcard actions, detect wildcard resources, detect policies attached to users (we require roles), detect policies without conditions on sensitive actions.
- Violations are reported to a Slack channel and tracked in a compliance dashboard.
This automated auditing catches policy drift before it becomes a security issue. Last month, it flagged a developer who had attached AdministratorAccess to a Lambda execution role "for testing." The policy was there for three hours before the audit caught it. Without automated scanning, it could have persisted for months.
Practical Recommendations
Start with deny, add allows incrementally. It is easier to grant additional permissions than to revoke them. When creating a new role, start with no permissions and add actions as needed based on CloudTrail logs showing what the role actually tries to do.
Use CloudTrail to right-size policies. CloudTrail logs every API call made by every principal. After a service has been running for a few weeks, analyze its CloudTrail events to see exactly which API actions it invokes, then write a policy that grants only those actions. This is tedious but effective.
Never attach policies to IAM users. Attach policies to roles and groups. Users assume roles or inherit permissions through group membership. This makes permission management scalable and auditable.
Tag everything. Use tags on IAM roles to identify the owning team, the associated application, and the environment. When an incident occurs, you need to quickly identify who owns a compromised credential and what it has access to. Tags make this possible.
Automate policy deployment. IAM policies should be managed through CloudFormation or Terraform, version controlled, and deployed through the same CI/CD pipeline as application code. Manual policy creation in the console is the enemy of consistency and auditability.
Least privilege is not a destination; it is a continuous process. Permissions that are correct today may be excessive tomorrow when a service is decommissioned. The goal is not perfection but a system that trends toward tighter permissions over time, with automated guardrails that catch the most dangerous deviations. Done well, it is invisible to developers. Done poorly, it is either a security liability or a productivity bottleneck. Getting the balance right is one of the most consequential challenges in cloud architecture.