S3 Access Denied: Fix 403 and the Other 1am Errors

Logeshwaran.C

When Amazon S3 tells you Access Denied, it is not telling you the file is missing, and it is usually not telling you your IAM policy is wrong either. An S3 request has to clear five independent gates — the caller's identity policy, the bucket policy, Block Public Access, object ownership, and the KMS key policy — and a denial at any one of them produces the exact same three words. So stop rereading the message. Read the error code instead, then open CloudTrail, which records the specific reason S3 refused to put in the response. That is the whole trick, and everything below is how to run it in about ten minutes.

⚡ Quick Answer

403 AccessDenied on an object you can see in the console → you are almost certainly missing s3:ListBucket on the bucket ARN, or the object is SSE-KMS encrypted and you lack kms:Decrypt.

403 SignatureDoesNotMatch → credentials or clock, not permissions. Check for a trailing newline in the secret key first.

301 PermanentRedirect → wrong region in the client.

A CORS error in the browser console → run the same URL through curl before touching the CORS rules. Half the time it is a 403 wearing a disguise.

If you only read one section, read the triage table, then jump to your code.

New to AWS? Every term below is explained the first time it appears, so you can read straight through — but the whole thing lands better if you have met the model first, in what Amazon S3 actually is.

Saturday night, and three words

Jake's repair-booking page had been running for six weeks and he had comfortably stopped thinking about it. A customer picks a slot, uploads a photo of the cracked screen so he knows which part to order, and that photo lands in S3. It cost him almost nothing and had already saved him a dozen phone calls.

On Saturday evening it quietly stopped accepting photos. Not loudly — the page loaded, the form submitted, and the upload just spun forever. By Sunday morning four bookings had arrived with no photo attached, one customer had given up entirely, and Jake had ordered the wrong screen for another: $34 of stock he cannot return, because he guessed the model from a phone call.

The server log had exactly one useful line in it. AccessDenied.

Nothing else. No key name, no policy name, no hint as to which of the five separate documents involved in that decision had said no.

"That's the whole message?" Jake said. "It knows what's wrong and it just doesn't say?"

"It knows exactly what's wrong," Ethan said. "And it won't tell you over the wire, because telling you means telling everyone. If S3 replied the bucket policy blocked you because your request didn't arrive over TLS, it would be handing a stranger a labeled map of your security controls, one failed request at a time. So it says the same three words to you and to them."

"Then how does anybody ever fix anything?"

"Because it does write down the real reason. Just somewhere you haven't looked yet."

Where it writes the reason down is CloudTrail — the AWS audit log that quietly records every API call made in your account, including the failed ones and the reason they failed. It is dramatically more specific than anything that comes back over the wire. It will name the key, or say the request was denied by a bucket policy, or point at a rule two levels above your account.

Almost nobody looks there first. They open the policy editor instead and start changing things they do not yet understand, at midnight, on a system that is currently down. That habit is what turns a five-minute problem into a lost weekend — and it is worth saying plainly, because the instinct to do something is strongest at exactly the moment it does the most damage.

"So the first thing I do," Jake said, "is nothing."

"The first thing you do is read. That's not nothing, it's just not typing."

The rest of this post is arranged the way you actually meet these errors — by symptom. Find the code you are staring at, go to that section, skip everything else. Jake's spinner comes back at the end, along with which of the five gates turned out to be responsible, because the answer is not the one he was expecting.

First: read the code, not the sentence

Every S3 error response carries a machine-readable <Code> element, and the SDKs surface it as an error code on the exception object. That code is the single most useful piece of information you have, and it is routinely thrown away by application logging that captures only the human-readable message.

If your logs are showing you sentences instead of codes, fix that before you fix anything else. In Python, the code is at e.response["Error"]["Code"]. In the JavaScript SDK v3 it is err.name. In Java it is e.awsErrorDetails().errorCode(). On the command line, add --debug and read the raw XML.

Error code HTTP What it actually means
AccessDenied 403 One of five gates said no. Could also be a missing object you are not allowed to know about.
SignatureDoesNotMatch 403 Credentials or the signed string are wrong. Not a permissions problem.
InvalidAccessKeyId 403 That access key does not exist. Deleted, rotated, or from another account entirely.
RequestTimeTooSkewed 403 Your clock is more than 15 minutes off.
NoSuchKey 404 The key is not there. The key is rarely what you think it is.
NoSuchBucket 404 Typo, deleted bucket, or you are pointed at the wrong account.
PermanentRedirect 301 Right bucket, wrong region endpoint.
AuthorizationHeaderMalformed 400 Region mismatch, and it usually names the correct region for you.
AccessControlListNotSupported 400 You tried to set an ACL on a bucket where ACLs are disabled. Every new bucket since 2023.
InvalidObjectState 403 The object is in an archive storage class and has to be restored before you can read it.
SlowDown 503 Too many requests against one partition. Back off and retry.
AllAccessDisabled 403 Rare and serious. AWS has disabled the bucket, usually for billing or abuse. Open a support case.

 How this was tested

Checked against live AWS behavior and current documentation on 8 August 2026, not copied from older tutorials:

  • Default settings on a newly created bucket — Block Public Access on, object ownership set to bucket owner enforced, ACLs unavailable.
  • The 403-instead-of-404 behavior reproduced by removing s3:ListBucket from a role and requesting a key that does not exist.
  • The billing exemption for denied requests confirmed as limited to callers outside the bucket owner's account or organization — requests from inside your own account still bill.
  • The failure that cost the most time in practice: a working policy, a working key, and a KMS key policy in a different account that had never granted kms:Decrypt.

AccessDenied: the five gates

"Think of it like the service entrance at a shopping mall," Ethan said, when Jake asked why one file needed five separate permissions. "There's the guard at the gate who checks whether you are allowed in the building. There's a second guard outside the specific shop, who has his own list. There's a mall-wide rule that says no member of the public gets past this point, no matter what either guard thinks. There's an old sign-in book nobody uses any more but which still technically exists. And if the thing you came for is in a safe, there's the person who holds the safe key — who does not work for the mall at all, and has never heard of you."

"And any one of them can turn me away."

"Any one. And they all say the same three words when they do."

Formally: a request to S3 is allowed only if something explicitly allows it and nothing explicitly denies it. Those allows and denies live in separate documents, owned by different teams, sometimes in different AWS accounts entirely. Here they are in the order they most often break.

Gate 1: the caller's identity policy — and the ARN trap

This is the IAM policy attached to the user or, far more likely, the role your code is running as — a role being a bundle of permissions that a machine or a service borrows for a few hours at a time, so that nobody has to ship a password inside the application. It is also where the single most common S3 mistake in the world lives, and it is a one-character mistake.

s3:ListBucket is a permission on the bucket. s3:GetObject is a permission on the objects inside it. They take different ARNs — an ARN, or Amazon Resource Name, is the globally unique address AWS gives to every single thing it hosts, which is why they are so long — and if you write only one of the two, half your application breaks in a way that looks like something else entirely.

"Resource": [
  "arn:aws:s3:::my-bucket",    // for ListBucket, GetBucketLocation
  "arn:aws:s3:::my-bucket/*"  // for GetObject, PutObject, DeleteObject
]

Miss the second line and every download fails. Miss the first and aws s3 ls and aws s3 sync fail while single-object downloads work perfectly — which is why the bug survives code review, ships, and then surfaces the first time someone runs a sync.

‍♂️ Jake's Reality Check

"Hang on. The file isn't there, and instead of telling me it isn't there, it tells me I'm not allowed? That's S3 lying to me."

It is not lying. It is refusing to confirm. Without s3:ListBucket you have no permission to learn which keys exist — and a 404 would tell you exactly that. Someone probing your bucket for backup.sql would learn whether it was there, one guess at a time, without ever being allowed to read it. So S3 gives the same answer to both questions.

Grant s3:ListBucket on the bucket ARN and the identical request starts returning honest 404s. Ethan's view, and he is not neutral about it: this single behavior has probably burned more engineer-hours than everything else on this page put together, and it is the first thing to suspect whenever a 403 makes no sense at all.

One last thing about this gate, because it is invisible from the S3 console and therefore gets missed. Two rules can quietly put a ceiling on any policy: a service control policy, which an organization's management account applies to every account beneath it, and a permissions boundary, which does the same job for a single role. Neither ever grants anything — they only subtract — so a policy can read as perfectly generous and still be capped from above by something you cannot see from here.

Gate 2: the bucket policy, where explicit denies live

The bucket policy is attached to the bucket rather than to you, and it is the usual home of the condition that breaks a request which "should" work. An explicit Deny beats every Allow anywhere else, so a single condition here overrides the most generous IAM policy you can write.

The three that catch people repeatedly:

  • TLS enforcement. A deny on aws:SecureTransport: false is good practice and standard in most hardening guides. It also breaks any internal client still calling the HTTP endpoint, and the resulting error says nothing about TLS.
  • VPC endpoint restriction. A VPC is your own private network inside AWS, and a VPC endpoint is a private door from it to S3 that skips the public internet. A deny unless aws:SourceVpce matches one specific door means the bucket works from inside your network and fails from your laptop, from CI, and from Lambda functions nobody remembered to attach to the VPC.
  • Encryption enforcement on upload. A deny on PutObject unless s3:x-amz-server-side-encryption is present fails uploads from any client that does not set the header, which includes a lot of older library code.

Gate 3: Block Public Access

Four independent switches, on by default for every bucket created since April 2023, and they operate above your bucket policy. If a request is anonymous or comes from another account without a policy that permits it, this is what stops it — and it stops it regardless of what your bucket policy says, which is precisely the point.

The switches split into two pairs: two govern ACLs, and two govern bucket policies that grant public access. If you have written a perfectly correct public-read bucket policy and it does nothing at all, this is why. There is also an account-level version of the same four settings, so a bucket can look correct while the account overrides it.

⚠️ Before you turn any of this off

Do not relax Block Public Access on a bucket that also holds private data. Almost every S3 story that ends up in the news is one bucket doing two jobs. If you need public files, make a second bucket that contains nothing else, and read what the 2026 breaches actually had in common before you decide you need it at all. For a website or app, the better answer is a private bucket behind CloudFront — Amazon's CDN, the caching layer that serves your files from somewhere near the visitor — using origin access control, a setting that lets CloudFront read the bucket and nobody else. Safer and usually cheaper than public reads.

Gate 4: object ownership and ACLs — probably not your problem now

ACLs are the original S3 permission system, from before IAM policies existed, and they are the reason so much old advice tells you to run --acl public-read or --acl bucket-owner-full-control.

On any bucket created since April 2023, ACLs are disabled by default through the bucket owner enforced ownership setting. Every object is owned by the bucket owner, permissions come exclusively from policies, and attempting to set an ACL fails with AccessControlListNotSupported rather than being quietly ignored.

This is a genuine improvement — the cross-account upload problem where the uploader kept ownership and the bucket owner could not read their own objects simply cannot happen any more. But it means a large fraction of the S3 answers you will find online now produce an error on the first command, and the error name does not obviously translate to "this advice is three years out of date."

Gate 5: the KMS key, which is not an S3 setting at all

This is the one that survives all the obvious checks, and the one worth memorizing.

KMS is the AWS Key Management Service — a separate service whose entire job is holding encryption keys and deciding who is allowed to use them. If an object is encrypted with SSE-KMS using a customer managed key, meaning one you created rather than the default key AWS manages on your behalf, reading it requires kms:Decrypt on that key, and writing one requires kms:GenerateDataKey. Those permissions live in the KMS key policy and, if the key belongs to another account, in that account's policy as well. You can hold full S3 access and still be refused, and the error will say AccessDenied with no mention of KMS whatsoever.

✅ The fastest way to find the guilty gate

Open CloudTrail, filter Event name to the failing call, and read the errorMessage field on the event. CloudTrail records a far more specific reason than the API returns over the wire — it will name the KMS key, or say the request was denied by a bucket policy, or point at a service control policy. Do this before you edit a single policy document. It turns guesswork into a lookup.

The 403s that are not about permissions

Three error codes share the 403 status with AccessDenied and have nothing to do with policies. Recognizing them saves you from editing IAM for an hour over a stray newline character.

SignatureDoesNotMatch

Your request was signed, and the signature S3 computed does not match the one you sent. Since the signature covers the secret key, the region, the timestamp, the HTTP method, the path and several headers, any of those being wrong produces the same message.

Ethan's advice here is deliberately unglamorous, and he gives it before looking at any code: check the key for a trailing newline. He has watched three separate people lose an afternoon to a secret key that was pasted out of a chat message with an invisible character on the end — the string looks identical, the environment variable holds it faithfully, and the signature comes out wrong every time. One of them rewrote an entire authentication layer first.

So work down this list, which is roughly in order of how often each one turns out to be the answer:

  1. A trailing newline or space in the secret key. Copying a key out of a terminal or a chat message picks up whitespace, and environment variables preserve it silently. This is the single most common cause.
  2. A truncated key. Secret access keys are 40 characters. Count them.
  3. Region mismatch. The signature includes the region, so a client defaulting to us-east-1 against a bucket elsewhere can fail here rather than with a clean redirect.
  4. Double-encoded key names. Object keys containing spaces, plus signs, or non-ASCII characters get encoded once by your code and again by the SDK. If the failing keys all have something odd in the name, this is it.
  5. A proxy rewriting headers. Corporate proxies, some API gateways, and a few load balancer configurations modify headers that are part of the signed string.

InvalidAccessKeyId

Simpler: the access key ID does not exist in AWS at all. It was deleted, it was rotated, it belongs to a different account, or a stale profile in ~/.aws/credentials is winning over the environment variables you thought were in effect.

That last one deserves care, because credential resolution order surprises people constantly. Environment variables beat the shared credentials file, which beats the instance or container role. Run aws sts get-caller-identity and confirm you are who you think you are before debugging anything else. If that command returns a different ARN than expected, you have already found the bug.

RequestTimeTooSkewed

The clock on the machine making the request is more than fifteen minutes away from Amazon's. Signed requests carry a timestamp so that a captured request cannot be replayed later, and outside that window S3 rejects them.

In practice this means virtual machines resumed from a snapshot, containers running after a laptop woke from sleep, Raspberry Pis with no real-time clock, and desktops with a dying CMOS battery. Fix the clock properly with NTP or chrony rather than nudging it by hand, because a machine whose clock drifts once will drift again.

NoSuchKey and NoSuchBucket, when the thing is clearly right there

S3 has no folders. What looks like a directory tree in the console is a flat list of keys where somebody put slashes in the names, and the console draws the tree for you. So the key is not report.pdf sitting inside a folder — the key is 2026/invoices/report.pdf, in full, every time.

Once you internalize that, the usual causes of a surprising NoSuchKey are easy to check:

  • A leading slash. /uploads/file.png and uploads/file.png are different keys. String joins produce this constantly.
  • Case. Keys are case sensitive, always, on every platform.
  • Trailing whitespace in a key that came from a CSV, a form field, or a filename someone typed.
  • URL encoding applied twice, so my file.pdf became my%2520file.pdf.
  • A delete marker. On a versioned bucket, deleting an object does not remove it — it adds a marker on top. A plain GET returns 404 while the data sits underneath, still recoverable, until a lifecycle rule — an automatic housekeeping policy that moves or deletes objects once they reach a certain age — finally expires it.

NoSuchBucket is blunter. Bucket names are globally unique across all AWS accounts, so the name you want may exist and simply belong to someone else. Check the region, check the account with aws sts get-caller-identity, and check for the hyphen or the environment suffix that differs between your staging and production names.

The 301 that eats an hour

Bucket names are global. Buckets are regional. That mismatch produces PermanentRedirect when a client configured for one region talks to a bucket in another.

Its sibling AuthorizationHeaderMalformed is the more helpful of the two, because the message usually names both the region you signed for and the region S3 expected. When you see it, you have been handed the answer — put that region in your client configuration explicitly rather than relying on a default from an environment variable, a config file, or an SDK's built-in fallback to us-east-1.

This is also where a nasty class of intermittent bug lives. If some requests succeed and others fail with a redirect, look for code paths that build a client in more than one place, or a container that inherits AWS_REGION in one environment and not in another. Find the bucket's real region once and stop guessing:

aws s3api get-bucket-location --bucket my-bucket

A response of null is not an error, incidentally. It means us-east-1, for historical reasons that date back to S3 predating the concept of regions having names.

CORS: the error that is often not a CORS error

Ethan calls this one the cruelest error in web development, and he will not be talked out of it. Here is why.

When a browser makes a cross-origin request and does not receive the right headers back, it blocks the response and reports a CORS error. It reports the same CORS error whether S3 returned your file without CORS headers configured, or refused the request outright with a 403. The browser cannot show you the real response, because it is not allowed to read it. So the console tells you about CORS, and you spend the next hour writing CORS rules for a problem that is actually authorization.

Always test with curl first. curl has no same-origin policy and will show you the truth:

curl -i "https://my-bucket.s3.us-east-1.amazonaws.com/path/to/file.png"

If that returns 200, your problem is genuinely CORS configuration. If it returns AccessDenied, close the CORS editor and go back to the five gates.

When it really is CORS, the rules that break most often are mundane: AllowedOrigins must match the origin exactly, including scheme and port and excluding any trailing slash; a preflight OPTIONS request needs the method it is asking about listed in AllowedMethods; and if your JavaScript reads the ETag header — the fingerprint S3 returns for a stored object, used for cache checks and upload verification — it has to be listed in ExposeHeaders or the browser hides it from you.

One thing CORS never does is grant access. It is a browser-side instruction, not a permission. A public CORS policy on a private bucket changes nothing at all.

Presigned URLs that die early

A presigned URL is an ordinary-looking link with a signature baked into the end of it, and it grants whoever holds that link temporary access to one private object — no AWS account, no login, nothing to install. It is how you let a customer download their invoice without making the bucket public.

So: you generated one with a seven-day expiry. It stopped working after an hour. Nothing is broken, and the expiry parameter was not ignored.

A presigned URL cannot outlive the credentials that signed it. If your code runs on Lambda, ECS, EC2 or anything else using an assumed role, it signs with temporary credentials that expire on their own schedule — commonly one hour. When the session token dies, so does every URL it signed. The seven-day maximum applies only to long-lived IAM user credentials.

Two more properties worth knowing before you ship a feature on presigned URLs:

  • Permissions are checked when the URL is used, not when it is created. Generating a URL for an object you cannot read produces a perfectly valid link that fails on click. It also means revoking the signer's access instantly invalidates every outstanding link, which is a useful emergency lever.
  • KMS applies to the person who signed. If the object is SSE-KMS encrypted, the signing identity needs kms:Decrypt, or the URL returns AccessDenied while the plain S3 permissions look immaculate.

InvalidObjectState: the file is there, but it is asleep

If a lifecycle rule moved objects into Glacier Flexible Retrieval or Deep Archive, a normal GET fails with InvalidObjectState. The object exists, it is listed, its metadata is readable, and its bytes are not immediately available. You have to issue a restore request and wait — minutes to hours depending on the tier and the retrieval option you pick.

This one bites hardest during incidents, because the backup you urgently need is exactly the kind of data somebody sensibly moved to archive storage to save money. If your disaster recovery plan involves reading from Deep Archive, the restore time is part of your recovery time whether you planned for it or not. Glacier Instant Retrieval, by contrast, reads immediately at a higher storage price. Choosing between them is a real trade-off, not a detail.

503 SlowDown, and advice that expired in 2018

A prefix here means the leading part of a key — everything up to the last slash, which is the thing the console draws as a folder. S3 supports at least 3,500 write and 5,500 read requests per second per partitioned prefix, and it splits busy prefixes across more partitions automatically as your traffic grows. A burst of SlowDown usually means you outran a partition faster than S3 could split it, and the correct response is retry with exponential backoff — waiting a little longer before each attempt instead of hammering harder — which every AWS SDK does by default.

What you should not do is follow the old advice to prefix your keys with a random hash so they scatter across partitions. That guidance was genuinely correct once, and S3's automatic scaling made it obsolete in 2018. Following it today buys you nothing and costs you real things: keys you cannot sort, prefixes you cannot list usefully, and lifecycle rules you cannot target by date.

If you legitimately need sustained rates well beyond those numbers, spread work across several prefixes that mean something — by date, by tenant, by region — and let each one scale on its own.

Four things that changed, and why old answers now fail

S3 launched in 2006 and there are twenty years of answers about it on the internet. Most were correct when written. Four changes since then quietly invalidated a great deal of them, and knowing which is which saves you from debugging advice instead of code.

Change When Advice it invalidated
Automatic request-rate scaling 2018 "Put a random hash at the front of every key."
Strong read-after-write consistency Dec 2020 "Sleep a few seconds after writing, S3 is eventually consistent."
Block Public Access on, ACLs off by default Apr 2023 "Just run --acl public-read."
No charge for outside denied requests 2024 "A stranger can run up your S3 bill with 403s."

That last row needs its caveat stated plainly, because a lot of write-ups dropped it. The exemption covers requests initiated from outside the bucket owner's AWS account or organization. A misconfigured service inside your own account, stuck in a retry loop against a bucket it cannot read, still generates billable requests. The change protects you from strangers, not from yourself.

The consistency change deserves a word too, because it removed an entire category of defensive code. S3 has been strongly read-after-write consistent for all operations since December 2020: a successful PUT is immediately visible to the next GET or LIST, including overwrites and deletes. If you inherit a codebase with retry loops or sleeps guarding reads after writes, that code is not protecting you from anything. It is protecting you from 2019.

Ten minutes, in order, before you wake anyone up

  1. Get the error code, not the message. If your logs do not carry it, that is bug number one.
  2. Confirm who you are: aws sts get-caller-identity. A surprising number of incidents end here.
  3. Confirm the region: aws s3api get-bucket-location --bucket my-bucket.
  4. Confirm the clock if you saw any 403 at all. It takes five seconds to rule out.
  5. Reproduce with the CLI using the same role. If the CLI works and your app does not, the problem is in your code or its credential chain, not in AWS.
  6. Read CloudTrail for the failing call and take the errorMessage literally. This is the step people skip, and it is the step that usually ends the investigation.
  7. Check the KMS key if the bucket uses SSE-KMS, including whether the key lives in another account.
  8. Only then open the policy editor.

The ordering matters more than the contents. Steps one through six are all read-only, take under a minute each, and cannot make the outage worse. Editing an IAM policy at 1am, without knowing which document denied the request, can.

The rest of the codes, in one table

The sections above cover the errors that generate the 1am pages. These are the rest of the ones you will actually meet — mostly around multipart uploads, bucket creation and malformed configuration — with the cause rather than a restatement of the name.

Code HTTP Cause, and what to do
EntityTooLarge 400 A single PUT above the 5 GB limit. Switch to a multipart upload, where the file is split into numbered parts, sent separately and reassembled by S3 — the high-level CLI and SDK transfer helpers do this for you automatically.
InvalidBucketName 400 The name breaks the rules: 3 to 63 characters, lowercase letters, digits, hyphens and dots only. No underscores, no capitals, nothing shaped like an IP address.
BucketAlreadyExists 409 Somebody else owns that name. The namespace is global across every AWS account on earth, which is why generic names have been gone for a decade.
BucketAlreadyOwnedByYou 409 You already created it. Usually a re-run of an infrastructure script that does not check first. Safe to treat as success.
BucketNotEmpty 409 Delete refused. On a versioned bucket this persists after you think it is empty, because old versions and delete markers still count as objects.
NoSuchUpload 404 The multipart upload ID is gone — already completed, aborted, or cleaned up by a lifecycle rule. Start the upload again.
InvalidPart / InvalidPartOrder 400 Completing a multipart upload with an ETag that does not match a stored part, or with part numbers out of ascending order. Re-send the exact ETags S3 returned, in order.
RequestTimeout 400 The connection went idle mid-upload for too long. Almost always a slow or flaky network rather than anything in your configuration.
OperationAborted 409 A conflicting operation is already running on that resource. Wait and retry; this is the one error where doing nothing is the fix.
PreconditionFailed 412 An If-Match or If-Unmodified-Since condition did not hold, meaning the object changed under you. In a conditional-write design this is a signal, not a bug.
InvalidRange 416 You asked for bytes past the end of the object. Check the object size before computing ranges, especially in resumable download code.
MalformedXML 400 A configuration document — lifecycle, CORS, replication, website — did not validate against its schema. The order of elements matters, not just their presence.
MalformedPolicy 400 The bucket policy is invalid. Most often a principal that does not exist yet, or a role ARN with a typo — S3 validates that principals resolve.
MethodNotAllowed 405 That verb is not valid on that resource. Commonly a POST or DELETE aimed at a static website endpoint, which only serves reads.
NotImplemented 501 A header or feature the endpoint does not support. If you are pointed at an S3-compatible service rather than AWS, this is the error that tells you the compatibility is partial.
ExpiredToken / InvalidToken 400 The temporary session credentials aged out. Long-running processes that fetch credentials once at startup and cache them forever produce this on schedule.
KMS.KeyDisabledException 409 The KMS key exists but is disabled, or is scheduled for deletion. Re-enable it; if deletion completed, the objects are unrecoverable.
AccountProblem 403 Something is wrong with the AWS account itself, usually billing. No amount of policy editing will help. Open a support case.

Two of these carry a warning worth spelling out. KMS.KeyDisabledException becoming permanent is one of the few genuinely unrecoverable states in AWS — if a key completes its deletion schedule, every object encrypted with it is mathematically gone, and no support case reverses that. And BucketNotEmpty on a versioned bucket is the error that teaches most people versioning exists, usually while they are trying to clean up and wondering why storage costs never fell.

Frequently asked questions

Why does S3 say Access Denied when the file definitely exists?

Because existence is not the question S3 is answering. A request has to clear several independent gates — the caller's IAM policy, the bucket policy, Block Public Access, object ownership settings, and the KMS key policy if the object is encrypted with a customer managed key. A deny in any one of them ends the request, and the error text is identical no matter which gate stopped it. Start with CloudTrail, which records the specific reason, rather than guessing.

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

They apply to different resources. s3:ListBucket is granted on the bucket itself, written as arn:aws:s3:::my-bucket, and it lets you see what keys exist. s3:GetObject is granted on the objects inside it, written as arn:aws:s3:::my-bucket/*, and it lets you download one. A policy that lists only the bucket ARN silently fails every download, and a policy that lists only the object ARN breaks every listing and every sync.

Why do I get 403 instead of 404 for a missing object?

This is deliberate. If you do not hold s3:ListBucket on the bucket, S3 refuses to confirm whether a key exists, because a 404 would leak that information to someone with no permission to enumerate the bucket. So a missing object returns AccessDenied instead of NoSuchKey. Grant s3:ListBucket on the bucket ARN and the same request starts returning an honest 404.

I have s3:GetObject but still get Access Denied. What else could it be?

The usual culprits, in order: an explicit Deny in the bucket policy such as a condition requiring TLS or a specific VPC endpoint; a service control policy or permissions boundary above your role; a VPC endpoint policy if the caller sits in a private subnet; and most commonly, SSE-KMS encryption. Decrypting an object needs kms:Decrypt on the key as well as s3:GetObject on the object, and the KMS key policy is a separate document from anything in S3.

What causes SignatureDoesNotMatch in S3?

Almost always the secret key or the string being signed, not the permissions. Check for a trailing newline or space copied into the environment variable, a secret key that was truncated, a region mismatch because SigV4 signs the region into the request, a proxy or load balancer rewriting headers, or a key name containing spaces or plus signs that got encoded twice. If credentials are correct and the clock is right, suspect the key name encoding next.

What does RequestTimeTooSkewed mean?

Your machine's clock is more than fifteen minutes away from Amazon's. Every signed request carries a timestamp and S3 rejects requests outside that window to prevent replay attacks. It shows up constantly on virtual machines resumed from a snapshot, on containers after a laptop wakes from sleep, and on hardware with a dying CMOS battery. Fix the clock with NTP or chrony rather than working around it.

Why does S3 return a 301 PermanentRedirect?

The bucket lives in a different region than the endpoint you are calling. Bucket names are global but buckets themselves are regional, so a client configured for one region talking to a bucket in another gets redirected. The related AuthorizationHeaderMalformed error is more helpful, because it usually names both the region you signed for and the region S3 expected. Set the region explicitly in your client instead of relying on a default.

Is a CORS error an S3 permissions problem?

Sometimes, and that ambiguity is what wastes the hour. A browser reports a blocked cross-origin request the same way whether S3 returned the object without CORS headers or refused the request with a 403. Test the exact URL with curl first. If curl succeeds, it is genuinely CORS configuration. If curl returns AccessDenied, the CORS rules are a red herring and the problem is authorization.

Why did my presigned URL expire early?

Because it inherited the lifetime of the credentials that signed it. A URL signed with temporary credentials from an assumed role dies when that session token expires, typically after an hour, no matter what expiry you requested. Only long-lived IAM user credentials support the full seven-day maximum. Permissions are also evaluated when the URL is used, not when it is created, so revoking the signer's access kills every outstanding link.

What does 503 SlowDown mean, and do I still need random key prefixes?

SlowDown means you exceeded the request rate for a partition and S3 is asking you to back off while it splits that partition. You no longer need randomized key prefixes. Since 2018 S3 scales automatically to at least 3,500 write and 5,500 read requests per second per partitioned prefix, and it adapts to sustained traffic. Keep retries with exponential backoff enabled, which every AWS SDK does by default, and use readable key names.

Does S3 charge me when someone else gets Access Denied on my bucket?

No, not since 2024. Requests that return HTTP 403 AccessDenied are free to the bucket owner when they come from outside the owner's AWS account or organization, so a stranger hammering your bucket no longer generates a bill. The important limit is that this only covers outside requests. A misconfigured application inside your own account retrying thousands of denied requests is still billable.

Why does acl public-read fail on my new bucket?

Because ACLs are switched off on buckets created since April 2023. New buckets default to the bucket owner enforced ownership setting, which disables ACLs entirely, and any attempt to set one returns AccessControlListNotSupported. This is why older tutorials no longer work. Grant public read through a bucket policy instead, after turning off the relevant Block Public Access setting deliberately.

Is S3 eventually consistent? Do I need to wait after a write?

No. S3 has been strongly read-after-write consistent for all operations since December 2020. A successful PUT is immediately visible to any subsequent GET or LIST, including overwrites and deletes. Any advice telling you to sleep for a few seconds, retry in a loop, or write to a random key to dodge stale reads predates that change and is solving a problem that no longer exists.

How do I make a bucket public safely?

Decide first whether you actually need a public bucket, because a private bucket behind CloudFront with origin access control serves the same files without exposing the bucket. If you genuinely need public reads, create a separate bucket that holds only public content, turn off the two Block Public Access settings covering public policies, and attach a bucket policy granting s3:GetObject on a specific prefix. Never relax public access on a bucket that also holds private data.

What Jake's spinner turned out to be

Nobody had touched Jake's booking page in six weeks. That was true, and it was also why it took so long to find.

What had changed was one folder over. On Saturday afternoon the friend who set the whole thing up had spent an hour tidying up security — sensible work, the kind everybody should do — and among other things had switched the bucket over to encryption with a key he created himself, rather than the default one Amazon manages. It is a genuine improvement. It is also gate five.

From that moment, saving a photo needed permission on the key as well as permission on the bucket. The booking page had the second and not the first. So the upload was refused by a service the developer had not been thinking about, on a resource that is not part of S3 at all, and S3 reported it with the same three words it uses for everything.

CloudTrail had said so in one line, on Saturday evening, hours before anyone looked. It named the key.

"So the answer was written down the whole time."

"It usually is. That's the part worth remembering — not the fix."

The fix itself took four minutes: one statement added to the role, granting kms:GenerateDataKey on that key. Finding it had taken most of a Sunday, and cost a $34 screen for a phone that turned out to be a different model entirely. Jake now checks CloudTrail first. He describes this as having learned something expensive, which is the only kind that sticks.

Where to go next

If you got here because something broke and you are not entirely sure what S3 is doing in your architecture in the first place, start at the beginning and come back — the errors make far more sense once the model does.

And the honest closing thought, twelve hours after the fact rather than at 1am: nearly every S3 error in this post is S3 working exactly as designed. The 403 instead of a 404 is an information-leak defense. The silence about which gate denied you is deliberate. The disabled ACLs exist because the alternative caused years of accidental data exposure. It is a service that would rather be unhelpful to you for thirty minutes than helpful to an attacker for five seconds — and once you know where it keeps the real answers, thirty minutes becomes ten.

Note. Published 8 August 2026, part of the free AWS series. Almost every error here is S3 working exactly as designed, none of it means you haven't done something stupid, and it is all recoverable. Check CloudTrail, and go to bed, Period!

Related