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
Related
Tags: #Backend #Debug #Troubleshooting