Object Storage Upload Denied

S3 / Firebase Storage / Supabase Storage upload 403s — IAM, signed URL, or bucket policy.

User uploads a file from your frontend. Browser calls s3.putObject() / storage.upload(). 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 "..."

Storage 403s look identical but split three ways: bucket policy / IAM permission / signed URL. The error usually hints at which — read it carefully.

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. Frontend with anon key can’t upload at all.

How to spot it:

  • S3: aws s3api get-bucket-policy --bucket xxx
  • Firebase: Console → Storage → Rules
  • Supabase: Dashboard → Storage → 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.

How to spot it: AWS Console → IAM → your role → Policies → search for s3:PutObject and s3:PutObjectAcl.

3. Signed URL expired / wrong signature

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

Or wrong secret key, wrong region, wrong bucket at signing time.

How to spot it: Upload time is past the URL’s expiry.

4. CORS blocks browser direct upload

Browser cross-origin PUT to S3 requires bucket CORS to allow. New buckets default to CORS denied.

How to spot it: Browser console shows CORS error rather than 403.

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

Some policies cap Content-Length / Content-Type:

"Condition": {
  "NumericLessThan": {"s3:content-length-range": 5242880}  // 5MB
}

Uploading 10MB is denied.

How to spot it: Error mentions “ContentLengthExceeded” or “ContentType not allowed.”

6. Anon user uploading to auth-required path

Firebase Storage / Supabase often gate by path:

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

Unauthenticated user writing to /users/{uid}/ definitely fails.

How to spot it: Error points to a specific line/rule.

Shortest path to fix

Step 1: Capture the full error response

# Reproduce upload
# Browser Network → failed request → Response

# S3 example
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
  <RequestId>...</RequestId>
  <Resource>/your-bucket/your-key</Resource>
</Error>

S3 Code and Message give the specific cause; Firebase / Supabase explicitly name the denying rule.

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
cat > /tmp/upload-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:PutObjectAcl"],
    "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

Step 3: Firebase Storage Rules

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // User folder: only 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
                   && request.resource.contentType.matches('image/.*');
    }
    // Public assets
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if false;
    }
  }
}

Deploy: firebase deploy --only storage

Step 4: Supabase Storage Policies

-- Dashboard SQL editor
INSERT INTO storage.objects (bucket_id, name, owner)
VALUES ('avatars', 'test.jpg', auth.uid());

-- Add 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 → bucket → Policies UI.

Step 5: Fix signed URLs

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

  // Upload immediately
  await fetch(url, { method: 'PUT', body: file });
}

// Server
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const url = await getSignedUrl(s3Client, new PutObjectCommand({
  Bucket: 'x',
  Key: filename,
}), { expiresIn: 900 });  // 15 min

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 client clock is skewed > 15 min:

# macOS
sudo sntp -sS time.apple.com

# Linux
sudo timedatectl set-ntp true

Prevention

  • Commit bucket policy / Storage rules to git; PR review surfaces permission changes
  • Document upload paths: which bucket, which path, who can write, who can read
  • Short signed-URL expiry (5-15 min); upload immediately after receiving
  • Browser direct upload requires CORS — test against prod domain during dev
  • IAM least-privilege: separate read-only and write roles
  • Never hardcode AWS secret keys in frontend; use signed URLs or STS
  • Monitor upload 4xx rate; > 1% means rules are misconfigured
  • Reproduce S3 / Firebase rules locally with emulator / LocalStack — don’t trial-and-error in prod

Tags: #Backend #Debug #Troubleshooting