AWS IAM in Practice: Least Privilege Without the Pain

TQ
Tran Quang
May 14, 2026 · 8 min read
#aws#iam#security#cdk

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:

  1. Everything is denied by default.
  2. An explicit Deny in any applicable policy wins, always.
  3. 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:

bash
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:

json
{
  "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:

json
{
  "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:

typescript
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:

typescript
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:

yaml
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.

json
{
  "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.
bash
# 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:

  1. Start new workloads with CDK grants and specific statements, never managed policies such as AdministratorAccess or AmazonS3FullAccess.
  2. When access is denied, read the error: most AWS APIs now include the denied action and, often, the policy type responsible.
  3. Put organization-wide rules (approved regions, no disabling CloudTrail, no public S3 changes) in SCPs rather than every role.
  4. Review unused access findings regularly and remove what is not used.
  5. Treat IAM changes as code changes, reviewed in pull requests with cdk diff output.

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.

Found this useful?

Share it with someone who might need it.

Related articles All articles