BlueByte
AccessDeniedFixed

AWS S3 PutObject fails with AccessDenied despite an allow policy

By Haneul SeoUpdated August 25, 20264 min

Hi, it's BlueByte. If a PutObject keeps failing with AccessDenied and you can see s3:PutObject right there in the policy, adding more allows will not help — something is denying the write, and a deny always wins. Let's find the one policy that actually decides, then fix that instead of piling on permissions and hoping.

What AccessDenied is really saying here

The call fails even though the caller has s3:PutObject:

An error occurred (AccessDenied) when calling the PutObject operation:
Access Denied

In AWS an allow can be overridden. Something in the evaluation chain is denying the write, and another allow will not undo a deny. So the job is not "grant more" — it is to find which policy decides and correct that one.

Why an allow can still be overruled

A request succeeds only if there is an allow and no deny anywhere it is evaluated — the identity policy, the bucket policy, any permission boundary, and the organization's Service Control Policies (SCPs). The usual reasons a valid identity allow still fails:

  • The caller is in another account and the bucket policy does not grant it — an identity allow never crosses an account boundary by itself.
  • An explicit Deny exists somewhere — for example a bucket policy that denies unless aws:SecureTransport is true (blocking plain HTTP), or an SCP that denies the action.
  • The write uses an ACL like bucket-owner-full-control, but the bucket has Object Ownership = Bucket owner enforced, which disables ACLs entirely.
  • The KMS key protecting the bucket denies the caller kms:GenerateDataKey, so the encrypted write fails even though S3 would allow it.
  • The key or bucket in the request does not match the policy's Resource.

Ask the simulator which statement decides

You do not have to read four policies in your head — ask the IAM policy simulator which statement wins:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111122223333:role/uploader \
  --action-names s3:PutObject \
  --resource-arns arn:aws:s3:::my-bucket/path/key

The result names the allowing or denying statement. If it says allowed but the real call still fails, the deny is in the bucket policy or an SCP the simulator did not include — check those next. Add --debug to the failing CLI call to see the exact resource it targeted and whether a KMS step is involved.

Fix the specific decider

  1. Cross-account caller — add the principal to the bucket policy explicitly:
{ "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/uploader" },
  "Action": "s3:PutObject", "Resource": "arn:aws:s3:::my-bucket/*" }
  1. Explicit deny — find and narrow it. A common one denies non-TLS requests, so make sure your client uses HTTPS.

  2. ACL write blocked by ownership — drop the ACL and rely on the bucket-policy grant, or change Object Ownership away from "Bucket owner enforced" only if you truly need ACLs.

A real case: a write that crossed accounts

An uploader role in account B writes to a bucket in account A and gets AccessDenied, even though the role has s3:PutObject. The simulator against the role says "allowed" — which points away from the identity policy. Reading account A's bucket policy, there is no statement granting account B; the identity allow never crossed the account boundary. You add an explicit Allow for the account B role to the bucket policy, and the write succeeds. Nothing about the role changed — the missing grant was on the resource side all along.

Confirm the object actually landed

Retry the exact write, then check it is really there:

aws s3api put-object --bucket my-bucket --key path/key --body ./file
aws s3api head-object --bucket my-bucket --key path/key

A successful head-object means the write went through under the corrected policy — not just that the error message changed.

Keep it from recurring

Prefer bucket-policy grants over ACLs so ownership settings cannot silently block writes, keep SCP denies documented, grant kms:GenerateDataKey on the key when the bucket is encrypted, and run the simulator while you set up any new cross-account access rather than discovering the deny in production.

How this differs from a public-access or Region error

AccessDenied on GetObject from a browser is usually Block Public Access — a different setting. And a NoSuchBucket or a 403 from the wrong Region is not a policy problem at all — check the endpoint and bucket name before you touch policies. Next time a write is denied with the allow clearly present, start at the deny, not the allow.

Related questions

My IAM policy clearly allows s3:PutObject. Why still denied?

An allow is necessary but not sufficient. A bucket policy, permission boundary, or SCP with an explicit deny, or a missing cross-account allow in the bucket policy, overrides it.

It broke right after I enabled Block Public Access. Related?

Possibly. Block Public Access and Bucket owner enforced disable ACLs. If your uploader relied on an ACL, switch to a bucket policy grant instead.

The policy simulator says allowed but the real call is denied.

The deny is in a policy the simulator did not evaluate — usually the bucket policy or an SCP. Check those directly, and add --debug to the CLI call to confirm the exact resource.

Does the object key matter?

Yes. If the bucket policy scopes Resource to a prefix, a write to a different key is denied. Make sure the key you write matches the policy's Resource.

The bucket is encrypted with KMS. Could that cause AccessDenied?

Yes. A PutObject to a KMS-encrypted bucket also needs kms:GenerateDataKey on the key. If the key policy denies the caller, the write fails with AccessDenied even when S3 permissions are correct.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker