What you'll learn
By the end of this you'll understand how AWS IAM policies actually work and how to write them to grant exactly the permissions your application needs — not more, not less. You'll know how to scope actions, resources, and conditions precisely, how to use IAM Access Analyzer and the policy simulator to verify what you've written, and how to avoid the common patterns that produce either too much access or confusing permission errors.
Least privilege is not just a security concept — it's a practical debugging tool. A well-scoped policy makes permission errors specific and fixable. A wildcard policy hides what the application actually depends on.
Who this is for
- Developers who've been attaching
AdministratorAccessorFullAccesspolicies to get things working and want to lock them down properly - Anyone who has written IAM policies before but isn't sure if they're scoped correctly
- Backend developers building on Lambda, ECS, or EC2 who need to configure execution roles for their applications
You can skip this if you're using a managed platform that handles IAM automatically (some fully managed services abstract permissions entirely). Come back when your application starts touching IAM directly — when you're writing Lambda execution roles, ECS task roles, or CI/CD pipeline credentials.
What is IAM least privilege?
IAM (Identity and Access Management) controls who can do what in your AWS account. Every API call your code makes to AWS is checked against the caller's IAM policy. Least privilege means the policy grants exactly the actions the caller needs on exactly the resources it needs to access — nothing broader.
Plain English: instead of "your app can do everything," least privilege says "your app can read objects from this specific S3 bucket and write logs to this specific CloudWatch log group — nothing else." If something breaks because a permission is missing, the error is specific and the fix is clear.
Simple idea: policies are JSON documents with four key parts — Effect (Allow or Deny), Action (which API calls), Resource (which AWS resources), and optionally Condition (under what circumstances). Least privilege means being as specific as possible in all three non-Effect parts.
Prerequisites
- An AWS account with IAM access
- Basic familiarity with AWS services (S3, Lambda, or whatever your application uses)
- Some application code already using AWS SDK that you want to lock down
Setup from zero
Step 1 — Understand the policy JSON structure
Every IAM policy is a JSON document with a Statement array. Each statement has at minimum:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-app-uploads/*"
}
]
}
This allows calling s3:GetObject on any object in the my-app-uploads bucket. Nothing else — not listing the bucket, not deleting objects, not creating other buckets. Exactly one operation on one bucket's objects.
Step 2 — Map your application's actual API calls
Before writing a policy, list every AWS API call your application makes. For an app that stores user uploads:
s3:PutObject — upload files
s3:GetObject — download files
s3:DeleteObject — delete files when records are removed
logs:CreateLogGroup — (Lambda auto-creates its log group)
logs:CreateLogStream — (Lambda, automatically)
logs:PutLogEvents — write logs from Lambda
This list becomes your Action allowlist. If an action isn't in the list, it shouldn't be in the policy.
Step 3 — Write the scoped policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3AppUploads",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::my-app-uploads/*"
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
}
]
}
The Sid (statement ID) is optional but documents what each block is for. The Resource ARNs are as specific as possible: one bucket's objects, one Lambda function's log group.
> Little tip: Use the Sid field in every policy statement. A policy with eight statements and no Sids is hard to audit six months later. A policy where each statement has a human-readable Sid like "S3UploadsReadWrite" or "DynamoDBSessionsTable" tells you exactly what each block is for at a glance.
The mental model
IAM evaluates every API call using a simple decision tree: deny by default → explicit allow → explicit deny wins.
- By default, every action is denied unless explicitly allowed
- An Allow statement permits the action if the Principal, Action, Resource, and Condition all match
- An explicit Deny statement blocks the action regardless of any Allow — you cannot Allow your way past a Deny
The most important practical implication of this model: missing permissions produce an AccessDeniedException immediately. The error tells you exactly which action was denied. This is a feature, not a bug — it tells you what to add to the policy.
Roles vs. users — for application code, always prefer IAM roles over IAM users with long-lived access keys. A role issues short-lived temporary credentials that rotate automatically. When your Lambda or EC2 instance has an attached IAM role, the SDK credential chain finds those credentials automatically — no keys to store, rotate, or accidentally commit.
The practical workflow: create a role for your application component (Lambda function, ECS task, EC2 instance), write a minimal policy, attach the policy to the role, and attach the role to the component. The application gets least-privilege access with no key management.
Key terms
Principal — who the policy applies to. For a resource-based policy (like an S3 bucket policy), the Principal is who's being granted access. For an identity-based policy (attached to a user or role), the principal is implicit — it's the user or role the policy is attached to.
Action — the AWS API operation being granted or denied. Format: service:OperationName. Examples: s3:PutObject, lambda:InvokeFunction, dynamodb:GetItem. Wildcard: s3:* means all S3 operations.
Resource — the specific AWS resource the statement applies to. Specified as an ARN. Wildcard: arn:aws:s3:::my-bucket/ means all objects in my-bucket. Never use as the resource unless you specifically mean every resource in the account.
Condition — optional constraints that further restrict when a statement applies. Common uses: restrict S3 access to specific IP ranges, require MFA for sensitive operations, allow Lambda to only be invoked from a specific API Gateway.
Managed policy — a standalone policy document that can be attached to multiple users, groups, or roles. AWS provides managed policies (like AmazonS3ReadOnlyAccess); you can also create customer managed policies.
Inline policy — a policy embedded directly in a user, group, or role rather than existing as a standalone document. Useful for policies that are specific to one principal and shouldn't be reused.
Step-by-step: using IAM Access Analyzer
IAM Access Analyzer has a feature called policy generation that watches your CloudTrail logs and generates a policy based on what your application actually called. This is the most accurate way to derive a least-privilege policy from real usage.
- AWS Console → IAM → Access Analyzer → Policy generation
- Select the IAM role or user your application runs under
- Choose a CloudTrail trail (or enable CloudTrail if not already enabled)
- Set a date range covering representative usage — at least a few days of normal operation
- Click Generate policy
The output is a policy JSON containing only the actions that were actually called during the period, with resources scoped to the ARNs actually accessed. Review it, add any actions you know are needed but weren't called during the monitoring period (for example, error paths or rarely triggered code), and use it as your policy.
> Little tip: The policy simulator at https://policysim.aws.amazon.com lets you test a policy before applying it. Enter the action (e.g., s3:PutObject), the resource ARN, and the policy JSON, and the simulator tells you whether the action would be allowed or denied — without making a real API call. Use it before attaching a new policy to catch both over-permission and missing-permission issues.
Patterns
One role per application component — create a separate IAM role for each Lambda function, ECS task, or EC2 instance group. Don't share one role with broad permissions across multiple components. If the user-upload Lambda only needs S3 access, its role shouldn't also have DynamoDB access because a different Lambda happens to use that same role.
Tag-based access control for multi-tenant apps — use IAM condition keys to scope access to resources tagged with specific values:
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/owner": "${aws:PrincipalTag/userId}"
}
}
}
Deny as a safety net — use explicit Deny statements to prevent specific high-risk actions regardless of what other policies allow:
{
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:AttachRolePolicy",
"ec2:TerminateInstances"
],
"Resource": "*"
}
Common mistakes
**Using "Action": "" or "Resource": ""** — a policy with "Action": "" and "Resource": "" is equivalent to root access. It's common to see this in development to "just make things work," but it should never reach production. Replace wildcards with the specific actions and ARNs your application actually uses.
Attaching AdministratorAccess to Lambda execution roles — Lambda functions with admin access can call any AWS API, read any data, and create any resource in your account. A bug or injection vulnerability in that Lambda has unrestricted access to your AWS environment. Give Lambda execution roles only the permissions the function's code actually exercises.
Confusing authentication errors with authorization errors — AuthFailure or UnrecognizedClientException means the credentials are invalid (authentication). AccessDeniedException means the credentials are valid but the policy doesn't permit the action (authorization). These have completely different fixes.
Troubleshooting
AccessDeniedException on a specific action — the error message includes the action name (e.g., s3:PutObject) and the resource ARN that was attempted. Add that action and the closest matching resource ARN to the relevant policy statement. Re-run the operation to confirm the fix.
Policy changes not taking effect — IAM policy changes propagate globally but can take a few seconds. If you updated a policy and still see AccessDeniedException, wait 10–15 seconds and retry before assuming the policy edit failed.
Confusing ARN formats — S3 bucket ARNs don't include a region or account ID: arn:aws:s3:::my-bucket. S3 object ARNs add / or a specific key: arn:aws:s3:::my-bucket/uploads/. Lambda ARNs include region and account: arn:aws:lambda:us-east-1:123456789012:function:my-function. Mismatching the ARN format in a Resource field causes the policy to not match even when the action is correct.
Checklist
- [ ] Every application component (Lambda, EC2, ECS) has its own IAM role
- [ ] Roles use policies scoped to specific actions, not wildcards
- [ ] Resource ARNs are specific — bucket ARN plus path, not
* - [ ] Policy statements have human-readable
Sidfields - [ ] IAM Access Analyzer policy generation run against actual usage
- [ ] Policy simulator used to verify the policy before attaching
- [ ] No
AdministratorAccessorFullAccessmanaged policies on application roles - [ ] Access keys rotated on a schedule (or eliminated in favor of roles)
- [ ] Root account MFA enabled and root keys deleted
Practice task
Take a Lambda function you've already deployed and audit its execution role. List every AWS service the function's code actually calls. Open the current policy and compare it to your list — identify any actions or resources broader than necessary. Write a replacement policy that grants exactly the actions in your list on the specific ARNs the function accesses. Use the policy simulator to verify it before attaching. Redeploy and run the function to confirm everything still works.
FAQ
When should I use AWS managed policies versus customer managed policies?
AWS managed policies (AmazonS3ReadOnlyAccess, AWSLambdaBasicExecutionRole) are convenient starting points and AWS keeps them updated, but they're usually broader than necessary. Use them as a reference for what actions a service requires, then write a customer managed policy scoped to your specific resources.
How do I grant cross-account access?
Create an IAM role in the target account with a trust policy that allows the source account's principal to assume it. The source account's principal calls sts:AssumeRole to get temporary credentials for the target account role. This is the standard pattern for accessing resources across AWS accounts.
Should I use permission boundaries?
Permission boundaries are IAM policies that cap the maximum permissions a principal can have — useful for delegating permission management to other teams or for CI/CD roles that provision infrastructure. They add complexity; use them only when you're managing permissions at scale across many teams or accounts.
What to learn next
After least privilege: AWS Organizations SCPs (Service Control Policies) for account-level permission guardrails, IAM conditions for fine-grained context-based access, and AWS Secrets Manager for storing credentials that Lambda or EC2 reads at runtime without hardcoding them.
Related on Baseline
- AWS for Node apps — the IAM user and credential setup that least-privilege policies protect
- AWS Lambda basics — configuring the execution role that Lambda runs under
- CloudWatch logging basics — the CloudWatch Logs permissions a Lambda execution role needs
Takeaways
IAM least privilege means granting exactly the actions your application exercises on exactly the resources it accesses — nothing more. Policies are JSON documents with Effect, Action, Resource, and optional Condition. Use IAM roles for application code, not long-lived access keys. Run IAM Access Analyzer against real usage to derive an accurate minimal policy. Scope resource ARNs as specifically as possible — the tighter the policy, the more specific and fixable the error when something is missing.
If you remember only one thing: never use "Action": "" or "Resource": "" in a production policy. Wildcard actions and resources look like a shortcut but they're a security incident waiting for a trigger. Write out the specific actions. The 10 extra minutes it takes to scope a policy correctly prevents the unlimited downside of a compromised credential with unrestricted access.