[RFC] 8 мин чтения

[RFC] URL-безопасный Base64 против стандартного Base64

Стандартный Base64 и URL-безопасный Base64 выглядят почти одинаково, но ломают декодеры друг друга. Вот что именно меняется, почему, и какой вариант нужен для JWT, OAuth и URL.

Апрель 2026 | standards

// КРАТКО

Существует два алфавита 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 не подходит.