IAM is the service you get wrong first and the one that matters most. Every other AWS service asks IAM whether the caller is allowed to do this, so a misunderstanding here shows up as a confusing error somewhere else entirely.
The four things IAM has
| Thing | What it is |
|---|---|
| User | A person or a long-lived credential. Has a password and/or access keys |
| Group | A bag of users, so policies attach in one place. Groups cannot be nested and cannot be a principal |
| Role | A set of permissions something can assume temporarily. No password, no permanent keys |
| Policy | The JSON document that actually grants or denies |
The one to internalise is role. A role issues short-lived credentials that rotate automatically, and it is how a Lambda, an EC2 instance, a container or a CI pipeline should get access. If your code holds an access key, replace it with a role — that is the single highest -value security change most projects can make.
A policy, part by part
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadContentObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-content-bucket/lovemesomecoding/*",
"Condition": {
"StringEquals": { "aws:PrincipalTag/team": "content" }
}
}
]
}Version— always2012-10-17. It is a policy language version, not a date you chose. The older2008-10-17silently disables policy variablesEffect—AlloworDenyAction— service-prefixed operations. Wildcards allowed:s3:Get*Resource— ARNs. The trailing/*matters enormously; see belowCondition— where least privilege actually gets expressed
The bucket-versus-objects trap
These two ARNs are different resources and neither implies the other:
arn:aws:s3:::my-bucket— the bucket.s3:ListBucketacts on thisarn:aws:s3:::my-bucket/*— the objects.s3:GetObjectacts on these
Grant GetObject on the bucket ARN without the /* and every read is
denied, with a message that says access denied and not "you named the wrong resource". A policy
needing both operations needs two statements.
Identity policies and resource policies
There are two places a permission can live, and an S3 bucket routinely needs both.
An identity policy attaches to a user, group or role: "this principal may do X." A resource policy attaches to the resource: "these principals may do X to me." S3 buckets, SQS queues, SNS topics, KMS keys and Lambda functions all take one.
Within one account, either is usually enough. Across accounts, both are required — the calling side must allow the call and the owning side must allow the caller. "I gave the role the permission and it still fails" is nearly always a missing resource policy.
Here is the real one serving this site. It says exactly one CloudFront distribution may read these objects, and nothing else can:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::lovemesomecoding.com/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E30YUPLP37MY9U"
}
}
}
]
}Without that SourceArn condition, the policy would say "any CloudFront distribution
in the world may read this bucket" — which is a real and commonly shipped mistake. The bucket
itself stays entirely private with Block Public Access on.
How a request is actually decided
The order is short and worth memorising:
- An explicit
Denyanywhere wins. Always, immediately, and it cannot be overridden by any allow - Otherwise, an explicit
Allowpermits the request - Otherwise it is denied — the default is no access
So permissions are additive across every attached policy, and a single Deny is
absolute. That is what makes a "deny unless the request came over TLS" guardrail reliable, and it
is also why adding a policy can never fix a request that some other policy denies.
Roles and the trust policy
A role has two policies and people mix them up constantly. The permission policy says what the role can do. The trust policy says who may assume it — and a role with no trust policy is unusable no matter how much access it grants.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}That is the trust policy every Lambda execution role carries. For EC2 it is
ec2.amazonaws.com, and the role reaches the instance through an instance profile.
Conditions are where least privilege lives
Most policies written in a hurry grant an action on "*" because scoping by ARN is
fiddly. Conditions are usually the easier win, because they scope by something you already have.
| Condition key | Restricts to |
|---|---|
aws:ResourceTag/Key | Resources carrying a tag — how a scheduled stop is confined to dev instances |
aws:PrincipalTag/Key | Callers carrying a tag |
aws:SourceArn / aws:SourceAccount | The specific service resource calling on your behalf |
aws:SecureTransport | HTTPS only |
aws:MultiFactorAuthPresent | Sessions that used MFA |
A tag-scoped Allow turns "this Lambda can stop any instance" into "this Lambda can
stop instances someone deliberately labelled", and the difference is that a bug in the code can no
longer reach production. Write the condition even when the code already filters — the code is what
you are protecting against.
Practical rules
aws sts get-caller-identity
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/my-role \
--action-names s3:GetObject \
--resource-arns "arn:aws:s3:::my-bucket/file.txt"get-caller-identity answers "who am I right now", which is the first question when
something is denied unexpectedly. The policy simulator answers "would this be allowed", without
having to try it.
Start from the managed policy that is close, then tighten. Turn on MFA for anything with a console password. And never use the account root user for daily work — it cannot be restricted by any policy, which is precisely why it should stay locked away.