Most people learn IAM by writing an Allow statement, watching it work, and assuming that’s the model: a list of things you’re allowed to do. Then one day a role with s3:* gets AccessDenied on a bucket, someone adds "Resource": "*" to be safe, and it still fails. That’s the moment you need the real model, which is closer to a set of filters than a list of permissions.
Default deny, then three questions
AWS evaluates every API call against every policy that applies to it, and it starts from deny. Nothing is allowed until some policy says so. From there, the decision comes down to three questions asked in a fixed order:
- Is there an explicit
Denythat matches? If any applicable policy (identity, resource, SCP, boundary, session) has a matching Deny statement, the answer is no. Full stop. No number of Allow statements can override it. - Do all the guardrails permit it? Service control policies (from AWS Organizations), resource control policies, permissions boundaries and session policies are ceilings. If one of them is in play and doesn’t allow the action, the request is denied, even though none of them said “Deny”. This is called an implicit deny.
- Does something actually grant it? An identity-based policy on the user or role, or a resource-based policy (like a bucket policy), has to contain an Allow that matches the action, resource and conditions.
Only if the request gets past all three does it succeed. The important thing interviewers want to hear is the difference between the two kinds of “no”: an explicit deny means someone wrote a rule against this, and an implicit deny means nobody wrote a rule for it (or a guardrail didn’t include it). They need different fixes.
A small model you can run
The logic is simple enough to model in a few lines of Python. This is a simplification (real evaluation also checks conditions, principals and service-specific rules), but it captures the ordering. Each argument is the set of verdicts that layer produced for this request: {'Allow'}, {'Deny'}, or an empty set if no statement matched. None means that layer isn’t in play at all.
def decide(identity, resource=None, scp=None, boundary=None, session=None, same_account=True):
layers = [identity, resource or set(), scp or set(), boundary or set(), session or set()]
if any('Deny' in l for l in layers):
return 'Deny (explicit)'
for name, l in (('SCP', scp), ('permissions boundary', boundary), ('session policy', session)):
if l is not None and 'Allow' not in l:
return f'Deny (implicit: {name} does not allow)'
if same_account:
ok = 'Allow' in identity or 'Allow' in (resource or set())
else:
ok = 'Allow' in identity and 'Allow' in (resource or set())
return 'Allow' if ok else 'Deny (implicit: no allow)'Running it against a handful of cases gives this:
identity allows only -> Allow
identity allows, SCP denies -> Deny (explicit)
identity allows, SCP silent -> Deny (implicit: SCP does not allow)
identity allows, boundary silent -> Deny (implicit: permissions boundary does not allow)
nothing allows -> Deny (implicit: no allow)
bucket policy allows, same account -> Allow
bucket policy allows, cross account -> Deny (implicit: no allow)
both allow, cross account -> AllowTwo rows deserve a second look. “SCP silent” is the one that trips people: the SCP didn’t deny anything, it just didn’t list the action, and that’s enough to block it. And the cross-account rows show why a bucket policy that grants another account access isn’t enough on its own; the role in that other account also needs an identity policy that allows the call.
Note
The same-account rule has nuances for some services. KMS keys, for example, require the key policy to allow access (directly or by delegating to IAM), so an identity policy alone won’t do. Treat “either side is enough” as the general rule, not a universal one.
Walking through a real AccessDenied
Say a Lambda function’s execution role has this identity policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::invoices-prod/*"
}
]
}The function calls PutObject and gets back something like this (illustrative, but the shape is what current AWS error messages look like):
An error occurred (AccessDenied) when calling the PutObject operation:
User: arn:aws:sts::111122223333:assumed-role/invoice-writer/invoice-fn
is not authorized to perform: s3:PutObject on resource: "arn:aws:s3:::invoices-prod/2026/10/inv-1042.pdf"
with an explicit deny in a service control policyNotice three things in that message. The principal is an assumed-role session, not a user, so the policies that matter are the role’s policies plus any session policy. The resource is the exact object ARN. And the tail tells you the policy type: “with an explicit deny in a service control policy”. Many services now include that phrase (“because no identity-based policy allows”, “in a resource-based policy”, “in a permissions boundary”), and it saves a lot of guessing.
Here the fix isn’t in the role at all. An SCP on the production OU might deny s3:PutObject unless the request uses SSE-KMS encryption:
{
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "*",
"Condition": {
"StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
}
}The function was uploading without an encryption header. Adding more Allow statements to the role changes nothing, because step one of evaluation already said no. The right fix is to send ServerSideEncryption: 'aws:kms' with the upload, which makes the Deny’s condition false so it no longer matches.
If the message had said “because no identity-based policy allows”, you’d look for a typo in the resource ARN instead. A classic one: granting arn:aws:s3:::invoices-prod (the bucket) when PutObject needs arn:aws:s3:::invoices-prod/* (the objects). ListBucket needs the bucket ARN; object actions need the object ARN.
Mistakes that show up in interviews
Treating guardrails as grants. A permissions boundary that allows s3:* doesn’t give the role S3 access. The effective permissions are the intersection of the identity policy and the boundary. Same for SCPs: they define the maximum an account can do, and even the account’s root user is subject to them (the management account is the exception, since SCPs don’t apply to it).
Assuming the principal. People debug “my user” when the call is actually made by a role assumed through SSO, an EC2 instance profile, or a CI pipeline. Run aws sts get-caller-identity from the same environment first. If the ARN starts with assumed-role/, you’re debugging a role.
Ignoring conditions. A statement with aws:SourceIp, aws:PrincipalOrgID or aws:MultiFactorAuthPresent only matches when the condition holds. Requests from a VPC endpoint don’t carry a public source IP, so an IP-based condition that worked from a laptop silently fails from inside the VPC.
Using NotAction with Allow casually. "Effect": "Allow", "NotAction": "iam:*" grants every action in every service except IAM, which is almost never what someone meant.
Tools for proving a decision
You don’t have to reason from memory. The IAM policy simulator evaluates a principal’s policies against an action and resource and shows which statement decided it. CloudTrail records the denied call with the errorCode and the principal ARN. IAM Access Analyzer can validate policy grammar and point out overly broad grants before you deploy them. In an interview, mentioning that you’d confirm with the simulator or CloudTrail rather than keep editing policies signals you’ve actually done this.
Interview practice: the role that has s3:* but can’t read
A developer says: “My role has s3:* on *, but GetObject on one bucket returns AccessDenied. The bucket is in another account. What’s going on?” Walk through how you’d diagnose it.
Answer
Start by confirming the principal with sts get-caller-identity, since the call might not be using the role they think. Then go through the evaluation order.
First, explicit denies. The bucket policy in the other account might deny everyone outside a specific organization using aws:PrincipalOrgID, or an SCP in either account might deny S3 in that region. Any of those ends the discussion.
Second, cross-account rules. Because the bucket is in a different account, the role’s s3:* is only half of what’s needed. The bucket policy must also grant this role (or its account) s3:GetObject. If it doesn’t, the result is an implicit deny even though the role looks all-powerful.
Third, the object itself. If the object was uploaded by a third account and Object Ownership isn’t set to “bucket owner enforced”, the bucket owner may not own it and ACLs may decide access. And if the object is encrypted with a KMS key, the role also needs kms:Decrypt, which requires the key policy in the owning account to allow it. That one surfaces as AccessDenied on GetObject, which is confusing until you’ve seen it.
A good closing line: adding more permissions to the role can’t fix any of these, because the missing piece is on the other account’s side or in a deny.
Further reading
- AWS interview questions
- AWS MCQs
- AWS roles and temporary credentials
- Cross-account IAM role assumption
- Policy evaluation logic (AWS docs)