前端用户传文件,浏览器调用 s3.putObject() / storage.upload(),返回 403:
AccessDenied: Access Denied
<RequestId>...</RequestId>
或者 Firebase Storage:
storage/unauthorized: User does not have permission to access this object.
或者 Supabase:
new row violates row-level security policy "..."
一句话先救急: 先读错误响应体,再对号入座。S3 上最常见的是 bucket policy 里有一条显式 Deny,或者 IAM 漏了 s3:PutObject;而 2023 年以后新建的 bucket 默认禁用了 ACL,所以现在再去要 s3:PutObjectAcl 或带 ACL header,反而会直接导致失败。三大类根因:权限(IAM / rule / RLS policy)、签名 URL(过期或签名输入不对)、bucket 配置(policy / CORS / Block Public Access)。
你属于哪一类?
| 错误 / Network 里的现象 | 最可能的原因 | 跳转 |
|---|---|---|
PUT 报 AccessDenied,GET 却正常 | IAM 缺 s3:PutObject | Step 2 |
AccessControlListNotSupported(400) | 向已禁用 ACL 的 bucket 发了 ACL | ACL 的坑 |
| 浏览器报的是 CORS error,不是 403 | bucket 没配 CORS | Step 6 |
| 刚才还能传,几分钟后就 403 | 签名 URL 过期 | Step 5 |
KMS.AccessDeniedException / 提到某个 key | 缺 KMS key 权限 | Step 2 |
storage/unauthorized(Firebase) | Storage rule 拦了路径/大小/类型 | Step 3 |
42501 / “violates row-level security policy” | Supabase 缺 INSERT policy | Step 4 |
存储 403 看上去都一样,但实际分三路。错误响应体几乎总会透露是哪一路——动手改之前先认真读它。
常见原因
按命中率从高到低排。
1. bucket policy / Storage rules 太严
最常见。新 bucket 默认 deny-all 或 only-owner,前端用 anon key 在加策略前根本传不了。Supabase 尤其明显:在你给 storage.objects 建 RLS policy 之前,Storage 一个上传都不放行。
如何判断:
- S3:
aws s3api get-bucket-policy --bucket your-bucket - Firebase: Console -> Storage -> Rules
- Supabase: Dashboard -> Storage -> 选 bucket -> Policies
2. IAM 角色 / 用户没 PutObject
S3 经典坑:server 用的 IAM Role 有 s3:GetObject 却漏了 s3:PutObject,于是 GET 没事、PUT 报 403。还有两个常见陷阱:对象级 action(s3:PutObject、s3:GetObject)的 Resource ARN 要以 /* 结尾,而 s3:ListBucket 针对的是 bucket ARN、不带 /*,两者搞混就 403;另外只要任何地方(bucket policy、SCP、身份策略)有一条显式 Deny,它会盖过所有 Allow。
如何判断: AWS Console -> IAM -> 你的 Role -> Policies -> 搜 s3:PutObject。server 端调用时,只有在 CloudTrail trail 上开启了 S3 data events,才能看到具体被拒的 action。
3. 签名 URL 过期,或签名输入不对
const url = await getSignedUrl(s3, {
Bucket: 'x', Key: 'y',
Expires: 60 // 60 秒过期
});
// 用户 5 分钟后才点上传 -> 403
或者签名时用错了 secret key、错 region、错 bucket。签名还覆盖了请求头(比如 Content-Type):如果浏览器实际发的 Content-Type 和你签名时的不一致,S3 会返回 SignatureDoesNotMatch。
如何判断: 上传时刻已超过 URL 的 expiry,或者错误 Code 是 SignatureDoesNotMatch 而非 AccessDenied。
4. CORS 拦截浏览器直传
浏览器跨域 PUT 到 S3,需要 bucket 的 CORS 配置放行。新 bucket 默认没有 CORS。
如何判断: 浏览器 console 报的是 CORS error(预检 OPTIONS 被拦),而不是一个干净的 403 响应体。
5. 文件大小 / Content-Type 不在允许范围
有些策略限制 Content-Length 或 Content-Type:
"Condition": {
"NumericLessThanEquals": {"s3:content-length-range": 5242880}
}
传 10MB 就被拒。Firebase rules 同理,用 request.resource.size(单位是字节)和 request.resource.contentType 来限制。
如何判断: 错误里提到 content-length 或 content-type 条件,或失败的 Firebase rule 引用了 request.resource.size。
6. anon 用户上传到需要 auth 的路径
Firebase Storage 和 Supabase 常按路径分权限:
allow write: if request.auth != null && request.auth.uid == userId;
未登录用户传到 /users/{uid}/ 必然失败。
如何判断: 错误指向某条具体 rule,或 Supabase 对该 INSERT 返回 42501。
2023 年以后 ACL 的坑(过时教程基本都踩在这)
从 2023 年 4 月起,每个新建 S3 bucket 默认就是 Object Ownership = Bucket owner enforced,也就是彻底禁用 ACL。截至 2026 年 6 月,这仍是默认值,也是 AWS 推荐的设置。
你必须知道的后果:
-
在 IAM policy 里加
s3:PutObjectAcl已经没意义;而在 PUT 时发送 ACL header(比如x-amz-acl: bucket-owner-full-control或任何 canned ACL)现在会返回:AccessControlListNotSupported: The bucket does not allow ACLs(HTTP 400,不是 403——但一样让上传失败。)那些叫你”加上
bucket-owner-full-controlACL”的老教程,正是这里的直接祸根。 -
修复: 把 ACL 去掉。从
PutObjectCommand/putObject参数里删掉ACL: 'public-read'/'bucket-owner-full-control',也不要设x-amz-acl。要公开读就用 bucket policy。每个上传上来的对象,bucket owner 本来就自动拥有。 -
只有在你确实要逐对象设 ACL 的少数场景下,才需要
s3:PutObjectAcl(并重新打开 ACL 设置)。绝大多数应用都应保持 ACL 关闭。
最短修复路径
Step 1:抓完整错误响应
# 重新触发上传,然后:
# 浏览器 DevTools -> Network -> 找失败请求 -> Response 标签
<!-- S3 返回的是这种 XML -->
<?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 和 Message 给出具体原因(AccessDenied、SignatureDoesNotMatch、AccessControlListNotSupported、KMS.AccessDeniedException);Firebase 和 Supabase 会直接点名是哪条 rule 或哪张表拒的。
Step 2:S3 IAM 修复
# 1. 看当前 role 权限
aws iam get-role-policy --role-name your-role --policy-name your-policy
# 2. 加 PutObject(注意:不加 PutObjectAcl——见上面的 ACL 坑)
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
如果对象用 SSE-KMS 加密,同一个身份还需要在该 key 上有 kms:GenerateDataKey 和 kms:Decrypt,否则即使 S3 权限正确也会报 KMS.AccessDeniedException。再确认 bucket policy 里没有显式 Deny,以及你本以为能成功的公开 PUT 没有被 Block Public Access 悄悄拦掉。
Step 3:Firebase Storage Rules
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// 用户文件夹:只有本人能写
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,单位字节
&& request.resource.contentType.matches('image/.*');
}
// 公开 assets
match /public/{allPaths=**} {
allow read: if true;
allow write: if false;
}
}
}
request.resource.size 单位是字节(所以 10MB 写成 10 * 1024 * 1024),request.resource.contentType 是客户端发上来的类型——MIME 类型对不上,哪怕文件本身没问题也会被 rule 拒。部署:
firebase deploy --only storage
想不碰生产就测 rules,用 Storage 模拟器:firebase emulators:start --only storage。
Step 4:Supabase Storage Policies
Supabase Storage 默认不放行任何上传——你要在 storage.objects 上加 policy。普通上传只需要一条 INSERT policy;但如果用了 upsert: true,还得有 UPDATE 和 SELECT,否则即使普通上传正常,upsert 也会报 42501。
-- Dashboard -> SQL editor。只做上传,这条 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
);
或者在 Dashboard -> Storage -> 选 bucket -> Policies 用 UI 加。如果是从可信的 server 调用,可以在 Authorization header 里用 service role key 完全绕过 RLS——但这个 key 绝不能下发到浏览器。
Step 5:签名 URL 修复
// 客户端:上传前现签,拿到立即 PUT。
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 } // 必须和签名时一致
});
}
// 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, // 签名时带上浏览器将要发送的同一个 header
}), { expiresIn: 900 }); // 15 分钟
如果你签名时带的 Content-Type(或任何 header)和浏览器实际回传的不完全一致,得到的是 SignatureDoesNotMatch 而非 AccessDenied——这本身就是一个有用的判断线索。
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:客户端时钟检查
签名验证依赖系统时间。如果客户端时钟飘超过 15 min,S3 会拒绝请求(RequestTimeTooSkewed):
# macOS
sudo sntp -sS time.apple.com
# Linux
sudo timedatectl set-ntp true
怎么确认真的修好了
- 从真实前端(不只是 curl)重跑同一个上传——浏览器直传还会走 CORS,而 server 端测试会跳过这一段。
- DevTools -> Network 里,PUT 应返回
200(S3)或200/201(Firebase/Supabase),且没有 CORS 预检报错。 - 确认对象真的落盘了:
aws s3 ls s3://your-bucket/your-key,或在 Firebase/Supabase 的 Storage 浏览器里看。 - 也测一下反例——超大文件或未登录用户应当仍被拒。如果什么都能传成功,说明你的规则放得太开了。
FAQ
为什么 GET 行、PUT 却 403?
读和写是两套权限。你的 IAM policy(或 Storage rule / RLS policy)给了 s3:GetObject 但没给 s3:PutObject。把写 action 加到真正执行/签名上传的那个身份上。
我照教程加了 s3:PutObjectAcl 和 bucket-owner-full-control ACL,还是失败,为什么?
因为 2023 年 4 月之后新建的 bucket 默认禁用了 ACL(Object Ownership = Bucket owner enforced)。发任何 ACL 都会返回 AccessControlListNotSupported。把请求里的 ACL 整个删掉即可,对象 bucket owner 本来就拥有。
刚才还能传,几分钟后就 403 了,变了什么?
基本都是签名 URL 过期。别去纠结调短——而是在上传前现签 URL、拿到立刻 PUT,并确认客户端时钟没飘(RequestTimeTooSkewed)。
Supabase:普通上传能成,但 upsert: true 报 42501,为什么?
upsert 等于 insert 加 update 加 read。你需要在 storage.objects 上同时有 INSERT、UPDATE、SELECT 三条 policy,而不只是 INSERT。
Firebase rule 看着没错却仍被拒,该查什么?
确认 request.auth 非 null(上传那一刻用户确实已登录),以及 request.resource.contentType 匹配你的 .matches(...) 模式。大小单位是字节,所以 < 5 * 1024 * 1024 会拦掉一切超过 5MB 的文件。
浏览器报的是 CORS error 而不是 403——同一套修法吗? 不是。CORS 失败意味着预检在真正的 PUT 之前就被拒了。按 Step 6 用你真实的 origin 配上 bucket CORS;权限本身可能已经是好的。
预防建议
- 把 bucket policy / Storage rules / RLS policy 进 git,让 PR review 能看到权限变更。
- 上传路径文档化:哪个 bucket、什么 path、谁能写、谁能读。
- 签名 URL expiry 设短(
5-15 min),并签名客户端将要发送的同一组 header(Content-Type);拿到 URL 立刻上传。 - 浏览器直传必配 CORS,开发时就用生产 domain 测。
- 保持 S3 ACL 关闭(默认状态),用 bucket policy 而非 ACL 来授权。
- IAM 最小权限:分开 read-only 和 write 角色;绝不在前端硬编码 AWS secret key——用签名 URL 或 STS。
- 监控上传 4xx 比例,持续
> 1%通常说明规则配错了。 - 用 Firebase 模拟器或 LocalStack 在本地复现 S3 / Firebase / Supabase 规则,别在生产里试错。