S3 AccessDenied on GetObject: Every Real Cause in Order

Logeshwaran.C

"AccessDenied" on an S3 GetObject call means one specific thing: somewhere in the policy chain, a deny beat your allow — and the fastest fix is almost never the one people try first. But here is the part almost nobody tells you: S3 lies about missing objects. If the caller does not have s3:ListBucket permission on the bucket, a GET for an object that does not exist returns 403 AccessDenied instead of 404 NoSuchKey — meaning a large share of the "AccessDenied" errors on the internet are actually typo'd file paths wearing a disguise, and no amount of IAM editing will ever fix them. If you are new here, start with What's S3 first. If you are new to AWS, there is a section called learn aws start from there..

⚡ Quick Answer

First command, every timeaws sts get-caller-identity — confirm the request is coming from the account, role, and profile you think it is

Top causes in order → (1) wrong key path + no s3:ListBucket (a hidden 404), (2) missing s3:GetObject in the IAM policy, (3) cross-account with no bucket-policy side, (4) SSE-KMS without kms:Decrypt, (5) an explicit Deny with a condition you forgot

Every cause, in order, with the 10-second test for each: start herethe full ordered tablewhen nothing works

That 403-instead-of-404 behavior is not a bug. S3 deliberately refuses to confirm the existence of objects to callers who are not allowed to list the bucket — telling you "that file does not exist" would leak information you were not granted. So S3 says AccessDenied instead, and the error message sends you off to debug permissions when the actual problem is a missing prod/ prefix in the key. Every experienced AWS engineer has lost an afternoon to this. This guide puts it first for exactly that reason.

The second thing worth internalizing before you touch a single policy: "AccessDenied" is not one error. It is the last line of a long chain of evaluations — the IAM identity policy, the bucket policy, the KMS key policy, VPC endpoint policies, service control policies, permission boundaries, session policies — and the error message does not tell you which one said no. The workflow in this guide is therefore not "check everything at random." It is a specific order, from most likely to least, with a ten-second test for each cause so you can rule them out one at a time.

‍♂️ Jake's Reality Check

"So the error says AccessDenied, and you're telling me the file might not even exist? Why doesn't the error just say that?"

Because S3 cannot tell you the file doesn't exist without confirming it does exist when it does. Jake's shop keeps repair invoices in a locked filing cabinet. If a stranger asks "is there an invoice for John Smith in there?", the answer is "you can't look" — not "yes" or "no." Telling the stranger "no such invoice" for the names that don't exist would let them map out which names do, by elimination. S3 is that filing cabinet, and s3:ListBucket is the permission to know what's inside.

What AccessDenied Actually Means (and What It Never Says)

An S3 AccessDenied error is an HTTP 403 — the server understood the request, identified the caller, and refused it. It is not a network error (that would be a timeout), not a credentials error (that would be InvalidAccessKeyId or SignatureDoesNotMatch), and not a client bug. S3 knows exactly who you are and exactly what you asked for, and something in the policy evaluation chain said no.

Before you debug anything, read the full error message, not just the code. AccessDenied comes in flavors, and each flavor points at a different cause:

  • Plain "AccessDenied" with "User: arn:aws:iam::... is not authorized to perform: s3:GetObject" — the classic. A policy in the chain failed to allow, or something denied.
  • "User: anonymous is unauthorized" — the request arrived with no authentication at all. Your code is not signing the request, or a public/anonymous caller hit a private bucket.
  • "Request has expired" inside an AccessDenied — a presigned URL past its expiry. The signature was fine yesterday.
  • A KMS-flavored denial — if the object is SSE-KMS encrypted and the error mentions the key, you have s3:GetObject but not kms:Decrypt.

One more thing the error never tells you: who it thinks you are. Half of all AccessDenied mysteries end at the discovery that the request was sent from a different AWS account, a different named profile, or an assumed role than the one with permissions — which brings us to the diagnostic.

The 60-Second Diagnostic (Do This Before Anything Else)

Four commands, sixty seconds, and three of the causes below eliminated or confirmed. Run them in this order:

  1. Confirm who is calling.
    aws sts get-caller-identity

    This prints the exact ARN, account, and user/role making the request. Compare it to the ARN your policy grants. Wrong account, wrong profile, wrong role — all ruled in or out here, instantly. This command works even with almost no permissions, so it is the safest first move.

  2. Ask for the object's metadata instead of the object.
    aws s3api head-object --bucket my-bucket --key prod/reports/q3.pdf

    A HEAD request needs the same permissions as a GET. If this fails with AccessDenied, you can debug permissions without transferring the file. If it succeeds and the GET fails, something about the full GET path differs (KMS, versioning).

  3. Try to list the bucket's contents (or a prefix).
    aws s3api list-objects-v2 --bucket my-bucket --prefix prod/reports/

    If this succeeds, you have s3:ListBucket, so a real 404 would have said NoSuchKey — meaning your AccessDenied is genuinely a policy problem, not a missing file. If the list itself fails, note that, because it changes what the GetObject error means.

  4. Check CloudTrail for the denied event.

    In the CloudTrail console (or Event history), filter for the GetObject event around the time of the failure. The denied call is recorded with the error code, the identity ARN, the source IP, and — critically — a request ID you can hand to AWS support if nothing here resolves it. CloudTrail also reveals the true source IP of the request, which matters for condition-key denials.

With those four results in hand, walk the ordered causes below. Each has a ten-second test and a fix.

✅ Why this order works

The causes below are sorted by how often they actually cause this error in the real world — which is roughly inverse to how exciting they are. The boring causes (a typo'd path, a missing statement) dwarf the exotic ones (SCPs, VPC endpoints). Debug boring first, exotic last, and you will statistically finish sooner.

Cause 1: The Object Does Not Exist — and Missing ListBucket Hides the 404

The single most common source of S3 GetObject AccessDenied errors is not a permissions problem at all. It is a path problem pretending to be one.

Here is the mechanism. When you call GetObject and the key exists, S3 checks your s3:GetObject permission and returns the object. When the key does not exist, S3 faces a choice: tell you it does not exist (a 404 NoSuchKey), or refuse to say (a 403 AccessDenied). What it decides depends on one specific permission: s3:ListBucket. If the caller has s3:ListBucket on the bucket, S3 answers honestly with a 404. If the caller does not, S3 answers with AccessDenied — because acknowledging the absence of an object is itself information about the bucket's contents.

The result: a typo like prod/reprot/q3.pdf (or a missing prefix, or a case mismatch — Q3.pdf and q3.pdf are different objects) produces AccessDenied, and the debugging session goes hunting through IAM policies for an hour.

The 10-second test

Add s3:ListBucket to the caller's policy scoped to the bucket, then retry the GET:

{
  "Effect": "Allow",
  "Action": "s3:ListBucket",
  "Resource": "arn:aws:s3:::my-bucket"
}

If the error changes from AccessDenied to NoSuchKey — congratulations, you never had a permissions problem. Fix the path. If the error stays AccessDenied, remove the grant if it was temporary and move to Cause 2.

 Why this trips up teams migrating from other systems

  • On a local filesystem or most object stores, "file not found" and "not allowed" are different errors from anyone's perspective.
  • S3 ties the honesty of the 404 to a second permission, so the same missing file produces different errors for different callers — maddening in teams where one person can list and one cannot.
  • The fix for the confusion is deliberate: grant read-only listers s3:ListBucket even when their app only ever GETs, so missing files report as missing.

Cause 2: The Caller Genuinely Lacks s3:GetObject

The plain-vanilla cause: the identity's IAM policy simply does not allow s3:GetObject on that bucket. Common variants:

  • The policy grants s3:GetObject but on a different bucket's ARN (copy-paste from another stack).
  • The policy grants s3:Get* but the Resource uses the bucket ARN (arn:aws:s3:::my-bucket) without /* — object-level actions like GetObject need the object ARN (arn:aws:s3:::my-bucket/*), while bucket-level actions like ListBucket use the bare bucket ARN. This mismatch is extremely common because the two ARN shapes look nearly identical.
  • The policy allows GetObject but the caller is a different role than the one the policy was attached to (see Cause 6).

The 10-second test

The IAM Policy Simulator answers this without touching production. In the IAM console, open Policy simulator, choose the user or role, and simulate s3:GetObject against the exact resource ARN of the object. The simulator walks the real evaluation chain — policies, boundaries, everything attached — and reports allowed or denied. From the CLI:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/MyAppRole \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::my-bucket/prod/reports/q3.pdf

The correct minimal read policy, for reference:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-bucket/*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::my-bucket"
    }
  ]
}

Two statements, two ARN shapes, on purpose. The /* on the first is what most broken policies are missing.

Cause 3: Cross-Account Access — the Handshake Needs Both Sides

This is the cause that makes people question their sanity, because their IAM policy is correct and it still fails. Here is the rule that governs all cross-account S3 access: the account that owns the bucket must allow the request via the bucket policy, AND the account making the request must allow it via the IAM identity policy. Both sides, always.

Within a single account, the logic is more forgiving — an IAM identity policy allow by itself is enough (as long as the bucket policy does not explicitly deny). The moment the bucket and the caller live in different accounts, that leniency vanishes. An identity policy that says "allow everything on that bucket" is powerless if the bucket's side says nothing.

Scenario Identity policy (caller's account) Bucket policy (owner's account) Result
Same account Allow GetObject No policy at all Allowed (no deny present)
Cross-account Allow GetObject No policy at all AccessDenied — bucket side never said yes
Cross-account Nothing Allow to the other account AccessDenied — identity side never said yes
Cross-account Allow GetObject Allow to the caller's ARN Allowed — handshake complete

The fix, in order

  1. In the caller's account, attach an identity policy allowing s3:GetObject on the bucket's object ARN (arn:aws:s3:::my-bucket/*).
  2. In the bucket owner's account, add a bucket policy statement allowing the caller's account, role, or user:
{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111122223333:role/PartnerAppRole" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-bucket/*"
}
  1. Retry from the caller. If still denied, check the next cause — an explicit Deny layered on top.

‍♂️ Jake's Reality Check

"Two policies on two sides, and both have to say yes? Why can't my account just decide for itself?"

Ethan's answer: "Because your account deciding for itself would mean other people's accounts could decide for themselves about your bucket. The two-sided handshake is what stops account 111122223333 from reading account 999988887777's data just because someone over there fat-fingered a role trust. It feels bureaucratic exactly once — the first time. After that, it's the reason you sleep at night."

Cause 4: SSE-KMS — You Have GetObject but Not kms:Decrypt

If the object is encrypted with a customer-managed KMS key (SSE-KMS), downloading it requires two permissions: s3:GetObject from S3, and kms:Decrypt from KMS. S3 checks the first; the KMS key policy (plus the caller's IAM policy for KMS actions) checks the second. The caller can have flawless S3 permissions and still get AccessDenied because the KMS side refused.

This cause is sneaky because it often appears after a change nobody connected to the failure: someone re-encrypted new uploads with a new key, or a new bucket default encryption picked a key the reader was never granted. Old objects keep working; new ones 403.

The 10-second test

aws s3api head-object --bucket my-bucket --key prod/reports/q3.pdf

Read the ServerSideEncryption field in the response. If it says aws:kms, the SSEKMSKeyId field shows which key encrypted the object. Then check whether the caller can use that key:

aws kms describe-key --key-id <the-key-id-from-above>

If the describe fails, or the key policy does not grant the caller's account, that is your denial.

The fix

Grant kms:Decrypt on that specific key — via the key policy (for cross-account or key-policy-only keys) or via an IAM policy (when the key policy delegates to IAM):

{
  "Effect": "Allow",
  "Action": "kms:Decrypt",
  "Resource": "arn:aws:kms:us-east-1:123456789012:key/<key-id>"
}

⚠️ The two-keys trap

A bucket can contain objects encrypted with different keys — the bucket's default encryption setting applies at upload time, not retroactively. So "the bucket uses key A" can be true while yesterday's files use key B, and a role granted Decrypt on A only fails on every object uploaded before the switch. When a KMS denial affects some objects in a prefix and not others, different keys per object is the first thing to check.

Cause 5: An Explicit Deny Is Winning Somewhere

AWS policy evaluation has one rule that overrides everything else: an explicit Deny beats every Allow, no matter which policy it lives in or how many Allows you stack against it. A bucket policy with a single Deny statement that matches your request ends the conversation.

The classic deniers, in rough order of frequency:

  • Condition mismatches — a Deny (or an Allow whose condition you fail) keyed to aws:SourceIp when your request comes from a NAT gateway or different office range. The code works from the office and fails from the VPN, or vice versa, with no policy change in between.
  • NotPrincipal denials — a statement like "Deny everyone except this role" where your role is subtly miswritten in the exception list.
  • Deny-with-condition on encryption — "Deny PutObject unless SSE-KMS" style statements usually target uploads, but broad versions can catch reads too.
  • Lockdown defaults from Terraform/CloudFormation modules — a shared module that adds a restrictive Deny you did not know your stack inherited.

The 10-second test

Pull the effective policies and read them, in this order: the bucket policy first, then the caller's attached identity policies, looking specifically for "Effect": "Deny" blocks whose Resource matches your object:

aws s3api get-bucket-policy --bucket my-bucket

CloudTrail helps enormously here: the denied event's source IP tells you whether a aws:SourceIp condition is the culprit — if the request is coming from an IP your Allow's condition never included, you have found it.

Cause 6: You Are Not Who You Think You Are

Embarrassingly common, and it produces exactly this error. The scenarios:

  • The wrong named profileAWS_PROFILE=prod in one terminal, the dev role (with dev permissions) in the other. The dev role can read dev buckets, not this one.
  • An environment variable override — a stray AWS_ACCESS_KEY_ID in the shell from a previous experiment, silently shadowing your configured profile.
  • Code running where you did not expect — an EC2 instance profile or ECS task role with its own (different) permissions, not your user's. The error surfaces in application logs, and you debug your user's IAM, which is irrelevant.
  • An assumed role one hop short — the policy grants the role, but the code is signing with the user who is allowed to assume the role but never did.

This is why aws sts get-caller-identity is step one of the diagnostic and worth repeating here: it prints the account number and the full ARN that actually signed the failed request. If either does not match what your policy grants — this is your cause, and no policy edit will fix it until the code uses the right identity.

 The subtle variant that fools everyone

  • Assume-role works in the CLI but the SDK in your app has its own credential chain — the CLI diagnosis and the app failure can involve two different identities.
  • Run get-caller-identity from inside the failing context when possible: the same container, the same profile, the same machine. Identity is per-context, and the terminal that works proves nothing about the process that fails.

Cause 7: The Object Belongs to a Different AWS Account

A cause specific to buckets with a history: object ownership is separate from bucket ownership. An object uploaded by a caller from another account is owned by that caller, not the bucket owner — and without modern ownership settings, nobody else, not even the bucket owner, can read it without the owner granting access.

This bites buckets that predate modern defaults or that accept cross-account uploads:

  • A partner account uploads files into your bucket. The partner owns those objects. Your own account's s3:GetObject on them fails — AccessDenied — while the same permission on your own uploads works.
  • The failure is per-object: some keys in the prefix read fine, the ones from the partner 403. That pattern — selective denial within one prefix, one policy — is the ownership fingerprint.

The modern resolution is the bucket's Object Ownership setting: Bucket owner enforced disables ACLs entirely and makes the bucket owner own every object, past and future. New buckets and new uploads default toward this. On an older bucket, enabling it settles ownership of existing objects to the bucket owner — which is precisely the fix when cross-account uploads have fragmented your access. The other path is per-object ACL grants from the uploading account, but ACLs are the legacy mechanism; prefer ownership settings.

The 10-second test

If your own-account read fails on specific keys that other accounts uploaded while succeeding on your own, inspect those keys' ownership — the owner is visible in list-objects-v2 output or in the CloudTrail event from the original upload. Different owner than your account: confirmed.

Cause 8: The Presigned URL Is Expired or Mis-Scoped

Presigned URLs fail with AccessDenied too — and the people hitting them are usually application users, not AWS engineers, which makes the error report arrive secondhand and confused. The flavors:

  • Expired — SigV4 presigned URLs max out at seven days, and the generator chose hours. The error says "Request has expired" inside the AccessDenied body. The fix is regeneration, not permissions.
  • Wrong scope — the URL was signed for GET and the client sent PUT (or vice versa), or signed for one key and the client requested another (presigned URLs are per-object; you cannot wildcard them).
  • Signed by a limited identity — the URL is only as powerful as the credentials that signed it. A URL generated by a role without GetObject inherits that gap; expiry is fine, scope is fine, and it still 403s because the signer could not have fetched the object either.

The diagnostic for presigned failures is different from everything above: decode what is in the URL itself. The query string carries X-Amz-Algorithm, X-Amz-Credential (which embeds the signing identity and date), X-Amz-Expires, and X-Amz-SignedHeaders. Compare the credential's identity against the policy, the expiry against the clock, and the method against what the client actually sent. Nine times out of ten, the answer is sitting in the URL.

Cause 9: A VPC Endpoint Policy Is Silently Filtering the Request

If the request originates from inside a VPC — an EC2 instance, an ECS task, a Lambda in a VPC — its path to S3 may travel through a gateway VPC endpoint with its own policy. That policy can restrict traffic to specific buckets, specific actions, or specific principals, and a GetObject it excludes is denied before your IAM policies are even consulted.

The hallmark of this cause: the same role works everywhere except inside that VPC. The code, the policy, and the object are identical; only the network path differs. Deploy to a different environment (no endpoint, or an endpoint with a permissive policy) and it works — which sounds like magic until you know endpoints have policies.

The check: aws ec2 describe-vpc-endpoints for the VPC in question, then read the endpoint policy's Resource list. A policy scoped to arn:aws:s3:::internal-bucket/* will deny everything else — including your bucket — regardless of IAM. The fix is either adding your bucket to the endpoint policy or (the blunt instrument) an endpoint policy of full S3 access, if your security posture allows it.

Causes 10 and 11: The Invisible Ceilings — SCPs, Boundaries, Session Policies

Three more places a deny can live, all of them invisible from the bucket and the role:

  • Service Control Policies (SCPs) — org-level guardrails applied to accounts or OUs. An SCP that denies S3 access outside certain regions or buckets wins over every Allow beneath it. If the whole account suddenly cannot read a bucket it always could, and nothing in the account changed, look upward to the organization.
  • Permission boundaries — a cap on a role's maximum effective permissions. The role's policies can allow s3:GetObject; if the boundary does not also allow it, the effective permission is denied. Boundaries are common in delegated-admin setups and multi-tenant platforms, and they fail in exactly this quiet way.
  • Session policies — a policy passed as a parameter when a role is assumed (in the console's Switch Role, in aws sts assume-role, or in an ECS AssumeRole call). A session policy further narrows what the resulting session can do, and GetObject falls outside it. The role is fine; this particular session is not.

All three share a diagnostic signature: the Policy Simulator run as the role shows "allowed," while reality says denied — because the simulator was run in a context without that ceiling applied. The CloudTrail event for the denial, plus knowledge of your org structure, is the honest path here: check with whoever owns the SCPs and boundaries.

Causes 10 Continued, 11, and the Rarities: Requester Pays, Versioned GETs, Anonymous Calls

Wait — before the rarities, one clarification on numbering: the invisible ceilings above are Cause 10 (org-level ceilings collectively), and the three that follow are Cause 11's family plus two standalone rarities. If that reads awkwardly, here is the honest version: the table below is the authoritative numbering. These last three each earn a short entry:

Requester Pays buckets. A bucket configured for Requester Pays charges the downloading account for the data transfer — but only if the request opts in by sending the request-payer=requester header. Without it, even a fully-permissioned caller gets a 403. The error is AccessDenied, not a billing message, which is why it confuses. The check is the bucket's payment configuration:

aws s3api get-bucket-request-payment --bucket my-bucket

The fix in the CLI is --request-payer requester on the get-object call; in SDKs, the equivalent request-payer parameter.

Versioned objects need GetObjectVersion. A GET that specifies a versionId requires s3:GetObjectVersion, a separate action from s3:GetObject. Policies granting only the latter work for plain GETs and fail the moment code starts pinning versions — a change someone made in application code, not permissions, that lands on your desk as "S3 broke."

Anonymous access to a private object. The error says "User: anonymous is unauthorized." Either the client is not signing requests at all (a missing credential chain in a script, a public link constructed by hand instead of presigned) or someone expected the bucket to be public and it is not. The fix is presigned URLs for sharing, real credentials for programmatic access, and a hard look at any design that requires an open bucket.

Every Cause at a Glance, in Order

# Cause The 10-second test The fix
1 Object doesn't exist; no ListBucket hides the 404 Grant ListBucket, retry — error changes to NoSuchKey? Fix the key path (prefix, typo, case)
2 Missing s3:GetObject in identity policy (or bucket ARN without /*) Policy Simulator: simulate GetObject on the exact ARN Add Allow on arn:aws:s3:::bucket/*
3 Cross-account handshake missing one side Bucket account and caller account differ? Read the bucket policy Allow on both sides — IAM policy AND bucket policy
4 SSE-KMS without kms:Decrypt head-object shows aws:kms — can the caller use that key? Grant kms:Decrypt on the object's key
5 Explicit Deny (condition, NotPrincipal) Scan bucket + identity policies for Deny blocks; check CloudTrail source IP Fix the condition or the exception list
6 Wrong identity (profile, env var, instance role) aws sts get-caller-identity from the failing context Point the code at the right credentials
7 Object owned by another account Selective denial within one prefix; check object owner Object Ownership: Bucket owner enforced
8 Presigned URL expired or mis-scoped Decode the URL: X-Amz-Expires, credential, method Regenerate with valid scope and lifetime
9 VPC endpoint policy filtering Works everywhere except one VPC? describe-vpc-endpoints Add the bucket to the endpoint policy
10 SCPs, permission boundaries, session policies Simulator says allowed, reality says denied; whole org or role family affected Widen the ceiling or the session policy
11 Requester Pays, versioned GETs, anonymous calls get-bucket-request-payment; versionId in the call?; "anonymous" in the error request-payer header; GetObjectVersion; sign the request

When Nothing Works: The Honest End of the Road

If every cause above is ruled out — the identity is right, the policy chain simulates clean, the KMS key is fine, the endpoint is open, the object exists, and it still says AccessDenied — then the error is coming from somewhere your tooling cannot see, and the next step is not more guessing. It is a support ticket with teeth.

Every S3 response, including errors, carries a request ID and an extended request ID in the response headers. Capture them (the AWS CLI exposes them with --debug; SDKs surface them in error objects and the x-amz-request-id / x-amz-id-2 headers). Open an AWS support case with those IDs, the timestamp, the bucket, the key, and the caller ARN. With the request IDs, support can trace the exact evaluation on the backend — something no amount of policy archaeology on your side can do.

One last honest note before the ticket: the cause of a truly mysterious 403 is, more often than any of us would like to admit, a cache. An application-level cache holding a failure from ten minutes ago, a CDN in front of S3 replaying an old denial, a library retrying with stale credentials it never refreshed. Before contacting support, send one request from one clean context — a fresh terminal, a fresh CLI call, no libraries — and see if the error is even current. The most stubborn AccessDenied is sometimes one that ended an hour ago and nobody told the log file.

IT Admin: Preventing AccessDenied at Scale

Debugging one AccessDenied is an afternoon. Preventing them across a fleet is a practice. The four habits that hold up:

  1. Always pair GetObject with ListBucket on read policies. It costs nothing, it converts the misleading 403 into an honest 404, and it removes the number-one cause from your queue permanently. Every read-only policy template in the org should carry both statements — with the two different ARN shapes.
  2. Run the Policy Simulator in CI. Infrastructure-as-code pipelines can simulate the critical actions (GetObject on the real bucket ARNs) against the roles being deployed, and fail the pipeline on a simulated deny. AccessDenied is caught at pull-request time instead of incident time.
  3. Alert on CloudTrail AccessDenied spikes. A denied GetObject or two is a Tuesday; a rising curve of them is an expired key rotation, a changed bucket policy, or a partner whose access silently broke. CloudTrail plus a metric filter on error code 403 turns the invisible into a dashboard.
  4. Document the two-sided handshake where cross-account lives. The single most repeated cross-account mistake is editing one side and forgetting the other. A runbook entry — "changing access requires a commit in both repos/accounts" — is worth its weight in support tickets not filed.

‍♂️ Jake's Reality Check

"Eleven causes for one error message. Isn't that... a lot of ways to say no?"

Ethan's answer: "It's eleven places that can say no, but in any given year, three of them cause ninety percent of your tickets — the path typo, the missing statement, the cross-account handshake. Learn those three cold and treat the other eight as the rarely-needed map. And when you finally do hit the VPC endpoint one at 2 AM, you'll be the only one on the bridge who knows a VPC endpoint has a policy. That's the whole game: not memorizing eleven things, but having the map when the GPS fails."

Frequently Asked Questions

Why does S3 return AccessDenied when the object doesn't exist?

When the caller lacks s3:ListBucket permission, S3 refuses to confirm or deny the existence of the object — returning 403 AccessDenied instead of 404 NoSuchKey — because revealing that a key doesn't exist leaks information about bucket contents. Grant s3:ListBucket and missing objects report honestly as 404.

What permission do I need to download an object from S3?

s3:GetObject on the object's ARN (arn:aws:s3:::bucket-name/*). If the object is SSE-KMS encrypted, you also need kms:Decrypt on the encrypting key. If the request specifies a version ID, you need s3:GetObjectVersion instead of s3:GetObject.

Why does my IAM policy work for s3:GetObject but still get AccessDenied cross-account?

Cross-account S3 access requires both sides to allow it: the IAM identity policy in the caller's account AND a bucket policy statement in the owner's account granting the caller. Unlike same-account access, where an identity policy allow alone suffices, cross-account requests fail with AccessDenied if either side is missing.

Why does S3 return 403 instead of 404?

Because the caller lacks s3:ListBucket. With that permission, a GET for a nonexistent key returns 404 NoSuchKey; without it, S3 returns 403 AccessDenied to avoid confirming the object's absence. Most "mysterious" 403s on GetObject are missing objects in disguise.

Can KMS encryption cause AccessDenied on S3 GetObject?

Yes. Objects encrypted with SSE-KMS require kms:Decrypt on the encrypting key in addition to s3:GetObject. The S3 permission can be perfectly configured and the download still fails if the KMS side denies. This commonly appears after someone changes a bucket's default encryption to a new key.

How do I find out which policy is denying my S3 access?

Run the IAM Policy Simulator against the exact role and object ARN — it evaluates the full policy chain and reports allowed or denied. Combine that with the CloudTrail event for the denied call, which shows the true caller ARN and source IP, and aws sts get-caller-identity to verify identity. Between the three, the denying layer reveals itself.

Why did my S3 access suddenly stop working when nothing changed?

The usual silent changes: a new KMS key on recent uploads (old objects readable, new ones denied), an SCP or VPC endpoint policy updated by another team, an expired credential being cached by the app, a permission boundary applied to the role, or a presigned URL that passed its expiry. Check CloudTrail around the time it broke — something changed, even if it was not in the account you were looking at.

What does "User: anonymous is unauthorized" mean in S3?

The request arrived without any AWS signature — no credentials at all. Either the client code is not configured with credentials, or a hand-built public URL is being used where a presigned URL is required. The fix is authentication: real credentials for programmatic access, a presigned URL for sharing.

Why does my presigned S3 URL return AccessDenied?

Three reasons cover nearly all cases: the URL expired (the error says "Request has expired" — SigV4 presigned URLs live at most seven days), the URL was signed for a different method or key than the client used (presigned URLs are per-object and per-method), or the identity that signed it never had s3:GetObject in the first place (a presigned URL cannot exceed its signer's permissions).

Can a VPC endpoint policy cause AccessDenied on S3?

Yes. Requests from inside a VPC with a gateway S3 endpoint travel through that endpoint, whose policy can restrict which buckets, actions, and principals are allowed. The signature of this cause: the same role and code work everywhere except that one VPC. Check with aws ec2 describe-vpc-endpoints and read the endpoint policy.

Why can I read some objects in a bucket but not others?

Per-object denial within one prefix almost always means different encryption keys (some objects SSE-KMS with a key you cannot use, others not) or different object owners (cross-account uploads where the uploader owns the object). Run head-object on a working key and a failing key and compare the encryption and ownership fields.

What is the difference between s3:GetObject and s3:GetObjectVersion?

GetObject covers plain downloads of the current object. GetObjectVersion is required when the request specifies a versionId on a versioned bucket. Code that starts pinning versions will fail with AccessDenied if the policy only grants GetObject, even though plain downloads keep working.

Why does S3 need both the bucket ARN and the object ARN in policies?

Bucket-level actions like s3:ListBucket take the bare bucket ARN (arn:aws:s3:::bucket), while object-level actions like s3:GetObject take the object ARN with /* (arn:aws:s3:::bucket/*). A read policy needs both statements with their respective ARN shapes — mixing them up is one of the most common silent policy bugs.

Can an SCP or permission boundary cause S3 AccessDenied?

Yes, and they do it invisibly. An SCP at the organization level, a permission boundary on the role, or a session policy applied at assume-role time can each cap effective permissions below what the IAM policies allow — producing AccessDenied even when the Policy Simulator (run outside those ceilings) says allowed. When the whole account or role family is affected at once, look upward.

What information should I send AWS support about an AccessDenied error?

The request ID and extended request ID from the S3 response headers (visible with --debug on the CLI or in SDK error objects), the timestamp, the bucket and key, the caller ARN from aws sts get-caller-identity, and the region. With the request IDs, support can trace the exact evaluation on the backend — which no amount of client-side debugging can reach.

Why does Requester Pays give me AccessDenied when my permissions are fine?

A Requester Pays bucket requires the request to opt in to paying the transfer costs by sending the request-payer=requester header. Without it, even fully-permissioned callers receive a 403. Add the --request-payer requester flag (CLI) or the equivalent SDK parameter.

Wrapping Up: The Order That Saves the Afternoon

The next time AccessDenied stares back from a log, resist the urge to open the IAM console and start editing. Run the four-command diagnostic first — who are you, does the object's metadata answer, can you list, what does CloudTrail say — because three of those four answers collapse the eleven causes down to two or three candidates instantly.

Then walk the order: the path typo wearing a 403 disguise, the missing GetObject statement, the cross-account handshake, the KMS key nobody mentioned, the explicit deny with the forgotten condition. The boring causes first. The exotic ones have waited this long; they can wait ten more minutes.

Jake printed the ordered table and taped it inside the repair-counter cabinet, next to the Dynamic Lock instructions. Ethan's verdict: "An error message that can mean eleven things is not a problem — it is a checklist that hasn't been printed yet. You just printed it."

Revision note. Written September 2026. If you have spent an afternoon being lied to by a 403 that was really a 404, you have earned this page the hard way — and knowing that trick puts you permanently ahead of everyone still editing IAM policies for typos.

Related