AWS AccessDenied — Decode and Fix IAM Fast
AccessDenied means an explicit or missing allow.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
- ✓Basic AWS IAM concepts
- ✓Comfortable with AWS CLI commands
- ✓Understanding of JSON policies
- AccessDenied means no allow (missing policy) or an explicit deny (SCP, boundary, or deny statement) — decode first to learn which
- Decode the encoded message with aws sts decode-authorization-message to get the exact action, resource, and context
- An explicit deny from an SCP or permissions boundary beats any allow — check those before adding policies
- Fix with least privilege: the single missing action on the narrowest resource, verified by replaying the call
Imagine an office where every door needs both your badge clearance AND the building rulebook to allow you. Your badge opens labs (your IAM policy allows it), but the rulebook says 'no contractors on floor 3 after 6 PM' (an SCP deny) — and the rulebook always wins. 'AccessDenied' just means a door stayed shut; it never says which lock did it. Decoding the error asks security exactly which rule fired, so you fix that rule instead of randomly upgrading badges and wondering why nothing changes.
Your deploy fails with AccessDenied, the encoded message is a wall of base64, and the engineer's first instinct is to attach AdministratorAccess 'just to unblock' — which works, ships to production, and becomes a $47K incident six months later when those credentials leak. The error names what was denied but never which policy is responsible, so teams guess: they add S3FullAccess for a KMS problem, or loosen a bucket policy when an SCP is the actual wall.
AccessDenied strikes at the intersection of five policy layers: identity policies, resource policies, SCPs, permissions boundaries, and session policies. Any single explicit deny anywhere beats every allow everywhere — which is why adding permissions to a denied principal changes nothing, and why the fix requires finding the deny before granting the allow.
The trap is treating all denials as missing allows. Half of production AccessDenied cases involve an explicit deny (often an SCP nobody on the team remembers), where the correct fix is scoping an exception — not stapling on broader permissions that silently expand blast radius.
By the end of this article you'll decode authorization messages into exact actions and context keys, trace denials through CloudTrail, evaluate the five policy layers in order, and write least-privilege fixes you can verify by replaying the denied call.
The Evaluation Algorithm: Deny Beats Allow, Always
AWS evaluates every request against up to five policy layers and applies three rules in strict order: an explicit deny in any layer wins immediately; otherwise a single explicit allow anywhere permits; otherwise an implicit deny refuses. This ordering is the entire debugging strategy in one paragraph — it means adding allows can only fix implicit denials (missing permissions), never explicit ones (SCPs, boundaries, deny statements). The teams that internalize this stop escalating privileges on day one and start hunting denies instead.
Map each layer to who owns it and what it can do. Identity policies (attached to users, groups, roles) grant allows and occasional denies — your team's normal surface. Resource policies (bucket policies, key policies, trust policies) grant cross-account access and can also deny. SCPs (organization level) can only restrict — they never grant, only filter the maximum available. Permissions boundaries cap a single principal's maximum. Session policies narrow assumed-role sessions. A request needs an allow surviving every filter with no deny anywhere.
The practical consequence: when generous identity policies fail, the answer is never 'more allows' — it's a deny in a layer the team forgot. Check SCPs, boundaries, KMS key policies (S3 writes to KMS-encrypted buckets need key-policy allows too), and trust-policy conditions in that order. Each is a five-minute check that saves days of allow-stacking. Save every simulation command with its verdict in the incident ticket — the allow/deny table becomes the audit trail proving the fix was scoped, and the next engineer inherits evidence instead of folklore.
Decode the Message: From Base64 to Actionable Sentence
The encoded authorization message looks like noise and reads like a verdict. It's a base64 (sometimes gzipped) JSON blob carrying the evaluation context: principal ARN, action, resource, and the condition keys AWS considered. Decoding needs only STS access with the same credentials — no IAM read permissions, no admin, no ticket to the security team. The engineer on call can run it in the first minute of any AccessDenied page.
Read the decoded output for three fields: the action-plus-resource pair (what was attempted), the context block (which condition keys were present or absent — the missing kms:ViaService in this article's incident was the whole diagnosis), and any matched-statement hints. Absent context keys are the classic reveal: policies demanding encryption, VPC endpoint, MFA, or tag conditions fail when callers simply don't send those attributes.
Make decoding muscle memory, not archaeology. Paste the message, decode, pretty-print, and read it before opening the IAM console. Half of all AccessDenied investigations end at this step with a sentence like 'PutObject denied because the call didn't specify KMS encryption' — a caller fix, not a policy fix. Store the decoded JSON in the incident ticket alongside the encoded original — future auditors and the next on-call inherit the full decision context instead of a base64 blob nobody can read.
SCPs and Boundaries: The Denies Nobody Remembers
Service Control Policies and permissions boundaries share one trait: they're set once by a security project, then forgotten by everyone who debugs daily IAM. SCPs attach to OUs and accounts, filtering the maximum permissions for everything inside — they grant nothing, only deny-shaped guardrails like 'no unencrypted S3 writes' or 'no leaving these regions.' Boundaries cap individual roles, commonly applied to delegated developer roles. Both produce explicit denies that survive AdministratorAccess, which is exactly why escalation 'mysteriously fails' and teams spiral.
SCP evaluation has quirks worth knowing. FullAWSAccess (the default) allows everything unless another SCP denies — removing it accidentally denies everything, a spectacular self-inflicted outage. Deny statements in any attached SCP apply; allows merely carve the ceiling. And SCP changes propagate in seconds but cached sessions can confuse — always test with fresh credentials after SCP edits.
Treat these layers as inventory, not mystery. List attached SCPs per OU quarterly, document each guardrail's intent and owner, and require security-team review for exceptions rather than silent allow-stacking elsewhere. When the simulator names an SCP deny, the correct fix is usually conforming the caller (encrypt with KMS, stay in-region) — the guardrail is working as designed.
KMS, Trust Policies, and Cross-Service Denies
A large share of S3-flavored AccessDenied is actually KMS-flavored: writing to a KMS-encrypted bucket requires kms:GenerateDataKey (and reading needs kms:Decrypt) granted in the key policy, not just S3 permissions on the bucket. Teams grant S3FullAccess, watch PutObject still fail, and conclude IAM is broken — while the key policy quietly denies a principal it was never told to trust. The decoded message plus a kms-scoped simulation names this in seconds.
Trust policies are the second hiding spot: AssumeRole denials (with MFA, external-ID, or IP conditions) fail before any permission evaluation, producing AccessDenied on sts:AssumeRole rather than on the target action. Cross-account patterns add resource-policy counterparts — the bucket policy must allow the foreign principal AND the foreign identity policy must allow the action, with no deny on either side. Miss either half and both teams blame each other.
VPC endpoint and tag conditions complete the usual lineup: policies requiring aws:SourceVpce deny traffic that bypasses the endpoint, and attribute-based (tag) conditions deny when principals or resources lack the expected tags. Each manifests as 'worked from my laptop, fails from the VPC' or 'worked yesterday, fails after retagging' — patterns that scream context, not identity.
CloudTrail: Every Denial Leaves a Signed Confession
CloudTrail logs every denied API call with the principal, action, resource, error code, and source IP — a complete incident history your team can query instead of reconstruct. Group a day's AccessDenied events by role and action and the pattern usually confesses immediately: one role, one action, 100% denied starting at a deploy timestamp means a scoped policy gap or a fresh deny; scattered denies across actions after a rotation means credential or session confusion.
Use the right tool per trail shape. lookup-events covers the last 90 days for quick incident queries from the CLI. Lake queries (or Athena over the S3 archive) handle the long view — 'when did this denial first appear' across months, which separates fresh breaks from always-broken paths nobody exercised. Alert on the metric, not the anecdote: a CloudWatch alarm on AccessDenied counts per critical role pages on night one, not during the restore drill on day eleven.
Correlate denial start times with change history: SCP edits, boundary attachments, key-policy changes, deploy timestamps. The denial that begins within minutes of a change names its cause. This correlation habit turns 'mysterious AccessDenied' into 'the 14:32 SCP edit' in one query — and makes the fix a revert or a scoped exception instead of a week of archaeology.
Least-Privilege Fixes You Can Verify by Replay
The correct fix grants the single missing action on the narrowest resource with the required conditions — nothing more. decoded message says s3:PutObject on arn:aws:s3:::reports/* with KMS context? The fix is that action on that prefix, conditioned on the encryption the guardrail demands. Resist the bundle policies (S3FullAccess, PowerUser) — each wildcard permission is future blast radius sold for present convenience, and the $47K leaked-credential incidents always trace back to a 'temporary' broad grant.
Write the policy as code and simulate before attaching. A JSON statement with explicit Action, Resource, and Condition blocks, checked into version control with a comment naming the incident, beats console click-ops that nobody can review. Simulate the exact denied call (with context entries) and demand allowed; simulate adjacent actions (s3:DeleteBucket, kms:*wildcard) and demand deny — proving both that the hole closed and that no new one opened.
Close the loop by replaying the real call with the real principal, then watching CloudTrail for 24 hours. New allows propagate in seconds but cached sessions and edge cases surprise — the replay plus a denial-count alarm is the only honest definition of fixed. Document the decision (why this action, this resource, this condition) so the next engineer inherits understanding, not just JSON.
The SCP Nobody Remembered That Blocked Backups for 11 Days
- Explicit denies beat all allows, so escalating permissions can't fix them. Eleven days of broader policies changed nothing because no allow overrides an SCP deny — decode first, escalate never.
- Monitor the outcome, not the job. Backup jobs 'succeeded' while writing nothing for 11 days; asserting the S3 object exists would have paged on night one.
- Forgotten guardrails outlive their authors. SCPs and boundaries set by departed engineers need ownership, documentation, and periodic review — or they become invisible walls.
| File | Command / Code | Purpose |
|---|---|---|
| aws iam simulate-principal-policy \ | The Evaluation Algorithm | |
| aws sts decode-authorization-message \ | Decode the Message | |
| aws organizations list-policies-for-target --target-id 123456789012 --filter SER... | SCPs and Boundaries | |
| aws kms get-key-policy --key-id abcd-1234 --policy-name default \ | KMS, Trust Policies, and Cross-Service Denies | |
| aws cloudtrail lookup-events \ | CloudTrail | |
| cat > backup-put-fix.json <<'EOF' | Least-Privilege Fixes You Can Verify by Replay |
Key takeaways
Common mistakes to avoid
5 patternsAttaching AdministratorAccess to 'unblock' the deploy
Stacking allows onto an explicit deny
Fixing S3 policies for what is actually a KMS denial
Editing the policy for the wrong principal
Monitoring job success instead of the protected outcome
Interview Questions on This Topic
A call fails with AccessDenied despite the role having S3FullAccess. What's your first step?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Cloud. Mark it forged?
5 min read · try the examples if you haven't