Object Storage Upload Denied (S3 / Firebase / Supabase 403)

Fix S3, Firebase Storage, and Supabase Storage upload 403s — IAM permission, signed-URL expiry, bucket policy, or disabled-ACL gotcha.

A user uploads a file from your frontend. The browser calls s3.putObject() / storage.upload() and gets a 403:

AccessDenied: Access Denied
   <RequestId>...</RequestId>

Or Firebase Storage:

storage/unauthorized: User does not have permission to access this object.

Or Supabase:

new row violates row-level security policy "..."

TL;DR — fastest fix: read the error body first, then match it to a bucket. On S3, an explicit Deny in the bucket policy or missing s3:PutObject is the usual culprit; a brand-new 2023+ bucket also has ACLs disabled, so asking for s3:PutObjectAcl or sending an ACL header now causes a 403. On Firebase/Supabase the rule/policy that denies the write is named in the error — read it. Three buckets of cause: permission (IAM / rule / RLS policy), signed URL (expiry or wrong signing input), and bucket config (policy / CORS / Block Public Access).

Which bucket are you in?

Symptom in the error / Network tabMost likely causeJump to
AccessDenied on PUT, GET works fineIAM missing s3:PutObjectStep 2
AccessControlListNotSupported (400)Sending an ACL to an ACL-disabled bucketThe ACL gotcha
Browser shows a CORS error, not a 403Missing bucket CORSStep 6
Worked earlier, 403 a few minutes laterSigned URL expiredStep 5
KMS.AccessDeniedException / mentions a keyMissing KMS key permissionStep 2
storage/unauthorized (Firebase)Storage rule denies the path/size/typeStep 3
42501 / “violates row-level security policy”Supabase missing INSERT policyStep 4

Storage 403s look identical but split three ways. The error body almost always hints which — read it carefully before changing anything.

Common causes

Ordered by hit rate, highest first.

1. Bucket policy / Storage rules too strict

Most common. New buckets default to deny-all or owner-only. A frontend using an anon key cannot upload at all until you add a policy/rule. On Supabase specifically, Storage allows zero uploads until you create an RLS policy on storage.objects.

How to spot it:

  • S3: aws s3api get-bucket-policy --bucket your-bucket
  • Firebase: Console -> Storage -> Rules
  • Supabase: Dashboard -> Storage -> select bucket -> Policies

2. IAM role / user lacks PutObject

Classic S3: your server’s IAM role has s3:GetObject but missed s3:PutObject. GET works, PUT 403s. Watch two more traps: object actions (s3:PutObject, s3:GetObject) target the object ARN ending in /*, while s3:ListBucket targets the bucket ARN with no /* — mixing them up is a common 403. And an explicit Deny anywhere (bucket policy, SCP, identity policy) overrides any Allow.

How to spot it: AWS Console -> IAM -> your role -> Policies -> search for s3:PutObject. For server-side calls, the exact denied action only shows up if S3 data events are enabled on a CloudTrail trail.

3. Signed URL expired or signed with the wrong input

const url = await getSignedUrl(s3, {
  Bucket: 'x', Key: 'y',
  Expires: 60  // expires in 60s
});
// User clicks upload 5 minutes later -> 403

Or it was signed with the wrong secret key, wrong region, or wrong bucket. The signature also covers headers (e.g. Content-Type): if the browser sends a different Content-Type than what you signed, S3 returns SignatureDoesNotMatch.

How to spot it: upload time is past the URL’s expiry, or the error Code is SignatureDoesNotMatch rather than AccessDenied.

4. CORS blocks browser direct upload

A cross-origin browser PUT to S3 requires the bucket’s CORS config to allow it. New buckets default to no CORS.

How to spot it: the browser console shows a CORS error (blocked preflight OPTIONS), not a clean 403 response body.

5. File size / Content-Type out of allowed range

Some policies cap Content-Length or Content-Type:

"Condition": {
  "NumericLessThanEquals": {"s3:content-length-range": 5242880}
}

Uploading 10MB is denied. Firebase rules do the same with request.resource.size (in bytes) and request.resource.contentType.

How to spot it: the error mentions a content-length or content-type condition, or the Firebase rule that fails references request.resource.size.

6. Anon user uploading to an auth-required path

Firebase Storage and Supabase often gate writes by path:

allow write: if request.auth != null && request.auth.uid == userId;

An unauthenticated user writing to /users/{uid}/ definitely fails.

How to spot it: the error points to a specific rule, or Supabase returns 42501 for the INSERT.

The 2023+ ACL gotcha (most stale advice gets this wrong)

Since April 2023, every new S3 bucket ships with Object Ownership = Bucket owner enforced, which disables ACLs entirely. As of June 2026 this is still the default and AWS’s recommended setting.

Consequences you must know:

  • Adding s3:PutObjectAcl to your IAM policy does nothing useful, and sending an ACL header (e.g. x-amz-acl: bucket-owner-full-control or any canned ACL) on the PUT now returns:

    AccessControlListNotSupported: The bucket does not allow ACLs

    (HTTP 400, not 403 — but it still breaks the upload.) Old tutorials that tell you to “add the bucket-owner-full-control ACL” are the direct cause here.

  • Fix: drop the ACL. Remove any ACL: 'public-read' / 'bucket-owner-full-control' from your PutObjectCommand / putObject params, and don’t set x-amz-acl. Use a bucket policy for public read instead. The bucket owner already owns every uploaded object automatically.

  • You only need s3:PutObjectAcl (and a re-enabled ACL setting) in the rare case you deliberately set per-object ACLs. Most apps should keep ACLs disabled.

Shortest path to fix

Step 1: Capture the full error response

# Reproduce the upload, then:
# Browser DevTools -> Network -> failed request -> Response tab
<!-- S3 returns XML like this -->
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
  <RequestId>...</RequestId>
  <Resource>/your-bucket/your-key</Resource>
</Error>

The S3 Code and Message give the specific cause (AccessDenied, SignatureDoesNotMatch, AccessControlListNotSupported, KMS.AccessDeniedException). Firebase and Supabase explicitly name the denying rule or table.

Step 2: Fix S3 IAM

# 1. Check current role permissions
aws iam get-role-policy --role-name your-role --policy-name your-policy

# 2. Add PutObject (note: no PutObjectAcl — see the ACL gotcha above)
cat > /tmp/upload-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject"],
    "Resource": "arn:aws:s3:::your-bucket/*"
  }]
}
EOF

aws iam put-role-policy \
  --role-name your-role \
  --policy-name upload-policy \
  --policy-document file:///tmp/upload-policy.json

If objects are encrypted with SSE-KMS, the same identity also needs kms:GenerateDataKey and kms:Decrypt on the key, or you get KMS.AccessDeniedException despite correct S3 permissions. Also confirm there is no explicit Deny in the bucket policy and that Block Public Access is not silently blocking a public PUT you expected to work.

Step 3: Firebase Storage Rules

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // User folder: only the owner can write
    match /users/{userId}/{allPaths=**} {
      allow read: if request.auth.uid == userId;
      allow write: if request.auth.uid == userId
                   && request.resource.size < 10 * 1024 * 1024  // 10MB, bytes
                   && request.resource.contentType.matches('image/.*');
    }
    // Public assets
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if false;
    }
  }
}

request.resource.size is in bytes (so 10MB is 10 * 1024 * 1024), and request.resource.contentType is the type the client sends — a mismatched MIME type fails the rule even when the file is fine. Deploy with:

firebase deploy --only storage

Test rules without touching prod using the Storage emulator: firebase emulators:start --only storage.

Step 4: Supabase Storage Policies

By default Supabase Storage allows no uploads — you add policies on storage.objects. An upload needs only an INSERT policy. If you use upsert: true, you also need UPDATE and SELECT, or the upsert fails with code 42501 even though plain uploads work.

-- Dashboard -> SQL editor. Upload-only needs just this INSERT policy:
CREATE POLICY "Users upload to own folder" ON storage.objects
  FOR INSERT TO authenticated
  WITH CHECK (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

Or use the Dashboard -> Storage -> select bucket -> Policies UI. From a trusted server you can skip RLS entirely by calling Storage with the service role key in the Authorization header — never ship that key to the browser.

Step 5: Fix signed URLs

// Client: sign right before upload, then PUT immediately.
async function uploadFile(file: File) {
  const { url } = await fetch('/api/get-upload-url', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name, contentType: file.type })
  }).then(r => r.json());

  await fetch(url, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type }  // must match what was signed
  });
}
// Server
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand } from '@aws-sdk/client-s3';

const url = await getSignedUrl(s3Client, new PutObjectCommand({
  Bucket: 'x',
  Key: filename,
  ContentType: contentType,  // sign the same header the browser will send
}), { expiresIn: 900 });  // 15 min

If you sign a Content-Type (or any header) that the browser does not send back exactly, you get SignatureDoesNotMatch, not AccessDenied — a useful tell.

Step 6: S3 CORS

cat > /tmp/cors.json <<EOF
{
  "CORSRules": [{
    "AllowedOrigins": ["https://yourdomain.com"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }]
}
EOF

aws s3api put-bucket-cors --bucket your-bucket \
  --cors-configuration file:///tmp/cors.json

Step 7: Verify client clock

Signature validation depends on system time. If the client clock is skewed beyond 15 min, S3 rejects the request (RequestTimeTooSkewed):

# macOS
sudo sntp -sS time.apple.com

# Linux
sudo timedatectl set-ntp true

How to confirm it’s fixed

  1. Re-run the same upload from the real frontend (not just curl) — browser direct uploads also exercise CORS, which a server-side test skips.
  2. In DevTools -> Network, the PUT should return 200 (S3) or 200/201 (Firebase/Supabase) with no CORS preflight error.
  3. Confirm the object actually landed: aws s3 ls s3://your-bucket/your-key, or check the Firebase/Supabase Storage browser.
  4. Test the negative case too — an oversized file or an unauthenticated user should still be denied. If everything succeeds, your rules are too open.

FAQ

Why does GET work but PUT returns 403? Read and write are separate permissions. Your IAM policy (or Storage rule / RLS policy) grants s3:GetObject but not s3:PutObject. Add the write action to the same identity that signs or performs the upload.

I added s3:PutObjectAcl and the bucket-owner-full-control ACL like the tutorial said, and it still fails — why? Because buckets created since April 2023 have ACLs disabled by default (Object Ownership = Bucket owner enforced). Sending any ACL returns AccessControlListNotSupported. Remove the ACL from your request entirely; the bucket owner already owns the object.

The upload worked, then started 403ing a few minutes later. What changed? Almost always an expired signed URL. Shorten nothing — instead sign the URL right before the upload and PUT immediately, and confirm the client clock isn’t skewed (RequestTimeTooSkewed).

Supabase: my upload works but upsert: true fails with 42501. Why? An upsert is an insert plus an update plus a read. You need INSERT, UPDATE, and SELECT policies on storage.objects, not just INSERT.

Firebase rule looks right but still denies — what do I check? Confirm request.auth is non-null (the user is actually signed in at upload time), and that request.resource.contentType matches your .matches(...) pattern. Size is in bytes, so a < 5 * 1024 * 1024 rule blocks anything over 5MB.

Browser says CORS error instead of 403 — same fix? No. A CORS failure means the preflight was rejected before the actual PUT ran. Add the bucket CORS config (Step 6) with your real origin; permissions may already be fine.

Prevention

  • Commit bucket policy / Storage rules / RLS policies to git so PR review surfaces permission changes.
  • Document upload paths: which bucket, which path, who can write, who can read.
  • Keep signed-URL expiry short (5-15 min) and sign the same headers (Content-Type) the client will send; upload immediately after receiving the URL.
  • Browser direct upload requires CORS — test against the prod domain during dev.
  • Keep S3 ACLs disabled (the default) and grant access via bucket policy, not ACLs.
  • IAM least privilege: separate read-only and write roles; never hardcode AWS secret keys in the frontend — use signed URLs or STS.
  • Monitor the upload 4xx rate; sustained > 1% usually means rules are misconfigured.
  • Reproduce S3 / Firebase / Supabase rules locally with the Firebase emulator or LocalStack — don’t trial-and-error in prod.

Tags: #Backend #Debug #Troubleshooting