AWS IAM in Practice: Least Privilege Without the Pain
Everyone agrees with least privilege in principle. In practice, IAM policies drift toward "Action": "*" because a deadline is close, the error message is cryptic and a wildcard makes the problem go away. Six months later nobody remembers which permissions are actually needed.
The fix is not heroic manual policy writing. It is a small set of habits and tools that make the narrow policy the easy path: understanding how evaluation works, using roles everywhere, generating policies from code, scoping with conditions, and letting AWS tell you what is unused.
How policy evaluation actually works
Most IAM confusion disappears once the evaluation logic is clear. For a request within a single account, AWS roughly applies these rules:
- Everything is denied by default.
- An explicit
Denyin any applicable policy wins, always. - Otherwise, the request is allowed only if an applicable policy explicitly allows it and no guardrail (SCP, resource control policy, permission boundary or session policy) excludes it.
Several policy types can take part:
| Policy type | Attached to | Grants permissions? | Typical use |
|---|---|---|---|
| Identity-based policy | User, group or role | Yes | What a workload or person can do |
| Resource-based policy | Resource (S3 bucket, SQS queue, KMS key, Lambda) | Yes | Who can access this resource, including other accounts |
| Permission boundary | User or role | No, sets a maximum | Delegating role creation safely |
| Service control policy (SCP) | AWS Organizations account or OU | No, sets a maximum | Organization-wide guardrails |
| Session policy | Passed when assuming a role | No, sets a maximum | Narrowing a session further |
The key insight: only identity-based and resource-based policies grant access. Everything else can only take permissions away. For cross-account access, both sides must allow it, the caller's identity policy and the resource's policy (or the target role's trust policy).
Roles, not long-lived keys
IAM users with access keys are the most common source of leaked credentials. Prefer temporary credentials everywhere:
- Humans: IAM Identity Center with permission sets, so people sign in through your identity provider and get short-lived role sessions.
- AWS workloads: execution roles for Lambda, task roles for ECS, instance profiles for EC2, IAM Roles for Service Accounts or EKS Pod Identity for Kubernetes.
- CI/CD: OIDC federation from your CI provider instead of stored keys.
A quick way to confirm which identity your tooling is actually using:
aws sts get-caller-identity
Anatomy of a least-privilege policy
A good policy names specific actions, specific resources and, where useful, conditions. Here is an identity policy for a service that reads and writes objects under one prefix of one bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListOnlyUploadsPrefix",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-app-data",
"Condition": {
"StringLike": { "s3:prefix": ["uploads/*"] }
}
},
{
"Sid": "ReadWriteUploadsObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-app-data/uploads/*"
}
]
}
Note the two resource shapes. s3:ListBucket applies to the bucket ARN, while object actions apply to object ARNs (bucket/key). Mixing them up is a classic reason a seemingly correct policy fails.
Conditions do the heavy lifting
Conditions let you express intent that actions and resources alone cannot. Global condition keys worth knowing:
| Condition key | What it checks | Example use |
|---|---|---|
aws:SecureTransport |
Request used TLS | Deny plain HTTP to S3 |
aws:SourceArn / aws:SourceAccount |
Resource or account a service acts on behalf of | Prevent confused deputy in resource policies |
aws:PrincipalOrgID |
Caller belongs to your AWS Organization | Share a bucket with all your accounts only |
aws:RequestedRegion |
Region of the request | Restrict to approved regions |
aws:MultiFactorAuthPresent |
MFA was used for the session | Require MFA for sensitive actions |
aws:PrincipalTag/<key> and aws:ResourceTag/<key> |
Tags on caller and resource | Attribute-based access control |
Attribute-based access control (ABAC) is especially useful when many teams share one account. This statement lets a principal manage only EC2 instances tagged with the same team as the principal:
{
"Effect": "Allow",
"Action": ["ec2:StartInstances", "ec2:StopInstances", "ec2:RebootInstances"],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": { "aws:ResourceTag/team": "${aws:PrincipalTag/team}" }
}
}
Adding a new team then means tagging, not writing a new policy. Pair ABAC with controls on who can change tags, or the model can be bypassed by retagging resources.
Let CDK write the policies
Hand-written JSON is where wildcards creep in. The AWS CDK grant* methods produce narrowly scoped statements tied to the actual resources:
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Duration } from 'aws-cdk-lib';
// fn is a lambda.Function defined elsewhere in the stack
bucket.grantRead(fn, 'uploads/*'); // object reads limited to uploads/*
table.grantReadData(fn); // Query, GetItem, Scan, BatchGetItem ...
queue.grantSendMessages(fn); // sqs:SendMessage and related actions
// When no grant method exists, add a precise statement
fn.addToRolePolicy(new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/notes-api/*`],
}));
Review the synthesized template with cdk diff before each deploy. CDK highlights IAM changes separately, which makes permission creep visible in code review.
CI/CD with GitHub Actions OIDC
Storing an access key in CI secrets is unnecessary. GitHub Actions can exchange its OIDC token for a short-lived role session. The trust policy is where least privilege matters most, because it decides which repositories and branches can assume the role:
const provider = new iam.OpenIdConnectProvider(this, 'GitHubOidc', {
url: 'https://token.actions.githubusercontent.com',
clientIds: ['sts.amazonaws.com'],
});
const deployRole = new iam.Role(this, 'GitHubDeployRole', {
roleName: 'github-deploy-notes-api',
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
StringEquals: {
'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
},
StringLike: {
'token.actions.githubusercontent.com:sub': 'repo:my-org/notes-api:ref:refs/heads/main',
},
}),
});
The workflow then requests an ID token and assumes the role:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy-notes-api
aws-region: ap-southeast-1
- run: npx cdk deploy --require-approval never
Never use a wildcard like repo:my-org/* for a production deploy role. A pull request workflow in any repository of the organization could then assume it. If you deploy through GitHub environments, scope the sub claim to repo:my-org/notes-api:environment:production instead.
Permission boundaries for safe delegation
Developers often need to create roles for their own Lambda functions, but iam:CreateRole combined with iam:AttachRolePolicy is effectively admin. Permission boundaries solve this: allow role creation only if the new role carries a boundary that caps its permissions.
{
"Sid": "CreateRolesOnlyWithBoundary",
"Effect": "Allow",
"Action": ["iam:CreateRole", "iam:PutRolePermissionsBoundary"],
"Resource": "arn:aws:iam::123456789012:role/app-*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/AppWorkloadBoundary"
}
}
}
Also deny changing or deleting the boundary policy itself, and removing the boundary from roles, otherwise it can be undone. In CDK, iam.PermissionsBoundary.of(this).apply(boundaryPolicy) applies a boundary to every role in a stack or app.
Tools that keep policies tight
You do not have to guess which permissions are needed or unused.
- IAM Access Analyzer policy validation flags syntax problems, overly broad statements and security warnings.
- Access Analyzer policy generation builds a policy from CloudTrail activity for a role over a chosen period, giving you a starting point based on real usage.
- Unused access findings in Access Analyzer report unused roles, access keys and permissions across the organization.
- Last accessed information shows when each service was last used by a principal.
- The policy simulator tests whether a principal can perform an action before you ship.
# Validate a policy document before deploying it
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document file://policy.json
# Check what a role can actually do
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/notes-api-fn-role \
--action-names s3:GetObject s3:DeleteObject \
--resource-arns arn:aws:s3:::acme-app-data/uploads/report.pdf
# Find services a role has not used recently
JOB_ID=$(aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789012:role/notes-api-fn-role \
--query JobId --output text)
aws iam get-service-last-accessed-details --job-id "$JOB_ID"
validate-policy works well as a CI step for any hand-written policy documents.
A workflow that scales
A sustainable approach looks like this:
- Start new workloads with CDK grants and specific statements, never managed policies such as
AdministratorAccessorAmazonS3FullAccess. - When access is denied, read the error: most AWS APIs now include the denied action and, often, the policy type responsible.
- Put organization-wide rules (approved regions, no disabling CloudTrail, no public S3 changes) in SCPs rather than every role.
- Review unused access findings regularly and remove what is not used.
- Treat IAM changes as code changes, reviewed in pull requests with
cdk diffoutput.
Conclusion
Least privilege becomes painless when it is the default outcome of your tooling rather than a manual audit. Use roles and federation instead of keys, let CDK generate scoped policies, lean on conditions such as aws:SourceArn, aws:PrincipalOrgID and resource tags, and restrict trust policies as carefully as permission policies. Then let Access Analyzer and last accessed data show you what to remove. Permissions will still need occasional adjustment, but each change will be small, reviewed and easy to reason about.