# Fix 403 Forbidden (Access Denied) from Amazon S3

> Why Amazon S3 returns 403 Forbidden: Block Public Access, missing s3:ListBucket, object ownership, clock skew, expired presigned URLs and CloudFront. Fix each.

By Boris Tane, Founder of Polylane · Published October 18, 2026 · 13 min read
Canonical: https://polylane.com/learn/troubleshooting/aws-403-forbidden/

```text
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.

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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)):

- 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](https://docs.aws.amazon.com/AmazonS3/latest/API/API_Error.html)):

- `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:

```bash
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:

```bash
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)). The message looks like this:

```text
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.

```mermaid alt="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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)). `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:

```bash
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteAccessPermissionsReqd.html)).
2. Turn off the bucket-level settings. If the account-level settings are on, turn those off too, or the account settings still win.

```bash
aws s3api put-public-access-block --bucket amzn-s3-demo-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
```

3. Add the public read policy. The `Resource` must end in `/*` so it covers objects:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::amzn-s3-demo-bucket/*"]
    }
  ]
}
```

```bash
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](https://docs.aws.amazon.com/boto3/latest/reference/services/s3/client/get_object.html)). 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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteAccessPermissionsReqd.html)).

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.

```bash
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:

```json
{
  "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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)). 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.

```bash
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:

```bash
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.

```bash
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)):

- **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](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html) 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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)).

```bash
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](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-authenticating-requests.html)). A host whose clock has drifted past that gets 403 with code `RequestTimeTooSkewed` ([S3 error codes](https://docs.aws.amazon.com/AmazonS3/latest/API/API_Error.html)). Small drifts can also break presigned URLs with `SignatureDoesNotMatch` ([presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)).

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

```bash
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:

```bash
timedatectl status
sudo timedatectl set-ntp true
chronyc tracking
```

2. 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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)).

**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:

```bash
aws s3 presign s3://amzn-s3-demo-bucket/report.pdf --expires-in 900
```

2. If links must last days, sign them with credentials that last that long, and treat the URL as a bearer token.
3. 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](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)):

```json
{
  "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](https://polylane.com/for/aws/).

## 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

- [Troubleshoot access denied (403 Forbidden) errors in Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/troubleshoot-403-errors.html)
- [Setting permissions for website access](https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteAccessPermissionsReqd.html)
- [Amazon S3 Error codes](https://docs.aws.amazon.com/AmazonS3/latest/API/API_Error.html)
- [Troubleshoot Signature Version 4 signing for AWS API requests](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-troubleshooting.html)
- [Download and upload objects with presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
- [Authenticating Requests (AWS Signature Version 4)](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-authenticating-requests.html)
- [get_object, Boto3 documentation](https://docs.aws.amazon.com/boto3/latest/reference/services/s3/client/get_object.html)
- [Restrict access to an Amazon S3 origin (CloudFront)](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)

## About the author

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

- [Is It Safe to Give an AI SRE Read Access to Production?](https://polylane.com/learn/ai-in-production/is-it-safe-to-give-an-ai-sre-read-access-to-production/): Read access for an AI SRE is a sound first step if you scope it. The risks are secrets, customer data and exfiltration. Here are the controls and a checklist.
- [Fix 502 Bad Gateway on AWS API Gateway and Load Balancers](https://polylane.com/learn/troubleshooting/aws-502-bad-gateway/): Why AWS returns 502 Bad Gateway from API Gateway or an Application Load Balancer, how to find which hop wrote it, and the fix for each cause.
- [Fix AuthorizerConfigurationException in API Gateway](https://polylane.com/learn/troubleshooting/aws-authorizerconfigurationexception/): AuthorizerConfigurationException means API Gateway couldn't call your Lambda authorizer or couldn't read its response. Find the cause and fix it.
- [AsyncEventsDropped: diagnose Lambda async event loss](https://polylane.com/learn/troubleshooting/AsyncEventsDropped/): AsyncEventsDropped counts Lambda async events discarded after age or retry limits. Check throttling, destinations and DLQs, then verify processing recovery.
- [ChangeMessageVisibility: troubleshoot SQS message timeout errors](https://polylane.com/learn/troubleshooting/ChangeMessageVisibility/): ChangeMessageVisibility resets SQS message visibility. Use current receipt handles, bounded timeout extensions and per-entry batch error checks with Lambda.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
