Get started Dashboard
Troubleshooting ·

Fix 403 Forbidden (Access Denied) from Amazon S3

Explore with AI

403 Forbidden

A 403 Forbidden from Amazon S3 means AWS refused the request: a policy denied it, nothing allowed it, or the signature failed because of a bad clock or bad credentials. Read the error code and message in the response body first. Then check the usual causes in order: Block Public Access overriding a public bucket policy, a missing s3:ListBucket permission hiding a missing object, objects owned by another account, clock skew, and expired presigned URLs.

Ongoing incident? Our founding team can help you get your AWS app back up.
On this page

You requested an object or called the S3 API, and the answer was a bare 403 Forbidden. It might come from a browser hitting a public asset URL, an SDK call in your app, a presigned link or a CloudFront distribution in front of a bucket. The status code alone tells you almost nothing. S3 uses 403 for permission denials, for signatures that fail to verify, for requests signed with a wrong clock, and even for objects that don’t exist.

The fix depends on which of these you hit. This page shows how to get the real reason out of S3, then goes through the causes people hit most, each with a check that confirms it and the commands that fix it.

What 403 Forbidden means when S3 sends it

AWS documents an access denied (HTTP 403) as the result of an authorisation request that was explicitly or implicitly denied (S3 troubleshooting guide):

  • An explicit deny means some policy has a Deny statement for the action. A deny always wins.
  • An implicit deny means no policy has a Deny, but no policy has an Allow either. IAM denies by default, so a missing Allow is enough to get a 403.

S3 also returns 403 for failures that have nothing to do with policies. Its error code list maps these to 403 Forbidden (S3 error codes):

  • AccessDenied: a policy said no.
  • SignatureDoesNotMatch: the signature S3 calculated differs from the one you sent. Check the secret key and the signing method.
  • InvalidAccessKeyId: the access key ID doesn’t exist in AWS’s records.
  • RequestTimeTooSkewed: the request time is too far from the server’s time.

So the first job is to find out which kind of 403 you have.

Get the error code and the enhanced message

The response body is XML with a Code and a Message. A browser often hides it behind a blank page, so ask for it directly:

curl -i https://amzn-s3-demo-bucket.s3.amazonaws.com/path/to/object.png

From the CLI or an SDK, the exception carries the same code. Check which identity is actually making the call, because a wrong profile or role is a common reason a correct policy seems to have no effect:

aws sts get-caller-identity
aws s3api get-object --bucket amzn-s3-demo-bucket --key path/to/object.png /tmp/out

For requests inside the same account or the same AWS organisation, S3 adds context to the denial: the policy type that denied it, the reason, and for explicit denies the ARN of the policy. The same context lands in CloudTrail (S3 troubleshooting guide). The message looks like this:

User: arn:aws:iam::123456789012:user/MaryMajor is not authorized to perform:
s3:GetObject because no identity-based policy allows the s3:GetObject action

The phrase with an explicit deny in a ... policy points at a Deny statement. because no ... policy allows points at a missing Allow. Cross-account requests from outside your organisation get a generic Access Denied. So do denials from a VPC endpoint policy and requests to directory buckets.

Why S3 refuses the request: the usual causes, most common first

These are the causes people actually hit, in the order worth checking. Each has its own section below with the check that confirms it and the fix.

  1. Block Public Access overrides a public bucket policy. Anonymous requests fail even though the policy grants s3:GetObject to everyone.
  2. The object doesn’t exist and the caller lacks s3:ListBucket. S3 reports the missing key as 403.
  3. Another AWS account owns the objects. Your bucket policy doesn’t cover objects you don’t own.
  4. A policy outside the bucket denies it. An identity policy, SCP, permissions boundary, session policy or VPC endpoint policy says no.
  5. The client clock is wrong. Signed requests fail with RequestTimeTooSkewed.
  6. A presigned URL outlived its credentials. The link worked when it was made and fails later.
  7. CloudFront can’t read the bucket. The origin access setup or the bucket policy is wrong.

Decision path from an S3 403 response to its most likely cause, based on the error code in the body

graph TB A[S3 returns 403 Forbidden] --> B{Error code in body?} B -->|RequestTimeTooSkewed| C[Fix the client clock] B -->|SignatureDoesNotMatch| D[Check keys and how you sign] B -->|AccessDenied| E{Does the object exist?} E -->|no| F[Missing key hidden by no ListBucket] E -->|yes| G{Public or anonymous request?} G -->|yes| H[Check Block Public Access and policy] G -->|no| I[Read which policy type denied it] style C fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style F fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d

Block Public Access is overriding your public bucket policy

This is the classic case: the bucket has a bucket policy granting s3:GetObject to "Principal": "*", static website hosting is on, and anonymous requests still get 403.

S3 Block Public Access settings override bucket policies, access point policies and ACLs. Since April 2023, all four Block Public Access settings are on by default for new buckets. The settings exist at the account, bucket and access point level, and S3 applies the most restrictive combination (S3 troubleshooting guide). RestrictPublicBuckets rejects all anonymous (unsigned) calls to a bucket with a public policy. IgnorePublicAcls makes S3 ignore public ACLs, so access granted only by a public ACL is refused.

How to tell it’s yours: the request is anonymous (a browser or curl with no signature), and one of these returns true for a setting:

aws s3api get-public-access-block --bucket amzn-s3-demo-bucket
aws s3control get-public-access-block --account-id 123456789012

Fix, if the bucket really should be public (for example a static website):

  1. Decide that public is what you want. AWS warns that turning these settings off lets anyone on the internet read the bucket, and recommends CloudFront for public sites that should keep Block Public Access on (website permissions).
  2. Turn off the bucket-level settings. If the account-level settings are on, turn those off too, or the account settings still win.
aws s3api put-public-access-block --bucket amzn-s3-demo-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
  1. Add the public read policy. The Resource must end in /* so it covers objects:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::amzn-s3-demo-bucket/*"]
    }
  ]
}
aws s3api put-bucket-policy --bucket amzn-s3-demo-bucket --policy file://policy.json

If put-bucket-policy itself fails with AccessDenied and a message about BlockPublicPolicy, a Block Public Access setting is still on somewhere, most likely at the account level.

A missing object that shows up as 403

If the key doesn’t exist, the error S3 returns depends on whether the caller also has s3:ListBucket on the bucket. With it, S3 returns 404 Not Found. Without it, S3 returns 403 Access Denied (GetObject reference). The static website endpoint behaves the same way for anonymous visitors: a missing object is 404, but an object that exists without read permission is 403 (website permissions).

So a 403 on a key with a typo, a leading slash, the wrong case or a file that was never uploaded looks exactly like a permission problem.

How to tell it’s yours: list the prefix with a principal that can list the bucket.

aws s3 ls s3://amzn-s3-demo-bucket/path/to/
aws s3api head-object --bucket amzn-s3-demo-bucket --key path/to/object.png

If the key isn’t there, you have a 404 wearing a 403.

Fix:

  1. Correct the key in your app, or upload the missing object.
  2. If your code needs to tell missing from forbidden, grant s3:ListBucket on the bucket ARN, with s3:GetObject on the object ARN:
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::amzn-s3-demo-bucket"},
    {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*"}
  ]
}

Objects owned by another AWS account

This one catches teams that migrated assets from a bucket someone else set up, or that receive uploads from another account. The bucket is yours, the policy is right, and some objects still return 403.

Allow statements in a bucket policy apply only to objects owned by the bucket-owning account. If an external account owns an object, access to it can only be granted through object ACLs, although your bucket policy can still deny it (S3 troubleshooting guide). The default and recommended Object Ownership setting, Bucket owner enforced, disables ACLs and makes the bucket owner own everything. Buckets on Object writer or Bucket owner preferred can still hold foreign-owned objects.

How to tell it’s yours: check the ownership setting, then compare an object’s owner with the bucket’s owner.

aws s3api get-bucket-ownership-controls --bucket amzn-s3-demo-bucket
aws s3api get-object-acl --bucket amzn-s3-demo-bucket --key path/to/object.png
aws s3api get-bucket-acl --bucket amzn-s3-demo-bucket

If the object’s Owner.ID differs from the bucket’s canonical ID, the bucket policy does not grant access to that object.

Fix, recommended path:

  1. Move any grants that live in ACLs into the bucket policy first. AWS warns that disabling ACLs without migrating them causes 403s.
  2. Switch the bucket to Bucket owner enforced. The bucket owner then owns and controls every object:
aws s3api put-bucket-ownership-controls --bucket amzn-s3-demo-bucket \
  --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

If you must keep ACLs: have the writing account upload with the bucket-owner-full-control canned ACL, or copy the objects back into the bucket as the bucket owner. You can also re-download and re-upload the files from the owning account.

aws s3 cp ./object.png s3://amzn-s3-demo-bucket/path/to/object.png --acl bucket-owner-full-control

The denying policy lives outside the bucket

When the request is signed and the object exists, the denial comes from one of several policy types. The enhanced message names it. Each has its own fix (S3 troubleshooting guide):

  • Identity-based policy: the user or role has no Allow for the action, or has a Deny. Add the statement to the role.
  • Bucket policy (resource-based): a Deny with a condition such as source IP, VPC or encryption headers. Exclude the principal, for example with aws:PrincipalAccount and StringNotEquals.
  • Service control policy or resource control policy: set at the organisation level in AWS, outside your account’s IAM. The message includes the policy ARN for explicit denies.
  • Permissions boundary or session policy: caps what an assumed role can do, even if the role’s own policy allows more.
  • VPC endpoint policy: a request through a gateway endpoint needs an Allow there too. These denials don’t get the enhanced message, so suspect them when a call works from your laptop and fails from inside the VPC.

For cross-account access, both sides must allow it: the bucket policy and the caller’s IAM policy each need an explicit Allow.

Fix steps:

  1. Confirm the principal with aws sts get-caller-identity. If it’s a presigned URL, the permissions are those of the identity that signed it.
  2. Read the policy type out of the error message, or find the same event in CloudTrail.
  3. Test the change with the IAM policy simulator before you deploy it.
  4. If a bad bucket policy has locked everyone out, sign in as the account root user and delete the policy.

Requester Pays buckets

If the bucket has Requester Pays turned on, requests that don’t declare who pays get a 403. Add --request-payer requester in the CLI, or set the x-amz-request-payer: requester header from an SDK (S3 troubleshooting guide).

aws s3 cp s3://amzn-s3-demo-bucket/object.txt ./object.txt --request-payer requester

A wrong clock: RequestTimeTooSkewed

Everything worked for months, nothing changed in IAM, and now every signed request fails. Check the clock. The signed parts of a SigV4 request are valid within 15 minutes of the request’s timestamp (S3 SigV4 authentication). A host whose clock has drifted past that gets 403 with code RequestTimeTooSkewed (S3 error codes). Small drifts can also break presigned URLs with SignatureDoesNotMatch (presigned URLs).

How to tell it’s yours: the body says RequestTimeTooSkewed. Compare your clock with the Date header S3 sends back:

date -u
curl -sI https://s3.amazonaws.com | grep -i '^date'

Fix:

  1. Turn on time sync on the host. On a systemd Linux host:
timedatectl status
sudo timedatectl set-ntp true
chronyc tracking
  1. Retry. Requests signed after the clock is right go through. Requests signed before it don’t.

Presigned URLs that expired early

A presigned URL carries the permissions of whoever signed it, and it lasts only as long as both its expiry and the credentials behind it. From the S3 console you can set an expiry between 1 minute and 12 hours. The CLI and SDKs allow up to 7 days, and only with IAM user credentials. A URL signed with temporary credentials dies when they do. An STS AssumeRole session lasts 1 hour by default, and EC2 instance profile credentials last about 6 hours. S3 checks expiry when the request arrives (presigned URLs).

How to tell it’s yours: the URL works right after you generate it and fails later, often naming ExpiredToken or an expired signature. Links generated by a Lambda function or an ECS task are the usual suspects, because their role credentials are short-lived.

Fix:

  1. Generate the URL closer to when it’s used, with an expiry no longer than the signing credentials will live:
aws s3 presign s3://amzn-s3-demo-bucket/report.pdf --expires-in 900
  1. If links must last days, sign them with credentials that last that long, and treat the URL as a bearer token.
  2. Check that the signer has s3:GetObject (or s3:PutObject for uploads) and that no bucket policy Deny applies. A presigned URL can’t grant more than its signer holds.

CloudFront in front of S3 returning 403

With a CloudFront distribution in front of a private bucket, CloudFront has to be allowed to read the bucket. CloudFront recommends origin access control (OAC). The bucket policy must allow the cloudfront.amazonaws.com service principal, with an AWS:SourceArn condition naming your distribution (CloudFront OAC):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipalReadOnly",
      "Effect": "Allow",
      "Principal": {"Service": "cloudfront.amazonaws.com"},
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
      "Condition": {"StringEquals": {"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"}}
    }
  ]
}

Check these in order:

  1. Distribution ID in the condition. A copied policy that names a different distribution allows nothing.
  2. Signing behaviour. With SigningBehavior set to never, CloudFront sends unsigned requests. These only work if the bucket is public, and the bucket’s errors pass through to viewers.
  3. Website endpoints. A bucket’s website endpoint must be added as a custom origin, and OAC can’t be used with it.
  4. SSE-KMS objects. Add a statement to the KMS key policy that gives the CloudFront distribution permission to use the key, or encrypted objects fail.
  5. Missing objects. The CloudFront policy grants only s3:GetObject, so a missing key reaches viewers as 403, for the ListBucket reason above.

Stopping S3 403s from coming back

Most of these causes come back through a config change nobody connected to the failure. A few checks catch them early:

  • Use the enhanced messages. Log the full S3 error message in your app, along with the status. For same-account and same-organisation calls it names the policy type, and CloudTrail records the same context, so you can find the denying policy without guessing.
  • Test policy changes before they ship. Run the IAM policy simulator, or a smoke test that does GetObject as the real app role, in CI for any change to bucket policies, SCPs or endpoint policies.
  • Keep Bucket owner enforced on. It’s the default for new buckets. It removes the foreign-owner class of 403s entirely.
  • Treat clock sync as a dependency. Monitor NTP sync on hosts that call AWS. Skew past 15 minutes breaks every signed request at once.
  • Match presigned URL lifetimes to credentials. Never ask for an expiry longer than the signing session. If you want stale links to stop working, a bucket policy condition on s3:signatureAge enforces a maximum signature age.
  • Handle 403 on reads as missing or forbidden. Code paths that only hold s3:GetObject should log the key on a 403, so a typo doesn’t get debugged as an IAM problem.

Running on AWS? See how Polylane monitors AWS in production.

Common questions.

Why does S3 return 403 for a file that doesn't exist?

If the caller lacks s3:ListBucket on the bucket, S3 returns 403 Access Denied for a missing key. That way it doesn't reveal whether the object exists. Grant s3:ListBucket on the bucket ARN and the same request returns 404 Not Found.

My bucket policy allows public s3:GetObject. Why do I still get 403?

S3 Block Public Access overrides public bucket policies, and all four settings have been on by default for new buckets since April 2023. Check both the bucket and the account with get-public-access-block. S3 applies the most restrictive combination, so an account-level block still wins.

How much clock skew does S3 allow?

The signed parts of a SigV4 request are valid within 15 minutes of the request timestamp. Beyond that, S3 returns 403 with the code RequestTimeTooSkewed. Turn on NTP or chrony time sync on the host and retry.

Why did my presigned URL stop working before its expiry time?

A presigned URL expires at its set time or when the signing credentials expire, whichever comes first. URLs signed with an AssumeRole session die when the session ends, 1 hour by default. Instance profile credentials last about 6 hours. Sign long-lived links with IAM user credentials, which allow up to 7 days.

How do I find which policy denied my S3 request?

For requests in the same account or the same AWS organisation, the 403 message names the policy type and the reason. For explicit denies it also gives the policy ARN, and CloudTrail records the same detail. Cross-account requests from outside your organisation, VPC endpoint denials and directory bucket requests get a generic Access Denied.

Why can't I read objects in my own bucket?

If another account uploaded them and the bucket allows ACLs, that account owns them. Your bucket policy's Allow statements don't cover objects you don't own. Migrate any ACL grants to the bucket policy, then set Object Ownership to Bucket owner enforced with put-bucket-ownership-controls.

Why does CloudFront return 403 for my S3 origin?

The bucket policy must allow the cloudfront.amazonaws.com service principal with an AWS:SourceArn condition naming your distribution. The OAC must be set to sign requests. For SSE-KMS objects, the KMS key policy also needs a statement giving the CloudFront distribution permission to use the key. Website endpoints can't use OAC and must be set up as custom origins.

Sources (8)

  1. Troubleshoot access denied (403 Forbidden) errors in Amazon S3
  2. Setting permissions for website access
  3. Amazon S3 Error codes
  4. Troubleshoot Signature Version 4 signing for AWS API requests
  5. Download and upload objects with presigned URLs
  6. Authenticating Requests (AWS Signature Version 4)
  7. get_object, Boto3 documentation
  8. Restrict access to an Amazon S3 origin (CloudFront)

About the author

Boris Tane

Founder of Polylane

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

Related

Nobody should be on-call. Polylane watches your infra, finds what broke, and writes the fix.

Get started for free