S3 预签名 URL 大文件上传中途 403

AWS S3 预签名 URL 小文件没事、大文件传到一半 403。修对 TTL、修对签发凭证的有效期,大文件改 multipart 上传。

浏览器对一条预签名 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 expiredTTL 到期,或签 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 profileIMDS 轮换,通常约 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 已经是过去时间。(错误码 SignatureDoesNotMatchRequest 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 GBmultipart,每段一条预签名 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)。

怎么确认修好了

  1. 传一个比你原来失败点更大的文件(比如 600 MB),并且限速——Chrome DevTools Network → throttling → “Slow 4G”——确认能跑到 200/204
  2. 如果你在 role 上签名,部署完一小时后再传一次,排除原因 3。
  3. multipart 的话,传到一半时列一下各段,确认没有哪段返回 SignatureDoesNotMatch
    aws s3api list-parts --bucket uploads --key video.mp4 --upload-id "$UPLOAD_ID"
  4. 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。

外部参考:AWS S3 预签名 URL 文档 · 排查 SignatureDoesNotMatch · 预签名 URL 在到期时间前就失效

标签: #后端 #排查 #s3