Mark Lowel Montealto — Full Stack Developer & DevOps Engineer

Mark Lowel
Montealto

Full Stack Developer & DevOps Engineer

Blog

June 2025

·

3 min read

AWS IAM Best Practices for Developers: Least Privilege in Practice

Stop using AdministratorAccess everywhere. A practical guide to AWS IAM roles, policies, permission boundaries, and the least-privilege patterns every developer needs.

Introduction

  • The most common AWS security mistake: attaching AdministratorAccess to everything for convenience.
  • Least-privilege IAM is a forcing function for understanding what your app actually needs.
  • This guide covers: users vs roles vs policies, writing minimal policies, permission boundaries, OIDC for CI/CD, and the IAM Access Analyzer.

Core Concepts: Users, Roles, Groups, Policies

  • IAM User — a person or machine identity with long-lived credentials. Avoid for applications.
  • IAM Role — assumed temporarily by a service, Lambda, EC2, or external identity (GitHub). Preferred.
  • IAM Group — collection of users sharing policies. For human team members only.
  • IAM Policy — JSON document that allows/denies actions on resources. Attached to roles/users/groups.

The Principle of Least Privilege

  • Grant only the permissions required to complete a task — nothing more.
  • Start with a deny-all baseline; add Allow statements one by one as you discover what's needed.
  • Tools to find minimum permissions: CloudTrail → IAM Access Advisor, iam:SimulatePrincipalPolicy.

Writing Minimal IAM Policies

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadECRImages",
      "Effect": "Allow",
      "Action": [
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:BatchCheckLayerAvailability"
      ],
      "Resource": "arn:aws:ecr:ap-southeast-1:123456789012:repository/my-app/*"
    },
    {
      "Sid": "GetAuthToken",
      "Effect": "Allow",
      "Action": "ecr:GetAuthorizationToken",
      "Resource": "*"
    }
  ]
}
  • Scope Resource to specific ARNs, not "*".
  • Use StringEquals condition keys to restrict to specific accounts/orgs.
  • Never use "Action": "*" or "Resource": "*" in production policies.

Roles for Services (Never Long-Lived Keys)

  • ECS tasks: attach a Task Execution Role (pull from ECR, write CloudWatch Logs) and a Task Role (app's runtime permissions).
  • Lambda: a dedicated role per function — not a shared "lambda-role".
  • EC2: instance profile with exactly the permissions that instance needs.

OIDC: Keyless CI/CD Authentication

  • Replace AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in GitHub Actions with OIDC trust.
  • GitHub → AWS IAM OIDC provider → assume a role with a trust policy scoped to your repo.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:myorg/my-repo:ref:refs/heads/main"
      }
    }
  }]
}
# .github/workflows/deploy.yml
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-deploy-role
    aws-region: ap-southeast-1

Permission Boundaries

  • A permission boundary caps the maximum permissions a role can ever have — even if someone tries to attach a broader policy.
  • Use case: granting developers the ability to create roles for their applications without letting them escalate to AdministratorAccess.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["ec2:*", "ecs:*", "ecr:*", "logs:*"],
    "Resource": "*"
  }]
}
  • Attach to all developer-created roles: iam:CreateRole condition requires iam:PermissionsBoundary.

IAM Access Analyzer

  • Identifies resources (S3, IAM roles, KMS keys) that are accessible from outside your account.
  • Run findings automatically in CI: aws accessanalyzer list-findings --analyzer-name <name>.
  • Policy validation: aws accessanalyzer validate-policy --policy-document file://policy.json.

Audit and Monitoring

  • Enable CloudTrail in all regions — every IAM API call is logged.
  • Set up CloudWatch Alarm for ConsoleSignIn with MFA = false.
  • IAM Access Advisor: which services has this role actually called? Remove uncalled services from policy.
  • Periodically rotate or review unused credentials with IAM Credentials Report.

MFA and Root Account

  • Enable MFA on the root account immediately — use a hardware key (YubiKey) if possible.
  • Never use the root account for day-to-day operations; create an admin IAM user instead.
  • Require MFA for sensitive actions via aws:MultiFactorAuthPresent condition.

Conclusion

  • OIDC for CI/CD and task roles for services eliminate the need for any long-lived access keys.
  • The Access Analyzer + CloudTrail combo surfaces over-permissioned roles before attackers do.
  • Treat IAM policies as code: store in Terraform, review in pull requests, validate in CI.