[RFC] URL-безопасный Base64 против стандартного 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, state-токенах OAuth, 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-форм), / — это разделитель пути, а = — зарезервированный поддлелиметр. Если поместить в URL стандартную Base64-строку со всеми тремя, разные парсеры будут интерпретировать её по-разному. Процентное кодирование (+ → %2B, / → %2F, = → %3D) технически спасает, но результат получается раздутым и уродливым.
RFC 4648 §5 решает проблему, подобрав два ASCII-символа без специального значения в URL: - (дефис) и _ (подчёркивание). Оба не зарезервированы по RFC 3986 и без изменений проходят через любой URL-парсер. Паддинг = тоже проблемен в некоторых URL-контекстах (это разделитель ключ/значение в query-строке), поэтому в 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)
- > • code_challenge / PKCE в OAuth 2.0 (RFC 7636)
-
>
• параметров
stateиnonceв OpenID Connect - > • магических ссылок, инвайт-кодов, коротких URL-токенов
- > • значений cookie, которые могут попасть в URL
-
>
• имён файлов, построенных из хэшей (избегайте
/в именах файлов!) - > • DNS-меток и TXT-записей (дефис допустим, слэш — нет)
- > • ключей content-addressable хранилища (системы вроде 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 нет хвостовых =. Code challenges в OAuth (RFC 7636) также запрещают паддинг.
Декодеры должны уметь работать и с тем и с другим. Либеральный декодер отбрасывает хвостовые = и добавляет паддинг внутри, если длина не кратна 4. Строгий декодер отвергает всё, что не выровнено по 4. Если вы пишете энкодер, подстраивайтесь под ожидания приёмника. Если пишете декодер — будьте щедрыми.
Сколько паддинга добавлять при декодировании base64url без паддинга? Правило такое: если длина входа mod 4 равна 2, добавьте два =; если 3 — один =; если 0 — ничего; если 1 — вход некорректен (валидная Base64-строка не может иметь такую длину mod 4).
// 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-safe-вход, декодирует большинство символов корректно, но
-и_не входят в его алфавит. В зависимости от реализации он либо бросит исключение, либо проигнорирует их как мусор, либо тихо выдаст неверные байты. - > Несоответствие длины — декодер видит строку, длина которой не кратна 4 (потому что паддинг отброшен), и сразу отвергает её. Частая боль с JWT-библиотеками, которые ожидают вход с паддингом.
-
>
Сюрпризы процентного кодирования — если вы случайно положили стандартный Base64 в URL, а мидлварь процентно закодировала только
+и=, получится payload со смешанным кодированием: валиден для одних декодеров и мусор для других. -
>
Отказ cookie — ряд HTTP-серверов отвергает cookie с
/или=в значении, даже если спецификация это допускает. URL-безопасный Base64 обходит это полностью. -
>
Падения по именам файлов в Windows — стандартный вывод Base64 содержит
/, а это разделитель пути. Именование файла по его SHA-256 в стандартном Base64 ломается и в Windows, и в POSIX. -
>
Порча при копировании — некоторые терминалы и мессенджеры переносят слова по символу
/, незаметно разбивая стандартную Base64-строку на несколько строк. URL-безопасный вариант эту проблему не порождает.
// КЕЙС-СТАДИ: JWT
JSON Web Token выглядит как header.payload.signature — три строки base64url, разделённые точками. Все крупные JWT-библиотеки (jose, jsonwebtoken, PyJWT, golang-jwt, java-jwt) кодируют именно в base64url без паддинга, потому что токены регулярно оказываются в URL и HTTP-заголовках.
Если вы декодируете JWT руками через atob() в браузере, сначала нужно конвертировать: заменить - на +, _ на / и добавить недостающие =. Библиотеки вроде 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 — так написано в спецификации.
URL-безопасный Base64 внутри data URI формально нарушает RFC 2397 и может быть отвергнут строгими парсерами. Для data URI придерживайтесь стандартного Base64. URL-safe нужен только в одном случае: если сам data URI затем встраивается в другой URL как query-параметр — тогда целесообразнее процентно закодировать всё целиком.
// BASE64URL — ЭТО НЕ URL-КОДИРОВАНИЕ
Стоит проговорить явно, потому что названия запутывающе похожи. URL-кодирование (оно же процентное кодирование, RFC 3986) превращает зарезервированные символы в последовательности %XX — именно это происходит с пробелом, когда вы вводите его в адресной строке (%20).
URL-безопасный Base64 — это разновидность Base64, спроектированная так, чтобы URL-кодирование не требовалось. Он производит вывод, уже безопасный для URL, поэтому процентное кодирование не нужно. Вы никогда не применяете оба подряд; вы выбираете одно или другое в зависимости от данных.
Правило: если данные бинарные (изображение, хэш, подпись) и их надо положить в URL, берите base64url. Если данные уже текстовые (значение query-параметра, поле формы), применяйте процентное кодирование.
// ДЕРЕВО РЕШЕНИЙ
- > Строка Base64 пойдёт в URL, значение cookie, имя файла, DNS-метку, JWT или OAuth-поток? → URL-безопасный Base64.
- > Пойдёт в тело письма, PEM-файл, HTTP Basic Auth, data URI или MIME multipart? → Стандартный Base64.
- > Потребитель — чужая библиотека, которой вы не управляете (JWT-парсер, OAuth-провайдер, S/MIME-клиент)? → Подстраивайтесь под их ожидания; читайте спецификацию или документацию библиотеки.
- > Не уверены? → Берите URL-безопасный без паддинга. Это самый переносимый вариант; при необходимости его всегда можно дополнить паддингом для строгих приёмников.
// ПОПРОБУЙТЕ САМИ
Наш кодировщик Base64 по умолчанию выдаёт стандартный Base64 с переключателем на URL-safe. Наш кодировщик base64url по умолчанию URL-safe без паддинга. Используйте их бок о бок, чтобы увидеть, какие именно символы меняются.
Дополнительное чтение:
• Что такое Base64 и как он работает? — 6-битная группировка, лежащая в основе обоих вариантов.
• Base64 в JavaScript (браузер + Node) — хелперы для конкретных рантаймов.
• Base64, Base64URL и Base32 в сравнении — когда ни один из вариантов Base64 не подходит.