AWS IAM Explained: Roles, Policies, and Least Privilege in Practice
Every AWS breach you've ever read about — exposed S3 buckets, compromised Lambda functions, leaked credentials on GitHub — almost always traces back to one root cause: IAM was either misconfigured or ignored. IAM isn't a niche security topic. It's the foundation every other AWS service is built on top of. Get it wrong and the blast radius of a single mistake can be catastrophic.
Before IAM existed, cloud access was a blunt instrument. You either handed someone the root account password or you locked them out completely. IAM solved that by introducing fine-grained, programmable permissions — you can say 'this Lambda function may read from exactly one S3 bucket and nothing else.' That specificity is the difference between a contained incident and a company-ending breach.
By the end of this article you'll be able to design a real IAM strategy for a production workload. You'll understand the difference between users, roles, and policies — not just what they are, but when and why to use each one. You'll also walk away knowing the three mistakes that trip up even experienced engineers, and exactly how to fix them.
Users, Groups, and Roles — Picking the Right Identity Tool
The most common IAM confusion comes from mixing up three distinct concepts: Users, Groups, and Roles. They're not interchangeable — each one exists for a specific purpose.
An IAM User represents a human (or a legacy script) that needs long-term credentials — an access key and a password. Think 'Alice the developer.' Users are for things that log in persistently. The golden rule: if a machine needs access, it should almost never be a User.
An IAM Group is just a folder for Users. Instead of attaching the same policy to ten developers individually, you attach it once to a 'BackendEngineers' group. It's pure convenience — groups have no credentials of their own and can't assume roles.
An IAM Role is where things get powerful. A Role is an identity with no long-term credentials. Instead, it's assumed temporarily by someone — or something — that needs it. An EC2 instance, a Lambda function, a CI/CD pipeline, or even a developer who needs elevated access for 15 minutes. Roles hand out short-lived tokens that expire automatically. That expiry is the security win.
The modern AWS best practice is clear: use Roles for everything that isn't a human, and even for humans where possible via AWS SSO. Long-term access keys on IAM Users are a liability.
# CloudFormation template: Creates an IAM Role for an EC2 instance # so it can read from a specific S3 bucket — and nothing else. # This is the Least Privilege pattern in action. AWSTemplateFormatVersion: '2010-09-09' Description: IAM Role granting an EC2 instance read-only access to one S3 bucket Parameters: # The name of the bucket this EC2 instance is allowed to read TargetBucketName: Type: String Default: my-app-data-bucket Description: The S3 bucket the EC2 instance needs to read from Resources: # The Role itself — notice the AssumeRolePolicyDocument. # This is the TRUST POLICY: it says 'ec2.amazonaws.com is allowed # to wear this role'. Without this, nobody can assume the role. AppServerInstanceRole: Type: AWS::IAM::Role Properties: RoleName: AppServer-S3ReadOnly-Role AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: ec2.amazonaws.com # Only EC2 can assume this role Action: sts:AssumeRole Tags: - Key: ManagedBy Value: CloudFormation - Key: Purpose Value: AppServer-S3-Access # The Permission Policy — this is WHAT the role can actually do. # We attach it inline here to keep the resource self-contained. AppServerS3ReadPolicy: Type: AWS::IAM::Policy Properties: PolicyName: ReadOnlyAccessToAppDataBucket PolicyDocument: Version: '2012-10-17' Statement: # Allow listing the bucket contents (needed for most S3 SDKs) - Sid: AllowBucketList Effect: Allow Action: - s3:ListBucket Resource: # The bucket ARN (not the objects inside — that's a separate statement) - !Sub 'arn:aws:s3:::${TargetBucketName}' # Allow reading individual objects inside the bucket - Sid: AllowObjectRead Effect: Allow Action: - s3:GetObject Resource: # The /* at the end means 'all objects inside the bucket' - !Sub 'arn:aws:s3:::${TargetBucketName}/*' # Explicitly DENY any write or delete — belt AND suspenders - Sid: ExplicitlyDenyWrites Effect: Deny Action: - s3:PutObject - s3:DeleteObject - s3:DeleteBucket Resource: '*' Roles: - !Ref AppServerInstanceRole # The Instance Profile wraps the Role so EC2 can actually use it. # This is a step many engineers forget — a Role alone isn't enough for EC2. AppServerInstanceProfile: Type: AWS::IAM::InstanceProfile Properties: InstanceProfileName: AppServer-InstanceProfile Roles: - !Ref AppServerInstanceRole Outputs: InstanceProfileArn: Description: Attach this profile to your EC2 instance Value: !GetAtt AppServerInstanceProfile.Arn RoleArn: Description: The ARN of the created role Value: !GetAtt AppServerInstanceRole.Arn
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - app-server-iam
# Verify the role was created:
$ aws iam get-role --role-name AppServer-S3ReadOnly-Role
{
"Role": {
"RoleName": "AppServer-S3ReadOnly-Role",
"Arn": "arn:aws:iam::123456789012:role/AppServer-S3ReadOnly-Role",
"AssumeRolePolicyDocument": {
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
}
}
IAM Policies Deep-Dive — Trust Policies vs Permission Policies
IAM policies are JSON documents that define permissions. But there are actually two completely different types of policies in play, and confusing them is one of the most common IAM headaches.
A Permission Policy answers: 'What actions can this identity perform?' It lives on a User, Group, or Role and lists the allowed (or denied) API calls. This is what most people think of when they hear 'IAM policy.'
A Trust Policy answers: 'Who is allowed to assume this role?' It lives exclusively on Roles and is defined inside the AssumeRolePolicyDocument block. Without a correct trust policy, even if the permission policy is perfect, nobody can assume the role — you'll get an AccessDenied on sts:AssumeRole.
Policy evaluation follows a strict order: an explicit Deny always wins, even over an explicit Allow. If nothing matches, the default is Deny. This is called 'default-deny' and it's intentional — AWS never guesses that you probably meant to allow something.
There are also two attachment styles: Managed Policies (standalone documents you can reuse across many identities) and Inline Policies (embedded directly into one identity). Use managed policies for shared, reusable permissions. Use inline policies when you want an inseparable 1:1 relationship — like a specific Lambda function's permissions that should be deleted when the function is deleted.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowECRImagePush",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "eu-west-1"
}
}
},
{
"Sid": "AllowECSDeploymentToProductionCluster",
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition"
],
"Resource": [
"arn:aws:ecs:eu-west-1:123456789012:service/production-cluster/*"
]
},
{
"Sid": "AllowPassRoleToECSTasksOnly",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/ECS-TaskExecution-Role",
"Condition": {
"StringLike": {
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
{
"Sid": "DenyAccessToOtherEnvironments",
"Effect": "Deny",
"Action": [
"ecs:UpdateService",
"ecs:DeleteService"
],
"Resource": [
"arn:aws:ecs:*:123456789012:service/staging-cluster/*",
"arn:aws:ecs:*:123456789012:service/dev-cluster/*"
]
}
]
}
$ aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/CI-CD-Deployer-Role \
--action-names ecs:UpdateService \
--resource-arns arn:aws:ecs:eu-west-1:123456789012:service/production-cluster/api-service
{
"EvaluationResults": [
{
"EvalActionName": "ecs:UpdateService",
"EvalResourceName": "arn:aws:ecs:eu-west-1:123456789012:service/production-cluster/api-service",
"EvalDecision": "allowed",
"MatchedStatements": [
{
"SourcePolicyId": "AllowECSDeploymentToProductionCluster",
"StartPosition": { "Line": 1, "Column": 1 }
}
]
}
]
}
Least Privilege in Practice — Building a Real-World IAM Strategy
Least privilege sounds obvious: give each identity only the permissions it needs, nothing more. In practice, most teams do the opposite — they start with AdministratorAccess and never tighten it because the deadline is tomorrow.
The right pattern flips that approach. Start with zero permissions and add only what your application actually calls. AWS CloudTrail + IAM Access Analyzer makes this tractable: deploy your app with a wide policy in a staging environment, then use Access Analyzer to generate a least-privilege policy based on what was actually called in the last 90 days.
For humans, the modern approach is AWS IAM Identity Center (formerly SSO) with Permission Sets. Engineers log in via a central portal, assume a role for their session, and the role expires automatically. No long-term access keys. No storing credentials in ~/.aws/credentials on a laptop that might get stolen.
For cross-account access — e.g. a deployment pipeline in Account A pushing to production in Account B — you use role chaining: the pipeline assumes a role in Account A, which then assumes a role in Account B. Both sides must have matching trust policies. This is the backbone of multi-account AWS Organizations setups.
Condition keys are your secret weapon for tightening policies without over-complexity. You can restrict actions to a specific region, require MFA, limit to requests from your corporate IP range, or enforce that resources must have specific tags before they can be modified.
#!/usr/bin/env bash # ============================================================ # generate_least_privilege_policy.sh # Uses AWS IAM Access Analyzer to generate a policy based on # what a role ACTUALLY called in CloudTrail over the past 90 days. # Run this after a full staging deployment cycle. # ============================================================ set -euo pipefail # Exit on error, undefined var, or pipe failure # --- Configuration --- ACCOUNT_ID="123456789012" REGION="eu-west-1" ROLE_NAME="StagingApp-WideAccess-Role" # The role you want to analyse ANALYZER_NAME="AccountAnalyzer" # Your Access Analyzer name OUTPUT_FILE="generated_least_privilege_policy.json" # Step 1: Confirm the Access Analyzer exists (it's created per-region) echo "[1/4] Checking for Access Analyzer in ${REGION}..." aws accessanalyzer list-analyzers \ --region "${REGION}" \ --query "analyzers[?name=='${ANALYZER_NAME}'].status" \ --output text # Step 2: Start the policy generation job # CloudTrail must be enabled — this reads from your trail history echo "[2/4] Starting policy generation for role: ${ROLE_NAME}" JOB_ID=$(aws accessanalyzer start-policy-generation \ --region "${REGION}" \ --policy-generation-details "principalArn=arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}" \ --cloud-trail-details "{ \"accessRole\": \"arn:aws:iam::${ACCOUNT_ID}:role/AccessAnalyzerCloudTrailRole\", \"trails\": [{ \"cloudTrailArn\": \"arn:aws:cloudtrail:${REGION}:${ACCOUNT_ID}:trail/management-events\", \"regions\": [\"${REGION}\"] }], \"startTime\": \"$(date -u -v-90d '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u --date='90 days ago' '+%Y-%m-%dT%H:%M:%SZ')\", \"endTime\": \"$(date -u '+%Y-%m-%dT%H:%M:%SZ')\" }" \ --query 'jobId' \ --output text) echo " Job ID: ${JOB_ID}" # Step 3: Poll until the generation job completes (usually 2-5 minutes) echo "[3/4] Waiting for policy generation to complete..." while true; do STATUS=$(aws accessanalyzer get-generated-policy \ --region "${REGION}" \ --job-id "${JOB_ID}" \ --query 'jobDetails.status' \ --output text) if [ "${STATUS}" = "SUCCEEDED" ]; then echo " Status: SUCCEEDED" break elif [ "${STATUS}" = "FAILED" ]; then echo " Status: FAILED — check CloudTrail is enabled and the role exists" exit 1 else echo " Status: ${STATUS} — waiting 15 seconds..." sleep 15 fi done # Step 4: Fetch and save the generated policy echo "[4/4] Fetching generated policy..." aws accessanalyzer get-generated-policy \ --region "${REGION}" \ --job-id "${JOB_ID}" \ --query 'generatedPolicyResult.generatedPolicies[0].policy' \ --output text | python3 -m json.tool > "${OUTPUT_FILE}" echo "Done. Least-privilege policy saved to: ${OUTPUT_FILE}" echo "Review it carefully, then attach it to a NEW role in production."
ACTIVE
[2/4] Starting policy generation for role: StagingApp-WideAccess-Role
Job ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
[3/4] Waiting for policy generation to complete...
Status: IN_PROGRESS — waiting 15 seconds...
Status: IN_PROGRESS — waiting 15 seconds...
Status: SUCCEEDED
[4/4] Fetching generated policy...
Done. Least-privilege policy saved to: generated_least_privilege_policy.json
Review it carefully, then attach it to a NEW role in production.
# Peek at the output:
$ cat generated_least_privilege_policy.json | python3 -c "import sys,json; p=json.load(sys.stdin); [print(s['Action']) for s in p['Statement']]"
['s3:GetObject', 's3:ListBucket']
['dynamodb:GetItem', 'dynamodb:Query']
['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents']
| Aspect | IAM Role | IAM User with Access Key |
|---|---|---|
| Credential lifetime | Temporary (15min–12hr, auto-rotated by STS) | Permanent until manually rotated or deleted |
| Best used for | EC2, Lambda, ECS, CI/CD pipelines, cross-account access | Legacy scripts, third-party tools that don't support roles |
| Rotation required? | No — STS handles it automatically | Yes — manual rotation is mandatory, often skipped |
| Leaked credential impact | Low — token expires within hours | High — key works forever until someone notices |
| Multi-account support | Yes — role chaining across accounts natively | No — credentials are account-scoped only |
| MFA enforcement | Can require MFA to assume via condition keys | Can require MFA for console; API keys bypass MFA by default |
| AWS recommended? | Yes — preferred for all workload identities | Only for legacy use cases; avoid for new workloads |
🎯 Key Takeaways
- Roles beat Users for every machine identity — they issue short-lived STS tokens automatically, so a leaked credential expires in hours instead of living forever.
- There are two completely separate policies on every Role: the Trust Policy (who can assume it) and the Permission Policy (what it can do). Both must be correct or you'll get AccessDenied.
- An explicit Deny in any policy always wins over an Allow — AWS IAM is default-deny, meaning silence equals blocked, never allowed.
- IAM Access Analyzer's policy generation feature is the practical path to least privilege — deploy wide in staging, capture the actual CloudTrail calls, generate the minimum policy, apply in production.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using AdministratorAccess on a Lambda or EC2 role 'just to make it work' — Symptom: Your app works fine, but if that function or instance is compromised, the attacker has full account access. Fix: Use IAM Access Analyzer's policy generation after a staging run to generate the exact minimum policy, then apply that in production. It takes 10 minutes and eliminates an enormous risk.
- ✕Mistake 2: Forgetting the InstanceProfile wrapper for EC2 roles — Symptom: You create a perfect IAM Role in CloudFormation but EC2 throws 'Unable to locate credentials' at runtime. Fix: Always create an AWS::IAM::InstanceProfile resource and attach the Role to it, then reference the InstanceProfile (not the Role) in your EC2 LaunchTemplate or Instance resource. The Role and InstanceProfile are separate objects even though the AWS Console hides this distinction.
- ✕Mistake 3: Writing a Trust Policy that's too broad — e.g. Principal: '*' with no conditions — Symptom: Any AWS principal in any account can assume your role, which is effectively a public backdoor into your account. Fix: Always scope the Principal to a specific AWS account ID, service, or ARN. When allowing cross-account access, add a Condition block requiring aws:PrincipalOrgID to match your AWS Organization ID, so even if an account number is guessed, the role can't be assumed from outside your org.
Interview Questions on This Topic
- QWhat's the difference between a Trust Policy and a Permission Policy in IAM, and what happens if you get the Trust Policy wrong?
- QA Lambda function needs to read from DynamoDB and write to SQS. Walk me through exactly how you'd set that up — what resources do you create and what goes in each policy?
- QYour Security team says a developer's IAM User access key was exposed in a public GitHub repo. Walk me through your incident response steps, and then explain what architectural change would prevent this from happening again.
Frequently Asked Questions
What is the difference between an IAM Role and an IAM User in AWS?
An IAM User has permanent, long-term credentials (a password and/or access key) and represents a specific human or legacy application. An IAM Role has no permanent credentials — instead it issues temporary tokens via AWS STS when assumed. Use Roles for all application workloads (Lambda, EC2, ECS) and CI/CD pipelines. Reserve Users only for humans who can't use AWS SSO, or legacy systems that don't support role-based auth.
What does 'least privilege' mean in AWS IAM and how do you actually achieve it?
Least privilege means each identity has exactly the permissions it needs to do its job — no more. In practice, deploy your application in a staging environment with a broad policy, enable CloudTrail, then use IAM Access Analyzer's policy generation feature to produce a policy based only on the API calls your app actually made. Apply that tight policy in production. Revisit it whenever your app's feature set changes.
Why does my EC2 instance say 'Unable to locate credentials' even though I created an IAM Role for it?
EC2 can't use an IAM Role directly — it needs an Instance Profile, which is a container resource that wraps the Role. In CloudFormation this is a separate AWS::IAM::InstanceProfile resource. In the console, this is handled silently. You must attach the Instance Profile (not the Role ARN) to the EC2 instance. Once attached correctly, the EC2 metadata service at 169.254.169.254 serves rotating credentials automatically.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.