Зачем Base64 дополнение
Base64 превращает 3 байта в 4 символа (весь алгоритм разобран в статье как работает Base64). Если длина ввода не кратна 3, последний блок получается неполным:
| Байтов в последнем блоке | Символов данных | Дополнение | Пример |
|---|---|---|---|
| 3 | 4 | нет | Man → TWFu |
| 2 | 3 | = | Ma → TWE= |
| 1 | 2 | == | M → TQ== |
= сохраняет длину результата кратной 4. Это облегчало жизнь декодерам эпохи MIME: они читали ввод фиксированными группами по 4 символа и знали, где кончаются данные, не отслеживая общую длину.
Строго говоря, эта информация избыточна. Последняя группа из 3 символов может означать только 2 байта, а группа из 2 символов только 1 байт, так что декодеру, который считает символы, = вообще не нужен. Поэтому Base64 без дополнения возможен, и поэтому многие форматы им пользуются.
Есть один случай, когда дополнение несёт настоящую информацию: склейка. Если соединить две строки с дополнением, = в середине показывает, где кончается первая. Правда, декодеры справляются с этим плохо. На TQ==TQ== (два закодированных M) стандартный декодер Python вернул b'M\x04\xd0', Buffer в Node вернул только M, а atob выбросил исключение. Лучше декодировать каждый кусок отдельно.
Когда дополнение необязательно
RFC 4648 §3.2 требует (MUST), чтобы кодеры добавляли дополнение, если использующая Base64 спецификация явно не говорит иного. Некоторые говорят:
- JWT и JOSE (RFC 7515) используют Base64URL без дополнения. Заголовок JWT вроде
eyJhbGciOiJIUzI1NiJ9не содержит=, даже если длина не кратна 4. См. Base64 в JWT. - URL и имена файлов часто содержат Base64URL без дополнения, потому что
=в строке запроса имеет своё значение и его пришлось бы экранировать как%3D. Полностью разница описана на странице Base64 и Base64URL. - WebAuthn и OAuth PKCE (
code_challengeиз RFC 7636) тоже используют Base64URL без дополнения.
Стандартный Base64 в почте, PEM-файлах, data URI и большинстве JSON API идёт с дополнением. Если сомневаетесь, выдавайте стандартный Base64 с дополнением, а Base64URL без него, и делайте свой декодер терпимым к обоим вариантам.
Декодеры, которым нужно дополнение
Поведение различается сильнее, чем принято думать. Вот результаты декодирования TWE (Ma без дополнения) стандартным декодером каждой библиотеки:
| Язык / инструмент | Вызов | Результат для TWE |
|---|---|---|
| Python 3.13 | base64.b64decode | binascii.Error: Incorrect padding |
| Python 3.13 | base64.urlsafe_b64decode | тоже Incorrect padding |
| .NET | Convert.FromBase64String | FormatException |
| Go | base64.StdEncoding | ошибка; для ввода без дополнения нужен RawStdEncoding |
| Go | base64.RawStdEncoding | Ma, но отвергает TWE= |
| Ruby | Base64.strict_decode64 | ArgumentError: invalid base64 |
| Ruby | Base64.decode64, urlsafe_decode64 | Ma |
| Java 21 | Base64.getDecoder().decode | Ma |
| Node.js | Buffer.from(s, 'base64'), atob | Ma |
| PHP 8.4 | base64_decode (и в строгом режиме) | Ma |
| GNU coreutils 9.7 | base64 -d | Ma |
| OpenSSL 3.5 | openssl base64 -d | пустой вывод, код выхода 0 |
Строка про OpenSSL заслуживает внимания. Он ничего не вывел и всё равно сообщил об успехе, так что скрипт, который пропускает Base64 без дополнения через openssl base64 -d, молча создаёт пустой файл. В тех же тестах он ничего не вернул и для одной строки без завершающего перевода строки; этот случай исправил флаг -A.
Go строже всех в обратную сторону: RawStdEncoding отказывается от строки, в которой дополнение есть. Если вы принимаете ввод из разных источников, убирайте = и используйте raw-вариант либо добавляйте дополнение и используйте стандартный.
Примеры для конкретных языков есть на страницах с кодом, например Base64 в Python и Base64 в Go.
Как вернуть пропавшее дополнение
Правило зависит только от длины строки после удаления пробельных символов:
len % 4 | Что дописать | Что это значит |
|---|---|---|
| 0 | ничего | блоки полные |
| 2 | == | в последнем блоке 1 байт |
| 3 | = | в последнем блоке 2 байта |
| 1 | исправить нельзя | строка обрезана или в ней лишний символ |
Остаток 1 никогда не бывает корректным. Один символ Base64 несёт 6 бит, а этого мало для байта, так что никакое дополнение не сделает строку декодируемой. Если вы это видите, ищите пропущенный символ, лишний символ или посчитанный перевод строки.
В большинстве языков исправление укладывается в одно выражение:
import base64
s = "eyJzdWIiOiIxMjM0In0" # 19 chars, 19 % 4 == 3
padded = s + "=" * (-len(s) % 4) # adds one "="
print(base64.urlsafe_b64decode(padded))
# b'{"sub":"1234"}'
const s = "eyJzdWIiOiIxMjM0In0";
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
// "eyJzdWIiOiIxMjM0In0="
Валидатор Base64 показывает длину строки и состояние дополнения, а починка Base64 за один шаг добавляет недостающие =, убирает пробелы и кавычки и переводит URL-safe символы.
Неканонические кодировки
У дополнения есть менее заметный родственник: неиспользуемые биты в последнем символе данных. При кодировании Ma его 16 бит заполняют две с половиной 6-битные группы. Третий символ несёт последние 4 бита a плюс 2 бита-заполнителя, и RFC 4648 §3.5 требует, чтобы кодер обнулял эти биты. Так получается E (000100).
Декодер биты-заполнители игнорирует, поэтому TWF= (F здесь равен 000101, биты-заполнители 01) декодируется в тот же Ma. В наших тестах Python, Node, Java и PHP приняли TWF= и вернули Ma. Точно так же TR== декодируется в M, как и TQ==.
Такие строки, как TWF=, называют неканоническими. Они важны в нескольких местах:
- Подписи и хеши над текстом Base64. Две разные строки декодируются в одни и те же байты, поэтому сравнение строк Base64 для проверки равенства данных может дать неверный ответ. Сравнивайте декодированные байты.
- Дедупликация и ключи кеша. Та же проблема: нормализуйте, декодируя и кодируя заново.
- Строгие парсеры. RFC 4648 разрешает декодерам отвергать ненулевые биты-заполнители.
StdEncoding.Strict()в Go и GNUbase64 -dотвергаютTWF=.
Похожая странность бывает с лишним дополнением. В TWE== на один = больше нужного. Python и Buffer в Node его приняли, а atob выбросил InvalidCharacterError.
Дополнение при кодировании частями
Если кодировать большой файл в Base64 кусками, каждый кусок, кроме последнего, должен быть кратен 3 байтам. Иначе каждый кусок получит своё дополнение, и склеенный результат будет неверным. Кодирование hello world!! кусками по 4 байта дало aGVsbA==byB3bw==cmxkIQ==IQ==, а кусками по 3 байта получилось aGVsbG8gd29ybGQhIQ==, ровно как при кодировании всего сразу. Для переноса строк в MIME удобнее всего брать куски по 57 байт на строку, потому что 57 байт превращаются ровно в 76 символов.
Вставьте строку с пропавшим или испорченным дополнением и получите корректный Base64.
Вопросы и ответы
Можно ли просто удалить знаки = из Base64?
Данные не пострадают: длина строки и так говорит декодеру, сколько байтов в последнем блоке. Декодируется ли результат, зависит от получателя: Base64URL в JWT ожидает строку без дополнения, а b64decode в Python и Convert.FromBase64String в .NET такую строку отвергают.
Почему в конце моей строки Base64 нет =?
Либо длина ввода была кратна 3 байтам и дополнение не понадобилось, либо источник использует Base64 без дополнения, что обычно для Base64URL в токенах и URL.
Что дописать к строке, если Python пишет Incorrect padding?
Перед декодированием допишите '=' * (-len(s) % 4). Если строка URL-safe, используйте ещё и base64.urlsafe_b64decode. Если длина по модулю 4 равна 1, дополнение не поможет: строка обрезана или в ней лишний символ.
Может ли строка Base64 заканчиваться тремя знаками =?
Нет. В последнем блоке остаётся 1 или 2 байта, и они дают == или =. Три = означали бы блок без данных, а такого не бывает.