浏览器对一条预签名 URL 用 PUT 上传 500 MB 视频。前 200 MB 顺利,然后返回 403 Forbidden,body 里写着 <Message>Request has expired</Message>。这条签名 URL 的 TTL 是 5 分钟——传缩略图够用,传大文件、上行又慢,就远远不够。
最快的修法: 别再用单段 PUT 传超过 100 MB 的东西。改用 S3 multipart 上传、每段一条预签名 URL;或者把活直接交给 SDK 的上传管理器(Node 的 @aws-sdk/lib-storage Upload、Python boto3 的 upload_file),分块、并行、重试都帮你做好。
有个坑,很多团队把 TTL 调大了还是栽:如果你用临时凭证签 URL(IAM role、EC2 instance profile、或 sts:AssumeRole),那 URL 会随这套凭证一起过期——不管你把 ExpiresIn 设多长。在怪 TTL 之前,先排查下面这一项。
第一步:先看清楚错误码
两种不同的 403 在浏览器里长得一模一样,但修法相反。打开响应 body(是 XML),对照 <Code>:
| 错误码 / 消息 | 真实含义 | 跳到 |
|---|---|---|
AccessDenied + Request has expired | TTL 到期,或签 URL 的凭证过期了 | 原因 1、3 |
SignatureDoesNotMatch | 请求头和签名时不一致(Content-Type、checksum、host、时钟偏移) | 原因 4、5、6 |
EntityTooLarge | 单段 PUT 超过 5 GB | 原因 2 |
EntityTooSmall(multipart) | 某个非末段小于 5 MB | 见 multipart 说明 |
ExpiredToken | 签名用的 STS/role 凭证已经失效 | 原因 3 |
浏览器里看不到 body 的话,在 DevTools 里把请求 copy as cURL,扔到终端跑一遍,S3 会把 XML 直接吐到命令行。
常见原因
按踩坑频率排序。
1. 签名 URL 的 TTL 比上传时间短
签名 URL 5 分钟过期。用户 5 Mbps 的上行,推完 500 MB 大概要 800 秒。S3 是在每个 HTTP 请求开始的那一刻检查截止时间的:所以一条还在传输中的单段 PUT 即使越过了截止线也没事——但连接断了、过期后再重试,或者 multipart 后面几段是在窗口关闭之后才开始的,就会被拒。
怎么判断:body 是 <Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>,URL 里的 X-Amz-Expires 值很小(比如 300)。
2. 单段 PUT 传超过 5 GB
S3 单段 PUT 硬上限 5 GB。就算 TTL 改对了,6 GB 文件永远失败。(multipart 把上限抬到单对象 5 TB、最多 10000 段。)
怎么判断:错误码 EntityTooLarge。
3. 签名用的凭证比 URL 先过期
这一项最容易漏。预签名 URL 的寿命永远不可能超过签它的那套凭证。截至 2026 年 6 月 AWS 文档说得很清楚:IAM 用户密钥签的 URL 最长能活 7 天(604800 秒),但临时凭证会把 URL 的寿命压到凭证自己的有效期,两者取短:
| 签发方凭证 | URL 实际最长寿命 |
|---|---|
| IAM 用户 access key(SigV4) | 7 天(604800 秒) |
sts:AssumeRole 会话 | role 会话时长——默认 1 小时、最多 12 小时 |
| EC2 instance profile | IMDS 轮换,通常约 6 小时 |
| ECS/Fargate task role | 约 1-6 小时轮换 |
| Lambda 执行角色 | 本次调用的 STS 会话,约几分钟到 12 小时 |
所以一个 Lambda 或 ECS 服务即使 ExpiresIn=86400(24 小时)签出来,给浏览器的 URL 还是一小时就死(最多也就 12 小时)。过期之后你会看到 403 Request has expired(继承了凭证的 TTL 窗口)或 ExpiredToken(凭证已经没了)。这也是 AWS 官方排障文档里被报告最多的「URL 提前失效」场景。
怎么判断:刚部署完 URL 能用,过一小时或几小时就开始 403,跟 ExpiresIn 设多大无关;签发方跑在 role 上,不是长期的 IAM 用户密钥。
修法:用一把专门的 IAM 用户密钥来签(权限收到只有上传前缀的 s3:PutObject),或者把 TTL 压到远小于 role 会话时长,或者 multipart 时每段实时重签,保证每条 URL 都是新的。
4. 浏览器没带 SDK 默认加的 checksum 头
AWS 在 2024 年底上线了默认数据完整性保护(2024 年 12 月 2 日发布),随后更新的 SDK 默认就把校验打开——默认算法是 CRC64NVME(也会用到 CRC32 / CRC32C)。你给 put_object 预签名时,SDK 可能把 x-amz-sdk-checksum-algorithm / x-amz-checksum-* 头折进签名里。结果浏览器 PUT 原始文件时没带这个头,签名对不上,S3 第一个字节就回 403 SignatureDoesNotMatch——根本不是传到一半才挂。所以一套 2023 年还好好的上传流程,可能只是升了一次 SDK、自己一行代码没动,就开始挂了。
怎么判断:<Code>SignatureDoesNotMatch</Code>;错误里的 <CanonicalRequest> 列了一个你客户端从没发过的 x-amz-checksum-* 或 x-amz-sdk-checksum-algorithm 头。
修法:要么签名时关掉默认 checksum(boto3:Config(request_checksum_calculation='when_required');JS SDK v3:requestChecksumCalculation: 'WHEN_REQUIRED'),让它不进签名;要么浏览器端把一模一样的 checksum 头发上去。
5. Content-Type 等头签了却没发(或反过来)
签名时如果你往 Params 里塞了 ContentType(或任何头),这个头就成了签名的一部分。浏览器 PUT 必须发完全相同的值。常见的翻车:签名时写 ContentType: 'video/mp4',却让 fetch 自己去定 Content-Type。
怎么判断:403 SignatureDoesNotMatch;签名头列表里有 content-type,但你的请求发的是另一个值(或没发)。
6. 签发方时钟偏移
签发方时钟慢了,S3 看到签名一到就已经过期。
怎么判断:所有签名 URL 都 403,连小文件也跑不通;URL 里的 X-Amz-Date 已经是过去时间。(错误码 SignatureDoesNotMatch 或 Request has expired。)
7. CORS preflight 把预算耗掉 / 直接挡住 PUT
浏览器 PUT 之前先发 OPTIONS 预检。桶 CORS 不允许 PUT,上传根本没开始、签名 URL 的时钟却一直在走。
怎么判断:Network 里 preflight 403(或者根本没发 PUT);浏览器 console 报 CORS 错误。
8. 用了 SigV2、但 region 只认 SigV4
老代码用了 v2 签名。2014 年 1 月之后上线的 region、以及 KMS 加密桶,都强制 SigV4。
怎么判断:InvalidRequest: The authorization mechanism you have provided is not supported。
最短修复路径
Step 1: 按文件大小选模式
| 文件大小 | 模式 |
|---|---|
| 小于 100 MB | 单段 PUT 预签名 URL,TTL 15 分钟 |
| 100 MB - 5 GB | multipart,每段一条预签名 URL |
| 大于 5 GB | 必须 multipart(单段 PUT 硬卡 5 GB) |
5 分钟 TTL 对面向用户的上传太紧。默认 900 秒(15 分钟)——但别超过签发方的凭证有效期(原因 3)。
Step 2: 单段 PUT 配合合理 TTL
# Python boto3——单段 PUT 预签名 URL
import boto3
from botocore.client import Config
s3 = boto3.client(
's3', region_name='us-east-1',
config=Config(
signature_version='s3v4',
request_checksum_calculation='when_required', # 别把 checksum 折进签名(原因 4)
),
)
url = s3.generate_presigned_url(
'put_object',
Params={'Bucket': 'uploads', 'Key': 'video.mp4', 'ContentType': 'video/mp4'},
ExpiresIn=900, # 15 分钟
HttpMethod='PUT',
)
前端直接 PUT,并且发和签名时一样的 Content-Type(原因 5):
fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': 'video/mp4' } });
Step 3: Multipart 上传,每段一条预签名
大文件就该这么传。每段一条签名 URL,浏览器并行推,后端做 complete。分段规则:每段最少 5 MB(末段除外),每段最多 5 GB,最多 10000 段。
# 1. 启动
mpu = s3.create_multipart_upload(Bucket='uploads', Key='video.mp4', ContentType='video/mp4')
upload_id = mpu['UploadId']
# 2. 给每段签 URL(比如 8 MB 一段)
def sign_part(part_number):
return s3.generate_presigned_url(
'upload_part',
Params={
'Bucket': 'uploads',
'Key': 'video.mp4',
'UploadId': upload_id,
'PartNumber': part_number,
},
ExpiresIn=3600,
)
# 3. 客户端每段上传完,从响应头拿 ETag
# 4. 完成
s3.complete_multipart_upload(
Bucket='uploads', Key='video.mp4', UploadId=upload_id,
MultipartUpload={'Parts': [
{'PartNumber': 1, 'ETag': '"abc..."'},
{'PartNumber': 2, 'ETag': '"def..."'},
]},
)
浏览器对每段 PUT 上对应 URL,从响应头读 ETag,把清单回传给后端。某段重试后 403 报 SignatureDoesNotMatch,就只重签那一段,别整单重签。
Step 4: 能用 SDK 上传管理器就别手搓
服务端上传别自己搓 multipart——用管理器。它把分块、并行、重试、失败时 abort multipart 全包了。
// Node AWS SDK v3
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { createReadStream } from 'node:fs';
const s3 = new S3Client({ region: 'us-east-1' });
await new Upload({
client: s3,
params: {
Bucket: 'uploads',
Key: 'video.mp4',
Body: createReadStream('./video.mp4'),
ContentType: 'video/mp4',
},
queueSize: 4, // 并行段数
partSize: 8 * 1024 * 1024,
}).done();
Python 对应的是 s3.upload_file(...) 配一个 TransferConfig(multipart_threshold=..., max_concurrency=...)。
Step 5: 修时钟、修 CORS
# 在签发方机器上——查时钟并强制校正(原因 6)
timedatectl status
sudo chronyc makestep
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}
ExposeHeaders: ["ETag"] 必须有,不然浏览器读不到每段的 ETag,complete 时拼不出来。AllowedHeaders: ["*"] 让预检放行任何签名头(Content-Type、checksum)。
怎么确认修好了
- 传一个比你原来失败点更大的文件(比如 600 MB),并且限速——Chrome DevTools Network → throttling → “Slow 4G”——确认能跑到
200/204。 - 如果你在 role 上签名,部署完一小时后再传一次,排除原因 3。
- multipart 的话,传到一半时列一下各段,确认没有哪段返回
SignatureDoesNotMatch:aws s3api list-parts --bucket uploads --key video.mp4 --upload-id "$UPLOAD_ID" aws s3api head-object --bucket uploads --key video.mp4应该显示完整的ContentLength。
常见问题
为什么小文件没事、大文件传到一半 403?
签名 URL 的 TTL(以及/或者签发方的凭证有效期)比大文件传输需要的时间短。小文件在窗口内传完;大文件越过截止线,序列里下一个请求就被 Request has expired 拒掉。
ExpiresIn 最大能设多少?
用 AWS CLI/SDK 加 IAM 用户密钥,7 天(604800 秒)。用 S3 console,1 分钟到 12 小时。但如果你用临时凭证签(role / STS / instance profile),URL 还是会跟着那套凭证一起过期——常常 1 小时就没了,而且 STS 会话不管你传多大都封顶 12 小时。
我把 ExpiresIn 设成 24 小时了,还是 403,为啥?
基本就是原因 3:你后端是用 role 或 STS 会话签的,URL 继承了会话(更短的)有效期——通常 1 小时,最多 12 小时。AWS 在它的预签名 URL 提前失效排障文档里专门讲了这个行为。改用专门的 IAM 用户密钥签,或者每段实时重签。
我第一个字节就 SignatureDoesNotMatch,不是 Request has expired,是另一个问题吗?
是。那是请求头对不上,不是过期。最常见的元凶是 SDK 默认 checksum 头(原因 4),或者签了却没发的 Content-Type(原因 5)。把错误里 <CanonicalRequest> 的签名头列表,跟你客户端实际发的对一遍。
那我把 TTL 直接拉到一周、不用 multipart 行不行?
不行。长 TTL 是安全隐患(URL 就是个 bearer token),而且单段 PUT 还是卡 5 GB。大文件用 multipart,TTL 保持短。你还能在桶策略里加 s3:signatureAge 条件来强制时效。
段大小用多少合适? 8-16 MB 是不错的默认。每段至少 5 MB(末段除外),最多 5 GB,总数封顶 10000 段——所以超大对象要把段调大,免得超过 10000 段。
预防
- 默认 TTL:单段 PUT 15 分钟,multipart 段 1 小时——并且永远别超过签发方的凭证有效期。
- 需要稳定、长期的 URL 时,用一把专门的、最小权限的 IAM 用户密钥签(只给上传前缀的
s3:PutObject)。 - 超过 100 MB 走 multipart,每段 8-16 MB。
- 把 SDK 的 checksum 挡在签名之外(
when_required/WHEN_REQUIRED),除非客户端会发对应的头。 - 签发方机器跑 NTP,时钟偏移超过 1 秒告警。
- 永远显式
signature_version='s3v4',连 SigV4 端点。 - 服务端上传优先用 SDK
Upload/TransferManager,别手搓 multipart。
Related
- Storage upload denied
- Edge function timeout
- Backend JWT expired clock skew
- Backend cron job skipped silently
- CORS error
- Firebase quota exceeded
外部参考:AWS S3 预签名 URL 文档 · 排查 SignatureDoesNotMatch · 预签名 URL 在到期时间前就失效