Every AWS API call is an authenticated and authorized request. When a Lambda function reads from DynamoDB, when an EC2 instance uploads a file to S3, when a CloudFormation stack creates a VPC — all of it goes through IAM authorization evaluation first. If the identity making the request does not have permission, the call fails. If the identity has more permission than it needs, a bug or a compromised workload can do far more damage than it should.
At its core, IAM authorization is built around identities and policies. An identity is the thing that acts — a role, a user, or a service. A policy is a document that defines what that identity is allowed or denied to do. Getting these right from the start is significantly easier than tightening them after something has gone wrong in production.
Roles and Policies Are Two Different Things
A role is an identity that AWS services and workloads can assume to take action on your behalf. A policy is a set of rules attached to that identity. They are separate objects in IAM and are connected by attaching one to the other. The distinction matters because a role without the right policies is useless, and policies without a role have nothing to enforce them.
An IAM role always has two types of policy associated with it:
- A trust policy — controls which principal is allowed to assume the role
- A permission policy — controls what the role can do once it is assumed
Both must be correct. A role with the right permission policy but a wrong trust policy can never be assumed — the permissions are irrelevant because nothing can use them. A role with the right trust policy but missing permissions can be assumed and then immediately fail when it tries to do anything. These two failures look different at runtime, which is useful to know before you run into them.
The Trust Policy
Imagine you are deploying a Lambda function that processes order data — it receives an event, reads the current order state from DynamoDB, and publishes a status update to an SNS topic. For the function to call DynamoDB or SNS at runtime, Lambda needs to assume an IAM role on behalf of the function and receive temporary credentials it can use to make those calls.
The trust policy is what allows that to happen. It answers one question: which principal is permitted to assume this role? For a Lambda function, the answer is the Lambda service itself:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
When you attach this role to the function, Lambda obtains temporary credentials for the role and makes them available to the runtime environment. Your code never handles credentials directly — the AWS SDK resolves them automatically from the runtime.
A common mistake is creating the role for Lambda but setting the principal to ec2.amazonaws.com by copy-pasting from another example. The role creates without error. The policy attaches without error. The function deploys. The failure only surfaces at invocation time, when Lambda cannot assume the role. At that point CloudWatch Logs will usually show a message along the lines of “the role defined for the function cannot be assumed by Lambda” — but because the deployment succeeded, teams often look at the application code first rather than the trust policy, which is where the actual problem is.
The correct principal for each common service:
| Service | Principal |
|---|---|
| Lambda | lambda.amazonaws.com |
| EC2 | ec2.amazonaws.com |
| ECS Tasks | ecs-tasks.amazonaws.com |
| CloudFormation | cloudformation.amazonaws.com |
| Glue | glue.amazonaws.com |
Creating the Role
Continuing with the order processing Lambda — save the trust policy to a file and create the role:
aws iam create-role \
--role-name order-processor-role \
--assume-role-policy-document file://trust-policy.json
The role now exists with a trust policy that allows Lambda to assume it. It has no permission policies yet, which means it can be assumed by Lambda and then do nothing. If you attached this to the function right now and invoked it, execution would start — the trust policy check passes — but the first DynamoDB call would return an AccessDeniedException. The function then fails partway through its execution path rather than failing at startup, which looks like an application error rather than a configuration error and takes longer to diagnose.
Writing a Permission Policy
The order processor needs three things: read access to a DynamoDB table that holds order state, publish access to an SNS topic for status updates, and write access to CloudWatch Logs so execution output is visible. The permission policy for that looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
},
{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:123456789012:order-status-topic"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
The Resource field on each statement is where least privilege actually happens. Specifying the exact ARN of the orders table instead of arn:aws:dynamodb:*:*:* means this function cannot read any other DynamoDB table in the account. If a bug in the function code tries to query the wrong table, or if someone exploits a vulnerability and attempts to read from a different table, IAM stops the request before it reaches DynamoDB.
The CloudWatch Logs block is the one people most often forget. Without those three permissions, the function executes — it starts, runs, and returns a result — but application logs may fail to appear in CloudWatch Logs, making debugging significantly harder. There is no obvious error in the Lambda console pointing to missing log permissions. You typically only notice when you go to investigate something else and find no log output to work with.
Create and Attach the Policy
aws iam create-policy \
--policy-name order-processor-policy \
--policy-document file://permission-policy.json
aws iam attach-role-policy \
--role-name order-processor-role \
--policy-arn arn:aws:iam::123456789012:policy/order-processor-policy
How IAM Evaluates Permissions
AWS does not evaluate a single policy in isolation — it evaluates all policies that apply to the identity making the request and combines them. The rule is that permissions are additive: if you have two policies attached to a role, one that allows s3:GetObject and one that allows s3:PutObject, the role can do both. You do not need everything in a single policy document.
The exception — and it matters — is an explicit deny. An explicit Deny statement anywhere in the evaluation chain overrides every Allow, regardless of which policy it comes from or how many policies grant the action. If a Service Control Policy from your AWS Organization explicitly denies s3:DeleteObject, no permission policy on any role in that account can override it. The deny wins unconditionally.
This is also the difference between implicitDeny and explicitDeny that the policy simulator reports. An implicitDeny just means nothing granted the permission — the default in IAM is always deny. An explicitDeny means something actively blocked it, and adding more permissions to the role will not fix the problem because the block is upstream of the role entirely.
Beyond identity-based policies, several AWS services also support resource-based policies — policies attached to the resource rather than to the identity. S3 bucket policies, SNS topic policies, and SQS queue policies are common examples. A resource-based policy can grant access to a principal from a different AWS account, which identity-based policies alone cannot do. Both types of policy are evaluated together, and an explicit deny in either one overrides any allow in the other.
Managed Policies vs Inline Policies
Say you are now deploying a second Lambda function — a reporting job that reads completed orders and writes a daily summary to S3. It needs the same CloudWatch Logs permissions as the order processor, but different application permissions.
Rather than writing the same CloudWatch Logs block into a second policy, attach the AWS-managed policy AWSLambdaBasicExecutionRole, which covers exactly those log permissions and is maintained by AWS:
# Attach the managed policy for logs — reused across both functions
aws iam attach-role-policy \
--role-name order-reporter-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Attach an inline policy for the reporting function's specific permissions
aws iam put-role-policy \
--role-name order-reporter-role \
--policy-name reporter-s3-access \
--policy-document file://reporter-policy.json
A managed policy can be attached to any number of roles, which makes it easy to maintain common permissions in one place. The downside is blast radius — if you update a managed policy and the change is wrong, every role it is attached to is affected simultaneously. An inline policy is embedded in a single role and scoped entirely to it. If you make a mistake updating it, only that one role is affected. For permissions that are sensitive or environment-specific, inline policies give you a smaller blast radius when something changes incorrectly.
What Happens When CloudFormation Creates IAM Resources
Suppose you are deploying the order processing system through a CloudFormation template that creates the Lambda function, its execution role, and the permission policies all in one stack. When you run the deployment, CloudFormation will refuse to proceed without an additional flag:
aws cloudformation deploy \
--stack-name order-processing-stack \
--template-file template.yaml \
--capabilities CAPABILITY_IAM
Without CAPABILITY_IAM, the deployment fails before a single resource is created. AWS requires explicit acknowledgment that your template will create or modify IAM resources. This is intentional friction — a guard against accidentally deploying a template that grants broader permissions than you intended.
If your template creates named IAM resources — a role with an explicit name like order-processor-role rather than an auto-generated one — you need CAPABILITY_NAMED_IAM instead. Passing CAPABILITY_IAM for a template with named resources produces a different error pointing to the named variant. The two errors look similar, and confusing the two flags is a common stumble when deploying stacks that manage their own IAM roles.
Testing a Policy Before It Causes Problems
Before attaching the order processor policy to anything, you can verify exactly what it allows using the IAM policy simulator:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/order-processor-role \
--action-names dynamodb:GetItem \
--resource-arns arn:aws:dynamodb:us-east-1:123456789012:table/orders
Running the same check against a table the function should not be able to access — say, a customers table — should return implicitDeny. If it returns allowed, something in your policy is broader than you intended, and the simulator caught it before it became a live permissions problem.
If the result is explicitDeny for something your function needs, the block is upstream of the role — likely a Service Control Policy from your AWS Organization. Adding more permissions to the role will not fix it. The block needs to be addressed at the organization level, not the role level.
Final Thoughts
The pattern that causes the most problems in practice is not missing permissions — those fail loudly and are easy to fix. It is overly broad permissions that go unnoticed until something unexpected happens. A wildcard in the Resource field, a broad managed policy attached because it was convenient, a trust policy that allows more principals than necessary — these are the things worth spending an extra five minutes getting right the first time.
- Wrong trust policy principal means the role can never be assumed by the intended service
- Missing permissions fail at the specific call that needs them, not at startup
- Missing CloudWatch Logs permissions make everything harder to debug without obvious errors
- Permissions are additive across policies — an explicit deny overrides all of them
- The simulator catches scope problems before they reach production
The most expensive IAM mistake is not the one that breaks something — it is the one that quietly allows too much for too long before anyone notices.
