這是 pre-signed URL 學習路線圖的 Level 3,整條路線的核心。Level 1 講清了「它是什麼、為什麼點了就能存取私有檔卻不用登入、它是 bearer token、權限不超過產生者、產生時純本機運算」;Level 2 分清了「下載用 GET、上傳用 PUT」。這篇只做一件事:把 X-Amz-Signature 那段簽章的黑盒打開——它到底怎麼算出來的?server 收到又是怎麼驗的?答案就是 SigV4。
Level 1 說 pre-signed URL 是「一個先簽好名的 HTTP 請求」,簽章放在 X-Amz-Signature。但「簽好名」這三個字,一直是個黑盒——那段長長的簽章到底怎麼長出來的?
這一站就是把黑盒拆開。你會看到它其實是三個步驟接力:把請求整理成固定格式的文字 → 用你的 secret key 長出一把「限定用途的簽名筆」→ 拿這把筆在那段文字上簽名。算完的結果就是 X-Amz-Signature。
這站技術密度比前兩站高,但我保證每個步驟都給你直覺、不是丟規格書。讀完你要能拿一條真的 pre-signed URL,逐個參數說出意義,畫出「canonical request → signing key → signature」的流程,並解釋為什麼改動 URL 任何一個字元、簽章就會立刻失效。
這幾題就是本文的重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,然後帶著它們往下讀——遇到答案所在的段落就特別留意;讀完再回來點「看答案」對照。
Hex(HMAC-SHA256(SigningKey, StringToSign)),再連同其他 X-Amz-* 塞進網址 query string。UNSIGNED-PAYLOAD,而不是去算 body 的雜湊——body 根本沒被簽進去。簽章只綁 method、路徑、被簽的 header。這是特性:讓「先發連結、之後才決定傳什麼」可行;反過來也代表單純的 presigned PUT 無法靠簽章限制上傳內容。X-Amz-* 參數。為什麼唯獨 X-Amz-Signature 不被算進 canonical request?看答案收合X-Amz-Signature 以外的所有參數;其他每個 X-Amz-*(Algorithm/Credential/Date/Expires/SignedHeaders)都要一起簽,唯獨簽章自己排除。X-Amz-Credential 裡,等於「帳號」那半),不是 secret key。server 就是靠 X-Amz-Credential 裡的 access key ID,從自己這邊查出對應的 secret key、長出同一把 SigningKey、算出簽章來比對。secret key 的效力早被揉進 SigningKey、再壓成那段 signature,全程不出現在網址裡。X-Amz-Signature 比對(不符→SignatureDoesNotMatch);(2) 以 X-Amz-Date + X-Amz-Expires 在請求當下判斷是否過期;(3) 那組憑證此刻是否仍有效仍有權限(撤銷/停用/臨時憑證 session 過期都會讓沒到期的 URL 失效)。動工前先花 10 秒撿回兩個 Level 0 的工具——本站從頭到尾一直用到它們:有這兩句墊底,後面所有
- 雜湊(hash):把任意長度的文字,壓成一段固定長度、改一個字就天差地遠的「指紋」。SHA256 就是其中一種雜湊演算法。特性:同樣輸入永遠得同樣指紋,但看指紋推不回原文。
- HMAC:拿一把 secret key、對一段文字算出的雜湊。重點是——同一把 key,兩邊各自算一次,就能比對結果一不一致(一致代表對方確實握有同一把 key)。
雜湊/SHA256/HMAC都有靠山。
整套演算法就三塊,AWS 官方把它拆成這樣:
先看下面這張流程圖抓個輪廓——圖上每個生字接下來都會一個一個拆開,這裡先掃過、留個印象就好:
比喻先給你抓感覺:想像你要寄一份「防偽公文」。
(A) 先把公文內容謄寫成一份格式嚴格、一個字都不能差的正本;
(B) 你手上有一顆總印章(secret key),但你不直接蓋它——你先用它刻一顆「只有今天、只有這個部門能用」的臨時章;
(C) 拿臨時章蓋在正本上,那個印記就是簽章。
收件方拿到公文,會照同樣格式再謄一次、也刻同一顆臨時章、再蓋一次,兩個印記一比對,對得上就收、對不上就退。
下面把三塊一塊一塊拆。
這一步的唯一目的:讓你算的東西,跟 server 待會兒重算的東西,一個字元都不差。
為什麼要這麼龜毛?因為簽章是「對一段文字做雜湊」。文字差一個空格、一個大小寫、參數順序換一下,算出來的簽章就完全不同。 就像兩個人各自算同一道長算式,中間步驟必須一模一樣,答案才會相等。所以 AWS 定死了一套「標準抄寫法」,叫 canonical request。
它是六行文字、用換行接起來:
HTTPMethod ← GET / PUT / HEAD / DELETE …
CanonicalURI ← 物件路徑,如 /my-bucket/photo.jpg
CanonicalQueryString ← 所有 query 參數,逐個 URL-encode、按 key 排序
CanonicalHeaders ← 至少要有 host,小寫、按字母排序
SignedHeaders ← 上面被納入簽章的 header 清單,分號連接
HashedPayload ← body 的雜湊
逐行講白:
%XX,例如 / 變成 %2F),然後按 key 的字母排序串起來。AWS 特別強調:排序發生在編碼之後(先編碼、再排序)。host;header 名要轉小寫、按字母排序,值要 trim 掉前後空白。(對 pre-signed URL 而言,通常就只簽 host。)host。它明白告訴 server「我簽了哪些 header,你就驗這幾個」。骨架抽象,直接填一份實例最清楚。本站全程用 path-style 定址(bucket 放在路徑裡、host 是 s3.amazonaws.com;另一種 virtual-hosted 定址是 Level 6 的題目,這裡不展開)。以「下載 my-bucket 裡的 photo.jpg、有效 900 秒」為例,canonical request 六行實際長這樣:
GET
/my-bucket/photo.jpg
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T091100Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host
host:s3.amazonaws.com
host
UNSIGNED-PAYLOAD
對著六行看,幾個關鍵處:
X-Amz-Credential 值中的 / 被編碼成 %2F(逐個 value URL-encode),且五個參數已按 key 字母排序;注意這裡沒有 X-Amz-Signature——它是稍後才算出來的,不能算進自己。host:s3.amazonaws.com),因為這條 URL 只簽了 host。那第 4 行跟host(第 5 行)之間怎麼空了一行? 這不是排版失誤,是規格。CanonicalHeaders 這一塊,每個 header 都寫成name:value、而且每行結尾都帶一個換行——所以host:s3.amazonaws.com這行本身就以換行收尾了;接著「六行用換行接起來」的規則,又在 CanonicalHeaders 和 SignedHeaders 之間再加一個換行。兩個換行疊在一起,看起來就是中間空了一行。照著重建 canonical request 時,這個空行千萬不能省,否則簽章就對不上。
第六行 HashedPayload 填的是 UNSIGNED-PAYLOAD——這藏著一個 pre-signed URL 的關鍵設計,單獨拉出來講。
一般帶 body 的請求,HashedPayload 會是 Hex(SHA256(body))——把 body 算進簽章。但 pre-signed URL 產生的當下,根本還沒有 body(尤其上傳:你產一條 PUT URL 給對方時,你完全不知道他等一下要傳什麼檔)。
所以 S3 有個專屬做法:canonical request 裡的 HashedPayload 直接填固定字串 UNSIGNED-PAYLOAD,不去算 body。AWS 原話:
For Amazon S3, include the literal string UNSIGNED-PAYLOAD when constructing a canonical request。
這就解開了 Level 2 留下的一個疑問:為什麼 presigned PUT 的 URL 簽好之後,對方上傳「任何內容」的檔案都能成功?因為 body 根本沒被簽進去——簽章只綁了 method、路徑、被簽的 header,沒綁 body。這是特性、不是漏洞:它讓「先發連結、之後才決定傳什麼」變得可行;相對地,也代表單純的 presigned PUT 無法靠簽章限制上傳內容(要限制內容是 Level 5/7 的題目)。
pre-signed URL 是把簽章放進 query string 的(不是放 header)。這帶出一個雞生蛋問題:簽章本身也是一個 query 參數(X-Amz-Signature),那它要不要被算進 canonical request?
答案:不要。AWS S3 API 規定,CanonicalQueryString 必須包含除了 X-Amz-Signature 以外的所有 query 參數。
道理很單純:簽章是「算完」才存在的結果,你不可能在計算之前就把還沒算出來的答案塞進要簽的內容裡。 其他每個 X-Amz-*(Algorithm、Credential、Date、Expires、SignedHeaders)都要進去一起簽;唯獨簽章自己排除在外(這也是為什麼上面那份填實值的第 3 行看不到它)。
canonical request 組好之後,先對它做一次 SHA256,得到一串小寫 hex(十六進位,就是用 0-9、a-f 這 16 個字元把那串位元組寫成一列可讀字串)。然後組出 string to sign——四行文字,換行接起,結尾不換行:
AWS4-HMAC-SHA256 ← 演算法
20260707T091100Z ← RequestDateTime(ISO8601, UTC)
20260707/us-east-1/s3/aws4_request ← Credential Scope
<hex(SHA256(canonical request))> ← 上面那串 hex
逐行白話:
AWS4-HMAC-SHA256:宣告用的演算法(SigV4 固定這個)。日期/region/service/aws4_request(region=AWS 機房區域,如 us-east-1;service=哪一個服務,這裡是 s3)。它把這個簽章的適用範圍鎖死——只在這一天、這個 region、這個 service 有效。直覺上你可能以為:拿 secret key 對 string to sign 做一次 HMAC 就是簽章了。不是。 SigV4 多繞了一圈:先用 secret key 做 4 次鏈式 HMAC-SHA256,推導出一把 signing key,再用這把 signing key 去簽。AWS 的確切公式:
DateKey = HMAC-SHA256("AWS4" + SecretKey, "20260707")
DateRegionKey = HMAC-SHA256(DateKey, "us-east-1")
DateRegionServiceKey = HMAC-SHA256(DateRegionKey, "s3")
SigningKey = HMAC-SHA256(DateRegionServiceKey,"aws4_request")
看懂它在幹嘛:把 secret key 一層一層揉進「日期 → region → service → aws4_request」,每一步的輸出當作下一步的 key。最後得到的 SigningKey,就是那把「限定用途的簽名筆」。
這個「作用域受限的衍生金鑰」設計,才是重點:比喻:與其把「總鑰匙」交出去,不如用總鑰匙刻一把「只能開今天這個房間」的臨時鑰匙。臨時鑰匙掉了,損失有限;總鑰匙始終在你手上。
- SigningKey 只對那一天、那個 region、那個 service 有效。就算它不小心外洩,攻擊者也只能在那天、那個 region、那個 service 內濫用;換一天就是一把廢筆。
- 而你真正的 secret key,從頭到尾沒有離開你的機器——它只在第一次 HMAC 當 key,之後就被「揉」進衍生鏈裡,不會出現在網址、也不會傳給 server。
有了 SigningKey 和 string to sign,最後一步就一行:
signature = Hex( HMAC-SHA256(SigningKey, StringToSign) )
用 SigningKey 當 key、對 string to sign 做 HMAC-SHA256,把結果轉成小寫 hex——這就是 X-Amz-Signature 的值。
然後把它連同其他 X-Amz-* 參數全部塞進網址的 query string。承接上面那份 path-style 實例,一條 pre-signed URL 拆開長這樣(為了讀,這裡換行、沒 URL-encode;真實網址是連續一串、且已編碼):
https://s3.amazonaws.com/my-bucket/photo.jpg?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAIOSFODNN7EXAMPLE/20260707/us-east-1/s3/aws4_request&
X-Amz-Date=20260707T091100Z&
X-Amz-Expires=900&
X-Amz-SignedHeaders=host&
X-Amz-Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855 ...
對照一下就知道 (A) 的 canonical request 是怎麼從這條 URL 重建的:host 是 s3.amazonaws.com、CanonicalURI 是 /my-bucket/photo.jpg——兩者對得起來。每個參數的意義:
| Query 參數 | 意義 |
|---|---|
X-Amz-Algorithm | 簽章演算法,固定 AWS4-HMAC-SHA256 |
X-Amz-Credential | access key ID / 日期 / region / service / aws4_request——即你的 access key ID 加上 credential scope。注意:帶的是公開的 access key ID,不是 secret key |
X-Amz-Date | 簽章時間(ISO8601, UTC),驗證期限的基準點 |
X-Amz-Expires | 有效秒數(相對 X-Amz-Date)。這是 pre-signed URL 的識別特徵。最大 604800 秒(= 7 天,用 IAM user 長期憑證時);S3 console 產的是 1 分鐘~12 小時 |
X-Amz-SignedHeaders | 哪些 header 被納入簽章(至少 host) |
X-Amz-Signature | 上面算出的簽章本體。唯一真正機密的東西,也是唯一沒被算進 canonical request 的參數 |
把 Level 1 的「access key ID vs secret key」在這裡收口:網址裡的 X-Amz-Credential 帶了 access key ID(公開的那半,等於「帳號」),這是要讓 server 知道「該用哪組憑證來重算」。而 secret key(真正的秘密)從頭到尾沒出現在網址裡——它的效力早被「揉」進 SigningKey、再壓成那段 signature 了。
URL 產好了、發出去了。等對方真的拿它去存取物件時,S3 / MinIO 才出場,做三件事:
1. 重算簽章、逐字比對。 server 從 URL 裡把 method、路徑、query 參數(除了 X-Amz-Signature)、SignedHeaders、host 全撿出來,照 SigV4 完整再算一次(它認得 X-Amz-Credential 裡的 access key ID,就從自己這邊查出對應的 secret key、長出同一把 SigningKey、算出簽章)。算出來的跟網址裡的 X-Amz-Signature 一比:
SignatureDoesNotMatch。等等——Level 1/(B) 段不是說 secret key「從沒離開你的機器、沒進網址、沒傳 server」嗎?那 server 哪來的 secret 拿來重算?
關鍵在 HMAC 是「對稱式」的(回扣 Level 0):同一把 secret,兩邊各算一次、比對是否一致——這代表驗證方本來就得手上有同一把 secret。而這把 secret,server 端本來就存有一份副本:你當初建立這組 access key 時,access key ID與secret key就是在 AWS/MinIO 那邊「成對」產生、成對保存的。所以 server 靠 URL 裡的 access key ID 一查,就拿得到對應的 secret,根本不需要你這次把 secret 傳過去。
換句話說,「網址沒帶 secret」的精確意思是 「不透過這次請求傳輸」,不是「server 不知道」。secret key 只是不在傳輸途中露臉,並非無人知曉。
2. 檢查是否過期。 以 X-Amz-Date + X-Amz-Expires 為準,在收到請求的當下判斷。AWS 原話:
Amazon S3 checks the expiration date and time of a signed URL at the time of the HTTP request。若 client 在到期前「開始」下載一個大檔,就算下載途中過了期限,下載仍會繼續;但若連線中斷、client 在過期後想重連,就會失敗。
3. 檢查那組憑證此刻仍有效、仍有權限。 這一條和「有沒有到期」是兩回事:
X-Amz-Expires 設得更長也沒用。這是「我明明設了 7 天,怎麼提早死」的頭號原因。這是這一站最該內化的結論,也是過關的核心。把上面串起來就懂了:
簽章 = 對「請求內容」做的雜湊。你一改請求裡任何被簽進去的東西——method、物件路徑、任一個 X-Amz-* 參數、被簽的 header——server 重算時的輸入就變了,算出來的簽章自然跟網址裡原本的 X-Amz-Signature 對不上,於是 SignatureDoesNotMatch。
那攻擊者能不能自己「補簽」一個對得上的?不能。 因為要算出正確簽章,必須有正確的 SigningKey,而 SigningKey 要用 secret key 才長得出來——secret key 不在網址裡、也不在攻擊者手上(只有你和 server 兩邊有)。這就是為什麼「改一個字元就壞」不是麻煩,而是這套機制的安全保證:它從數學上鎖死了「這條 URL 只能做當初簽的那一件事」。
(順帶一提,這也解釋了 Level 5 會展開的兩個常見雷:時鐘偏移——產 URL 的機器時間跟 server 差太多會直接 SignatureDoesNotMatch 或提早過期,務必 NTP 對時;以及代理伺服器——中間的 proxy 若偷改了 header 或 query string,也會讓 server 重算對不上。)
拿到一條 pre-signed URL,你現在應該能講完整條鏈:
s3.amazonaws.com、URI 是 /my-bucket/photo.jpg);S3 的 body 用固定字串 UNSIGNED-PAYLOAD,所以上傳換內容仍成立;query string 含除 X-Amz-Signature 外的所有參數。signature = Hex(HMAC-SHA256(SigningKey, StringToSign)),連同 X-Amz-Algorithm / Credential / Date / Expires / SignedHeaders 塞進網址。SignatureDoesNotMatch)、查是否過期、查憑證此刻是否仍有效有權限。它能重算,是因為 HMAC 對稱、server 端本來就存有你這組憑證的 secret 副本。✔ 過關標準:能講出這五點、並說清「為什麼改一個字元就壞」,這一站就過關了。
為了聚焦,下面這些點名 defer:
這站只負責一件事:把 SigV4 的簽章與驗證,從骨子裡講清楚。
UriEncode 規則、CanonicalQueryString 先編碼後排序、CanonicalHeaders 至少 host、UNSIGNED-PAYLOAD for S3、string to sign 四行、4 次鏈式 HMAC 推導 signing key 的確切公式、signature = hash(SigningKey, string-to-sign) hex、query string 範例參數)X-Amz-Expires 最大 604800 秒、CanonicalQueryString 須含除 X-Amz-Signature 外所有參數)SignatureDoesNotMatch 成因:時鐘偏移需 NTP / proxy 竄改 / 參數須逐字相符、403 = 產生者缺權限)