[RFC] Base64 URL 安全版 vs 標準 Base64
標準 Base64 與 URL 安全 Base64 看起來幾乎一樣,卻會互相破壞對方的解碼器。以下說明到底哪裡改變、為什麼改變,以及 JWT、OAuth 與 URL 各該用哪一種變體。
// 懶人包
有兩種 Base64 字母表,都在 RFC 4648 中規範。
• 標準 Base64(§4)—— 字元為 A–Z a–z 0–9 + /,並以 = 填充。適用於 MIME、PEM 檔、HTTP Basic Auth、電子郵件。一旦放進 URL、cookie、檔名或 DNS 標籤就會出問題。
• URL 安全 Base64,有時寫成 base64url(§5)—— 字元為 A–Z a–z 0–9 - _,填充通常省略。適用於 URL、JWT 的各段、OAuth state 權杖、cookie、DNS、檔名。
一種字母表的解碼器會拒絕另一種的輸出。選擇符合你的傳輸管道的變體並固定使用。
// 兩種字母表對照
// Position 62 and 63 are the only differences
//
// Standard (RFC 4648 §4) URL-safe (RFC 4648 §5)
// 0–25 A-Z A-Z
// 26–51 a-z a-z
// 52–61 0-9 0-9
// 62 + - (hyphen)
// 63 / _ (underscore)
// pad = = or omitted
// Worked example: encoding 0xFB 0xFF 0xBF
// binary: 11111011 11111111 10111111
// 6-bit: 111110 111111 111110 111111
// values: 62 63 62 63
// Standard: + / + / → '+/+/'
// URL-safe: - _ - _ → '-_-_'
// 為什麼需要 URL 安全版
標準字母表是為 MIME 郵件內容設計的,那是寬鬆的環境。URL 就不是了。在 RFC 3986 中,+ 有時會被解碼成空白(繼承自 HTML 表單),/ 是路徑分隔符,而 = 是保留的子分隔符。如果把同時含有這三者的標準 Base64 字串塞進 URL,不同的剖析器對它的解讀會不一致。百分比編碼在技術上能解決問題(+ → %2B、/ → %2F、= → %3D),但結果既臃腫又醜陋。
RFC 4648 §5 透過挑選兩個在 URL 中沒有特殊意義的 ASCII 字元來解決這個問題:-(連字號)與 _(底線)。兩者依 RFC 3986 都屬於未保留字元,能完整通過每個 URL 剖析器。填充字元 = 在部分 URL 情境下也很棘手(它是查詢字串中鍵/值的分隔符),因此 base64url 依慣例將其省略。
// 何時用哪一種
- > 標準 Base64 —— 用於:
- > • MIME 電子郵件附件(RFC 2045)
- > • PEM 格式的憑證與金鑰(RFC 7468)
-
>
• HTTP Basic Auth 標頭(
Authorization: Basic ...) -
>
• data: URI(
data:image/png;base64,...)——+/=這些字元在引號屬性中都合法 - > • XML-DSIG 與 XML-ENC 簽章
- > • S/MIME
- > URL 安全 Base64 —— 用於:
- > • JWT 標頭、酬載與簽章(RFC 7515 強制使用 base64url)
- > • OAuth 2.0 code_challenge / PKCE(RFC 7636)
-
>
• OpenID Connect 的
state與nonce權杖 - > • 魔法連結、邀請碼與短網址權杖
- > • 可能被複製進 URL 的 cookie 值
-
>
• 由雜湊值產生的檔名——檔名中避免
/! - > • DNS 標籤與 TXT 紀錄(允許連字號,不允許斜線)
- > • 以內容定址的儲存金鑰(類 IPFS 系統)
// 兩者之間的轉換
由於兩種字母表只在位置 62、63 與填充上不同,轉換就是三個字元的尋找替換。以下是你可能用到的各種執行環境寫法。
// JavaScript (browser + Node)
const toUrlSafe = (b64) =>
b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const fromUrlSafe = (b64u) => {
let s = b64u.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '='; // re-add padding
return s;
};
// Node 16+ native
const url = Buffer.from(data).toString('base64url');
const std = Buffer.from(url, 'base64url').toString('base64');
// Python
import base64
url = base64.urlsafe_b64encode(data).rstrip(b'=') # no padding
std = base64.b64encode(data)
// Go
import "encoding/base64"
url := base64.RawURLEncoding.EncodeToString(data) // no padding
std := base64.StdEncoding.EncodeToString(data)
// PHP
$url = rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
$std = base64_encode($data);
// Java
String url = Base64.getUrlEncoder().withoutPadding().encodeToString(data);
String std = Base64.getEncoder().encodeToString(data);
// Shell (GNU coreutils 9+)
echo -n 'hello' | base64 --wrap=0 | tr '+/' '-_' | tr -d '='
// 填充的問題
標準 Base64 永遠會把輸出以 = 填充到 4 的倍數。URL 安全 Base64 通常省略填充(RFC 4648 §5 明確指出此為可選)。JWT(RFC 7515)完全禁止填充——eyJhbGciOiJIUzI1NiJ9 結尾沒有 =。OAuth code challenge(RFC 7636)同樣禁止填充。
解碼器必須能處理兩者。寬鬆的解碼器會剝除結尾的 =,並在酬載長度不是 4 的倍數時於內部補上填充。嚴格的解碼器則會拒絕任何不是 4 對齊的輸入。如果你在寫編碼器,請符合接收端的期待;如果你在寫解碼器,請寬容一些。
解碼無填充的 base64url 時要補多少填充?規則是:若輸入長度 mod 4 為 2,加兩個 =;若為 3,加一個 =;若為 0,不用加;若為 1,則輸入無效(沒有合法 base64 的長度會是 mod 4 為 1)。
// Restore padding before decoding a JWT segment
function addPadding(b64u) {
const mod = b64u.length % 4;
if (mod === 2) return b64u + '==';
if (mod === 3) return b64u + '=';
if (mod === 0) return b64u;
throw new Error('Invalid base64url length');
}
// 混用時會發生什麼事
-
>
無聲的資料毀損 —— 把 URL 安全酬載餵給標準解碼器時,大部分字元會被正確解碼,但
-與_不在它的字母表中。依實作不同,它可能丟例外、可能當成垃圾跳過,也可能默默產生錯誤位元組。 - > 長度對不上 —— 解碼器看到長度不是 4 的倍數(因為填充被去掉)的字串,直接拒收。JWT 函式庫若期望有填充的輸入,這是常見的痛點。
-
>
百分比編碼的意外 —— 若你意外地把標準 Base64 放進 URL,而中介層只對
+與=做百分比編碼,你就得到一個混合編碼的酬載:某些解碼器看來合法,對另一些解碼器則是垃圾。 -
>
Cookie 被拒絕 —— 有些 HTTP 伺服器會拒絕值中含有
/或=的 cookie,即使規格上其實允許。URL 安全 Base64 完全避開這個問題。 -
>
Windows 檔名失敗 —— 標準 Base64 輸出包含
/,它是路徑分隔符。用檔案的 SHA-256 標準 Base64 命名,會在 Windows 與 POSIX 上都失敗。 -
>
複製貼上毀損 —— 某些終端機與聊天 App 會在
/上自動換行,導致標準 Base64 字串無形中跨行斷開。URL 安全版不會引發這個狀況。
// 真實案例:JWT
一個 JSON Web Token 長得像 header.payload.signature,是以點號分隔的三個 base64url 字串。每個主流 JWT 函式庫(jose、jsonwebtoken、PyJWT、golang-jwt、java-jwt)都用 base64url 編碼且不含填充,因為這些權杖經常被放進 URL 與 HTTP 標頭。
如果你在瀏覽器中用 atob() 手動解碼 JWT,必須先轉換:把 - 換回 +、_ 換回 /,並補回缺少的 =。像 jose 這類函式庫會在內部處理好這些。
// Decode a JWT payload in the browser
function decodeJwt(token) {
const [, payloadB64u] = token.split('.');
let b64 = payloadB64u.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const json = atob(b64);
return JSON.parse(json);
}
// Example JWT payload
decodeJwt('eyJhbGciOiJIUzI1NiJ9' +
'.eyJzdWIiOiJhYmMxMjMifQ' +
'.sig').sub; // 'abc123'
// DATA URI:一個邊界情境
Data URI(data:image/png;base64,...)技術上是 URL,但依 RFC 2397 它們接受完整的標準 Base64 字母表,包含 + 與 /。這就是為什麼你到處看到 data URI 裡是標準 Base64——這是規格規定。
在 data URI 中使用 URL 安全 Base64 技術上違反 RFC 2397,可能被嚴格的剖析器拒絕。data URI 請固定用標準 Base64。URL 安全對 data URI 唯一會有影響的情境,是 URI 本身又被嵌入另一個 URL 當作查詢參數——那時你應該對整個 URI 做百分比編碼,而不是混用。
// BASE64URL 不是 URL 編碼
值得明確澄清,因為名稱容易混淆。URL 編碼(也稱為百分比編碼,RFC 3986)會把保留字元轉成 %XX 序列——當你把空白輸入進瀏覽器網址列時就是這樣(%20)。
URL 安全 Base64 是一種Base64 的變體,設計上就不需要再做 URL 編碼。它產生的輸出本身就 URL 安全,所以不需要百分比編碼。你永遠不會把兩者依序套用;應依資料情境擇一使用。
經驗法則:如果資料是二進位(圖片、雜湊、簽章)而你要把它放進 URL,使用 base64url。如果資料本身已是文字(查詢參數值、表單欄位),使用百分比編碼。
// 決策樹
- > Base64 字串要進入 URL、cookie 值、檔名、DNS 標籤、JWT 或 OAuth 流程嗎?→ URL 安全 Base64。
- > 要進入電子郵件內容、PEM 檔、HTTP Basic Auth 標頭、data URI 或 MIME 多段內容嗎?→ 標準 Base64。
- > 消費端是你無法控制的函式庫(JWT 剖析器、OAuth 提供者、S/MIME 客戶端)嗎?→ 配合它們的期待;讀規格或函式庫文件。
- > 不確定?→ 使用沒有填充的 URL 安全版。它是最可攜的變體;之後若接收端嚴格,你隨時可以補上填充。
// 親手試試
我們的 Base64 編碼器預設輸出標準 Base64,並提供切換成 URL 安全輸出的開關。我們的 base64url 編碼器則預設輸出無填充的 URL 安全形式。你可以並排使用,親眼看清楚哪些字元不同。
延伸閱讀:
• 什麼是 Base64?它是如何運作的? —— 兩種變體共同基礎的 6-bit 分組。
• JavaScript(瀏覽器 + Node)中的 Base64 —— 執行環境相關的輔助函式。
• Base64 vs Base64URL vs Base32 —— 當所有 Base64 變體都不是正解時。