Base64 и URL-кодирование: зачем они нужны и где их применяют неправильно

Опубликовано 2026-04-13 7 мин чтения

Резюме (TL;DR)

echo -n "Hi" | base64 вернёт ровно 4 символа (SGk=). На вход пришли 2 байта (16 бит), они разрезаны по 6 бит и развёрнуты в 4 выходных символа, причём последний слот добит одним паддингом = — на этом битовая арифметика заканчивается. Стоит только расширить это простое правило до «давайте на всякий случай обернём всё в Base64», как появляется и цена: отношение «3 байта входа → 4 символа выхода» даёт почти 33 % раздувания, и в одном продакшене JPEG на 1,2 МБ, обёрнутый в Base64, превращался в 1,64 МБ. Кодирование — это не шифрование и не сжатие: это правило, по которому данные из одного алфавита записываются в другом, чтобы пройти через канал, который исходный алфавит не пропускает. Base64 (RFC 4648 §4) создан для каналов, допускающих только текст: тело письма, строковые поля JSON, inline-изображения. Base64URL (тот же RFC, §5) заменяет два символа (+-, /_) и безопасно ложится в URL, имена файлов и JWT. Процентное кодирование (RFC 3986) — отдельный инструмент: оно оборачивает в %XX ASCII-символы, которые значат что-то в синтаксисе URL (?, &, #, пробел, не-ASCII), чтобы пометить их как данные. Выбирайте под канал: если канал понимает бинарные данные — отдавайте их как есть; для текстового канала — Base64; в URL-контексте — Base64URL; внутри URL — процентное кодирование. И ещё: алфавит Base64 открыт, так что любую заметную Base64-строку может расшифровать кто угодно — использовать это как средство секретности нельзя.

Предыстория и концепции

Каналы только для текста существовали задолго до того, как в них стали возить изображения. Электронная почта исторически предполагала 7-битный ASCII и иногда портила байты с установленным старшим битом; HTTP-заголовки до сих пор запрещают некоторые символы; строковые поля JSON хранят UTF-8, но не тащат в себе сырые управляющие байты 0x00–0x1F. Base64, стандартизованный RFC 4648, решает это: 3 входных байта (24 бита) режутся ровно по 6 бит и раскладываются в 4 выходных символа. Алфавит из 64 знаков: A-Z (0–25), a-z (26–51), 0-9 (52–61), + (62), / (63) плюс паддинг =, чтобы длина была кратна 4. 1 входной байт даёт XX== (8 → 6 + 2 бита), 2 байта — XXX= (16 → 6 + 6 + 4 бита), 3 байта — XXXX (24 → 6 + 6 + 6 + 6 бит). Если входная длина кратна 3, паддинг не появится.

Вариант URL-safe меняет только индексы 62 и 63 алфавита: +-, /_. Причина прозрачная: в форм-сабмитах + раньше интерпретировался как пробел, а / — это разделитель пути. В URL-контексте часто опускают и паддинг = — именно в таком виде Base64URL живёт в JWT (RFC 7519), чтобы трёхчастная структура «заголовок.payload.подпись» компактно помещалась в заголовок Authorization: Bearer ....

Процентное кодирование (его же часто называют URL-encoding) — отдельная история. RFC 3986 делит ASCII на две группы. Незарезервированные символы (A-Z, a-z, 0-9, -, ., _, ~) можно использовать как есть. Зарезервированные (:, /, ?, #, [, ], @, !, $, &, ', (, ), *, +, ,, ;, =) несут в URL синтаксический смысл, и если они нужны как данные, их надо кодировать. Остальные байты — включая UTF-8 для не-ASCII — тоже пишутся в шестнадцатеричном виде %XX. В JavaScript encodeURIComponent агрессивно кодирует и зарезервированные символы, чтобы безопасно попасть внутрь одного компонента; encodeURI предполагает, что обрабатывает URL целиком, и ведёт себя более мягко.

Эти три схемы нельзя взаимозаменять, даже если их выходные символы визуально похожи. Процентно-кодированный query-параметр — это всё ещё ASCII, описывающий другой ASCII, и если его отдать Base64-декодеру, получится бессмысленный набор байтов. А если стандартную Base64-строку с / воткнуть прямо в путь URL, путь нарежется не там — мой коллега как-то потерял на этом половину рабочего дня. Половина боевых багов с кодированием вырастает именно из такой путаницы.

Сравнение и данные

ПунктBase64 (стандартный)Base64URLПроцентное кодирование
Когда применятьТекстовые каналы, не пропускающие бинарник (MIME-тело, байты внутри JSON)То же, но для URL, имён файлов, JWTЗарезервированные и не-ASCII символы внутри одного компонента URL
АлфавитA–Z a–z 0–9 + / =A–Z a–z 0–9 - _ (паддинг опционален)Незарезервированные, остальное — %XX
Накладные расходыОколо 33 % (3 байта → 4 символа), в MIME дополнительно переносы по 76 символовОколо 33 %, обычно без паддингаПеременные: 1 UTF-8 байт → 3 символа %XX
Частые поломки+ и / внутри URL, пропущенный паддинг у строгих декодеровПодача в декодер, ждущий стандартные +/=Двойное кодирование уже кодированной строки
Типичные задачиВложения MIME, data URI, бинарь в JSONJWT, URL-safe ID, короткие ссылкиQuery-параметры, сегменты пути, form-body

Эти числа нельзя игнорировать. Картинка на 1 МБ в Base64 становится примерно 1,37 МБ, а MIME с переносами строки каждые 76 символов добавляет сверху ещё около 2 %. В HTTP-ответе это раздувание бьёт и по полосе сервера, и по клиентскому парсеру. Процентное кодирование на уровне строк обычно мягче, но CJK-текст сильно раздувает. Один корейский иероглиф в UTF-8 занимает 3 байта, которые превращаются в 9 ASCII-символов (%XX × 3). Слово «안녕» в URL станет %EC%95%88%EB%85%95 — 18 байтов.

Практические сценарии

Сценарий 1 — вложение в электронном письме. PDF едет внутри MIME-части с Content-Transfer-Encoding: base64. Почтовый клиент прогоняет файл через Base64, разбивает строки по классическим 76 символам и отправляет. На стороне получателя выполняется обратная операция. «Предположение о тексте» в SMTP по сути сделало Base64 стандартом в этой области; даже при наличии расширений 8BITMIME и BINARYMIME множество серверов до сих пор ради надёжности используют Base64. Лишние 33 % принимаются как плата за гарантированную доставку.

Сценарий 2 — JSON Web Token. JWT (RFC 7519) имеет структуру «заголовок.payload.подпись», и каждый сегмент — Base64URL без паддинга. Благодаря URL-safe алфавиту токен без лишнего экранирования помещается в заголовок Authorization, query-параметр, строку лога. Отсутствие паддинга помогает держать его покороче. Одно предупреждение: любой, кто декодирует JWT, прочитает его claims. Безопасность обеспечивает не кодирование, а подпись (HMAC, RSA). Не кладите секреты в payload.

Сценарий 3 — data URI для маленьких картинок. Запись вида background-image: url("data:image/png;base64,iVBORw0K...") встраивает PNG прямо в CSS. Для крохотных иконок это экономит сетевой роунд-трип, но с учётом 33 % раздувания Base64 и потери браузерного кэширования и параллельных запросов выгодно только для очень мелких ассетов, где стоимость HTTP-запроса выше. По моему опыту, точка безубыточности — район 1–2 КБ; дальше отдельный файл или SVG обычно быстрее.

Сценарий 4 — загрузка аватара. Частый антипаттерн: FileReader.readAsDataURL читает файл, превращает его в строку data:image/png;base64,... и POST-ит это полем JSON. Работает, но почти никогда не является правильным выбором. Payload распухает на 33 %, серверу приходится отдельно декодировать Base64 перед записью на диск, а в памяти и у клиента, и у сервера живёт строковая копия. Я видел случай, когда картинка на 5 МБ превращалась в JSON на ~6,7 МБ и падала по таймауту на мобильной сети; стоило переключиться на multipart/form-data, и тот же файл поехал своими 5 МБ. Base64 здесь оправдан только тогда, когда транспорт действительно требует текст — например, сторонний API принимает лишь строковые поля JSON.

Распространённые заблуждения

«Base64 — это шифрование». Нет. Соответствие между байтами и символами открыто в RFC 4648, алфавит фиксирован, и любой декодер вернёт исходные байты. Кодирование защищает транспорт, а не содержимое. Для чувствительных данных сначала зашифруйте, а потом обёрните шифртекст в Base64 — уже для транспорта.

«URL-кодирование нужно только для не-ASCII». Зарезервированные ASCII-символы тоже требуют кодирования, если используются как данные. Оставьте & как есть — и сервер прочитает дальше как новый параметр; оставите # — и всё после него превратится во фрагмент. Правило опирается не на «алфавит», а на структуру.

«В HTTP всегда безопаснее Base64». HTTP прекрасно возит бинарные тела: Content-Type: application/octet-stream, chunked-transfer, произвольные байты. Base64 — это обходной путь для каналов, которые бинарь не умеют; а если канал уже умеет байты, добавлять 33 % сверху — это просто налог. Для загрузки файлов используйте multipart/form-data или сырое тело; Base64 — только там, где канал действительно требует текст: поле JSON, параметр URL, тело MIME.

«Base64 и Base64URL взаимозаменяемы». Нет. Строгий Base64-декодер, ждущий +/, упадёт или выдаст мусор на URL-safe строке. Библиотеки обычно поддерживают оба варианта, но соглашайте энкодер и декодер от конца до конца. Node Buffer.from(s, 'base64') принимает оба алфавита, но стандартные библиотеки не во всех языках так же либеральны.

Чек-лист

  1. Канал только 7-битный или структурированный текст? Тело письма, байты внутри JSON, data URI в CSS — это Base64.
  2. Значение попадает в URL, имя файла, JWT? Base64URL; наличие паддинга согласуйте с потребителем.
  3. Само значение — компонент URL? Нужно процентное кодирование для соответствующих частей.
  4. Данные большие? Сначала проверьте, действительно ли канал требует текст. Сырые байты тела сэкономят 33 %.
  5. Декодер строгий или мягкий? Согласуйте стандарт/URL-safe алфавит сквозным образом.
  6. Цель — сохранить тайну? Сначала шифруйте. Кодирование секретностью никогда не является.

Связанный инструмент

Base64-энкодер/декодер Patrache Studio обрабатывает и стандартную, и URL-safe версии локально, так что исходные байты не покидают браузер — удобно, когда нужно взглянуть на токен или маленький ключ. Если вы работаете с JWT, посмотрите JSON: форматирование, валидация и JSON Schema в реальной работе — там показано, как аккуратно проверять декодированные claims. Если Base64URL у вас — это короткий ID, производный от UUID, загляните в Сравнение UUID v1/v4/v7 и дизайн первичного ключа, чтобы понять, почему свойства сортировки и индексации исходных байтов сохраняются и здесь.

Источники