Приходится иметь дело с форматом Base64? Тогда этот сайт идеально вам подойдет! Воспользуйтесь нашим невероятно удобным онлайн-инструментом для кодирования или декодирования ваших данных.

Декодирование Base64 в JavaScript/Node.js: полное руководство

Ваше приложение получает Base64-строку. Это может быть заголовок Authorization входящего запроса, поле внутри JSON-пелода, изображение, прячущееся в data URL, или сертификат, вставленный в файл конфигурации. Всё это одно и то же: сырые байты в ASCII-костюме. Эта статья о том, как снять этот костюм в JavaScript и Node.js, и как сделать это, не потеряв по дороге ни одного байта.

Скорое слово о самом формате: Base64 - это текстовое кодирование, которое превращает каждые три входных байта в четыре печатаемых символа. Главная страница этого сайта подробно объясняет алфавит, математику и заполнение, поэтому здесь об этом - одно предложение. Одно следствие стоит держать в кармане: закодированные данные примерно на 33 процента больше, чем те байты, которые они несут, а значит, декодирование - это сжимающая операция, и ничего в этой статье не добавляет и не отнимает никакой секретности. Вы распаковываете, а не вскрываете.

Хорошие новости: устанавливать нечего. Браузеры отдают atob() уже два десятилетия, в Node.js есть класс Buffer со встроенным режимом base64, а современные рантаймы теперь поставляют Uint8Array.fromBase64() - строгого и настраиваемого новобранца из спецификации ES2026. Мастерство - в выборе правильного инструмента для задачи и в точном знании, что именно прощает каждый из них, потому что на сервере вы декодируете данные от незнакомцев, и именно на снисходительности всё идёт наперекосяк.

Выбор декодера

Три API покрывают большую часть работы по декодированию. Они отличаются характером, и это различие - вся история:

Декодер Доступен в Характер
Buffer.from(string, 'base64') Node.js (каждая версия, которая имеет значение) Снисходительный: пропускает неизвестные символы, останавливается на первом =, никогда не бросает исключение
atob(string) Все браузеры, Node.js 16 и новее Строгий: бросает InvalidCharacterError на плохом входе, пропускает ASCII-пробельные символы, прощает отсутствующее заполнение
Uint8Array.fromBase64(string) Chrome 140+, Firefox 133+, Safari 18.2+, Node.js 25+ Настраиваемый: вы выбираете алфавит и то, каким строгим должен быть последний фрагмент

Все три открывают один и тот же классический пелод одним и тем же способом:

// Рабочая лошадка Node.js
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// Легаси-пара (каждый браузер, Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // «hello world», как бинарная строка
// Современный метод ES2026 (Chrome 140+, Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"

Одно предупреждение, прежде чем вы начнёте опираться на atob(): она возвращает строку, но бинарную строку, строку, где каждый символ носит один сырой байт в виде кодовой точки от 0 до 255. Вывести одну - без проблем. А вот сохранив её в JSON, в базу данных или в куки, вы отправляете эти сырые байтовые значения в путешествие с собой, поэтому сразу после декодирования превратите их в настоящие байты или настоящий текст.

Снисходительный декодер и то, что он глотает

Buffer в Node - это снисходительный читатель, и это меч с двумя лезвиями. Он прекрасен для данных, которые путешествовали по трудным дорогам: MIME-почта с её переводами строк, строки, скопированные вручную, вывод логов с заблудившимися пробелами. Он опасен для данных, которые вы не создавали сами, потому что никогда не жалуется. Вот что происходит на самом деле:

Вход Что делает Buffer.from(input, 'base64')
'!!!' Возвращает пустой Buffer. Весь мусор пропускается, ничего не декодируется, ошибки нет.
'aGVsbG8== garbage' Возвращает «hello». Первый = завершает декодирование; остальное игнорируется.
'aG!VsbG8' Возвращает «hello». Восклицательный знак пропускается, а не становится ошибкой.
'aGVs=bG8' Возвращает «hel». = посреди строки останавливает шоу на полпути.
'aGVsbG8====' Возвращает «hello». Лишнее заполнение в конце игнорируется.
'=aGVsbG8' Возвращает пустой Buffer. Заполнение перед данными ничего не значит.

Исправление для недоверенного входа - это валидатор, и грамматика Base64 достаточно мала, чтобы уместиться в одном регулярном выражении:

const STRICT = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
function decodeStrict (base64) {
  if (!STRICT.test(base64)) {
    throw new TypeError('Not a valid base64 string');
  }
  return Buffer.from(base64, 'base64');
}
console.log(decodeStrict('aGVsbG8gd29ybGQ=').toString('utf8')); // "hello world"
try {
  decodeStrict('aGVs!bG8');
} catch (error) {
  console.log(error.message); // "Not a valid base64 string"
}

Регулярное выражение проверяет форму: группы по четыре с правильным заполнением. Одно правило, которое оно не может проверить, - это правило канонического кодирования из RFC 4648, которое гласит, что неиспользуемые биты заполнения последней группы должны быть нулями. Строгий режим Uint8Array.fromBase64() проверяет именно это, так что в Node.js 25 или любом современном браузере вы можете пропустить регулярное выражение целиком и позволить платформе провести аудит:

console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // «hello», режим loose прощает отсутствующее заполнение
try {
  Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
  console.log(error.name); // «SyntaxError», биты заполнения не нули
}

У опции lastChunkHandling три настройки, о которых стоит знать. "loose" (по умолчанию) пропускает пробельные символы, принимает отсутствующее заполнение и игнорирует оставшиеся биты заполнения. "strict" требует полную последнюю группу с заполнением, у которой все биты заполнения обнулены. А "stop-before-partial" декодирует только полные группы по четыре символа и оставляет хвостовой фрагмент, чтобы вы донесли его сами, - и именно этот кусочек делает потоковое декодирование приятным, как вы увидите позже в этой статье.

От байтов к тексту: решение о кодировке

Декодирование Base64 выдаёт вам байты. Байты становятся текстом только тогда, когда вы выбираете кодировку, и этот выбор - за вами, обычно исходя из того, что пообещал отправитель. Значение по умолчанию в Node - то, которое вам нужно в большинстве случаев:

const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // два байта C3 A9
console.log(bytes.toString('utf8'));   // «é», два байта сливаются в один символ
console.log(bytes.toString('latin1')); // «Ã©», те же байты, читаемые по одному символу за раз

С UTF-8 есть один подвох: когда последовательность байтов не является корректным UTF-8, Node не бросает исключение. Он подставляет символ замещения Unicode (U+FFFD, ромб с вопросительным знаком) и идёт дальше, что означает: испорченный пелод может проскользнуть через ваш конвейер прямо в вашу базу данных. Настоящий текстовый декодер платформы, TextDecoder (глобальная переменная в Node.js и во всех браузерах), имеет опцию fatal, которая превращает порчу в TypeError, который вы можете поймать:

const stray = new Uint8Array([0xe9]); // один одинокий байт, некорректный UTF-8
console.log(new TextDecoder().decode(stray)); // символ замещения, без ошибки
try {
  new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
  console.log(error.name); // "TypeError"
}

Легаси-системы никогда не умирают, и TextDecoder до сих пор знает, как их читать. Он принимает полную таблицу ярлыков стандарта кодировок WHATWG, так что Base64-пелод из Windows-приложения 1990-х, японского мэйнфрейма или старого FTP-зеркала по-прежнему можно декодировать с ярлыками вроде 'windows-1250', 'shift_jis', 'euc-kr' или 'gb18030', все они без учёта регистра. Один ярлык заслуживает предупреждения, потому что он стоил реального времени на отладку: спецификация привязывает 'iso-8859-1', 'latin1' и даже 'us-ascii' к декодеру Windows-1252. Байт 0x80, управляющий символ в истинном Latin-1, выходит знаком евро:

console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // «€», а не тот Latin-1, о котором вы просили
// Для настоящего побайтового чтения Latin-1 используйте сторону Buffer:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // сырой управляющий символ 0x80

Если вам действительно нужна та сырая привязка, кодировка 'latin1' у Buffer (чей легаси-алиас 'binary', по словам документации Node, - очень вводящее в заблуждение название) сопоставляет байт N с кодовой точкой N без обхода через Windows. Для всего современного безопасная пара - UTF-8 плюс fatal: true.

Вскрываем JWT

Самый частый Base64-пелод, который декодирует JavaScript-сервис, - это JSON Web Token: строка вида xxxxx.yyyyy.zzzzz, едущая в заголовке Authorization у половины веба. По RFC 7515 компактный JWS - это три части, разделённые точками, и первые две - JSON-объекты, закодированные base64url без заполнителя. Прочитать их в Node.js - дело без церемоний, потому что режим base64url - кодирование первого класса:

const { Buffer } = require('node:buffer');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWRhIn0.JMjpmDdNzQZpTuUO1H33GJsj7nWhBu-qxkPD0GL2uaA';
const [head, body, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))); // { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))); // { sub: '123', name: 'Ada' }

Сказано достаточно много раз, но стоит повторить: декодирование - это не проверка. Заголовок и пелод лишь переодеты, а не зашифрованы, и любой, у кого есть токен, может прочитать и то и другое. Часть, которую нужно проверить, - третья, подпись. Для классического токена HMAC-SHA256 вся проверка - это несколько строк встроенного модуля crypto, и единственная тонкая часть - сравнение через timingSafeEqual, чтобы злоумышленник не мог засечь время вашего побайтового сравнения:

const crypto = require('node:crypto');
const expected = crypto.createHmac('sha256', 'topsecret').update(head + '.' + body).digest();
const actual = Buffer.from(signature, 'base64url');
console.log(crypto.timingSafeEqual(expected, actual)); // true
console.log(crypto.timingSafeEqual(crypto.createHmac('sha256', 'wrong-secret').update(head + '.' + body).digest(), actual)); // false

В настоящем сервисе вы обычно не пишете это руками. Пакет jose (ноль зависимостей, работает в Node.js, браузерах и edge-рантаймах) и давно известный пакет jsonwebtoken (Node.js) обёртывают весь танец, обрабатывают семейства алгоритмов RSA и ECDSA и проверяют заявления exp, aud и iss. Какую бы библиотеку вы ни выбрали, под ней та же Base64-обвязка - те самые два вызова, что вы только что видели.

HTTP: заголовки, строки запроса и куки

Три уголка соединения полны Base64. Самый старый - HTTP-аутентификация Basic, определённая в RFC 7617: клиент отправляет Authorization: Basic плюс Base64 от user-id:password. На сервере это один срез и одно декодирование, с небольшой протокольной деталью: имя пользователя и пароль разделяет только первое двоеточие, так что в пароле законно может быть ещё двоеточий, а в имени пользователя - нет:

const { Buffer } = require('node:buffer');
const header = 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==';
const credentials = Buffer.from(header.slice(6), 'base64').toString('utf8');
const [user, ...rest] = credentials.split(':');
console.log(user, rest.join(':')); // "Aladdin" "open sesame"

И помните, что такое аутентификация Basic на самом деле: обфускация, а не безопасность. Учётные данные пересекают соединение в костюме, поэтому схема приемлема только поверх HTTPS. Второй уголок - строка запроса, и именно она прячет самую зловредную мину в Base64-мире:

const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // «aGVs bG8=», плюс превратился в пробел

Ваш Base64 не испортил сам себя. Слой URL сделал это вежливо, действуя по правилам кодировки форм, которые трактуют + как пробел. Именно поэтому токены, живущие в строках запроса, используют алфавит, безопасный для URL, - о нём речь в разделе ниже. Третий уголок - куки: куки - только ASCII, поэтому любое не-ASCII значение, сохранённое в одной из них, почти наверняка Base64, и старый приём - засовывать JSON-блоб в куку через Base64 - жив в поразительном числе продакшен-систем. Декодирование то же самое, что вы уже знаете; просто сначала проверьте форму, потому что куки - это место, где пользователь или браузерное расширение могут протянуть вам мусор.

Файлы, изображения и data URL

Файловая система Node говорит на Base64 напрямую, поэтому целый файл может пересечь границу JSON одной строкой:

const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // файл, примерно на 33 процентов тяжелее
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);

Другой пелод в форме файла - это data URL: строка data:image/png;base64,..., которую фронтенды любят для встроенных изображений. Рецепт одинаков в любом рантайме: отрезать по первой запятой, разобрать метаданные перед ней и декодировать остальное. Вот настоящий однопиксельный PNG, оживающий на ваших глазах:

const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const comma = dataUrl.indexOf(',');
const meta = dataUrl.slice(5, comma);
const bytes = Buffer.from(dataUrl.slice(comma + 1), 'base64');
console.log(meta); // "image/png;base64"
console.log(bytes.subarray(0, 8).toString('hex')); // «89504e470d0a1a0a», сигнатура PNG

Проверять сигнатуру - привычка дешёвая. Первые восемь байтов PNG - это всегда 89 50 4E 47 0D 0A 1A 0A, а JPEG начинается с FF D8 FF. Если «base64-изображение» от клиента не начинается с магических байтов, которые оно обещало, вы узнаете об этом заранее, прежде чем делать с ним что-нибудь дорогое.

URL-безопасный Base64: алфавит для токенов

Классический Base64 использует + и / в качестве своих двух специальных символов (RFC 4648, раздел 4), и оба они - беда в URL: + превращается в пробел при декодировании формы, а / - это разделитель путей. Вариант из раздела 5, безопасный для URL и имён файлов, который все называют base64url, заменяет их на - и _ и может вовсе отбросить хвостовое заполнение =, если длина известна по контексту. Это ровно та комбинация, которая нужна JWT, OAuth-токенам и глубоким ссылкам, поэтому base64url - алфавит, который вы встретите чаще всего в дикой природе.

Buffer в Node превращает всё это в ничто. Оба режима декодирования, 'base64' и 'base64url', принимают все четыре специальных символа и сопоставляют их с одними и теми же значениями, поэтому часть JWT, OAuth-токен и классический Base64-блоб декодируются без всякой церемонии замены символов:

const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVs-bG8', 'base64').toString('hex'));    // "68656cf9b1bc"
console.log(Buffer.from('aGVs+bG8', 'base64url').toString('hex')); // «68656cf9b1bc», те самые шесть байтов

API ES2026 нарочно придирчивее, и даёт ту же гибкость с явной крутилкой. Опция alphabet выбирает между "base64" (по умолчанию, + и /) и "base64url" (- и _), а если подать символ из неверного алфавита, вы получите SyntaxError, а не молчаливое декодирование по чужому алфавиту:

console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
  Uint8Array.fromBase64('aGVs-bG8'); // алфавит по умолчанию - классический
} catch (error) {
  console.log(error.name); // «SyntaxError», дефис - не классический символ
}

В браузере, где новых методов ещё нет, обход - это маленькая замена перед тем, как отдать строку atob(), который знает только классический алфавит. Ещё придётся восстановить заполнение, если отправитель его отбросил, - а для полезных нагрузок токенного вида это норма:

function decodeBase64Url (value) {
  const classic = value.replace(/-/g, '+').replace(/_/g, '/');
  const padded = classic + '='.repeat((4 - (classic.length % 4)) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}
console.log(new TextDecoder().decode(decodeBase64Url('aGVsbG8gd29ybGQ'))); // "hello world"

Base64 в дикой природе: где прячутся пелоды

Base64 - это почтовая служба для байтов в мире JavaScript. Тур по местам, где он появляется, с рецептом декодирования для каждой остановки:

  • Поля JSON API, самый частый носитель, без конкуренции: аватары, миниатюры, сгенерированные документы и загрузки приходят как Base64-строки внутри обычного JSON, потому что в JSON нет слова для «это байты». Декодируйте поле, прежде чем делать с ним что-либо ещё.
  • Переменные окружения и файлы конфигурации: несколько менеджеров секретов, CI-системы и даже сам npm CLI вручают вам Base64-блобы (старые версии npm хранили учётные данные реестра в .npmrc как Base64 от user:password; современный npm пишет сырой bearer-токен в _authToken). Декодируйте один раз при запуске и держите открытый текст в памяти ровно так долго, сколько он вам нужен.
  • Kubernetes и кластерные инструменты: секреты k8s знамениты тем, что закодированы в Base64 и в API, и в etcd, и официальная документация не устаёт повторять, что это кодирование, а не шифрование. Ваш декодирующий код должен относиться к результату как к секрету, а не как к доказательству безопасности.
  • Базы данных: всё бинарное, что хранится в JSON-колонке (Postgres jsonb, документы MongoDB, Redis), - часто Base64-строка. Декодируйте её на пути чтения в Buffer или Uint8Array и позвольте базе оставаться чисто текстовой.
  • Электронная почта: MIME Base64 с его 76-символьным переносом строк - так вложения и бинарные заголовки пересекают SMTP, протокол, который изначально был только 7-битным. Декодер Node пропускает переводы строк за вас, поэтому всё тело декодируется одним вызовом, без чистки.
  • CI/CD-конвейеры: системы сборки и инжекторы секретов передают токены как Base64-значения переменных окружения; декодируйте в скрипте конвейера и никогда не выводите декодированное значение в лог.
  • Данные каталогов и SAML: LDIF-файлы хранят бинарные атрибуты (подумайте: сертификаты) как Base64, а ответы SAML часто сжимаются алгоритмом DEFLATE, а потом кодируются в Base64, прежде чем пересечь границу HTTP.
  • Worker-треды и edge-рантаймы: Base64-строки пересекают границу worker_threads как обычные строки, пригодные для структурированного клонирования, так что тяжёлое декодирование может жить на воркере, пока цикл событий основного треда остаётся свободным.

Две из этих остановок заслуживают пристального взгляда, потому что встречаются и в интервью, и в продакшене:

const { Buffer } = require('node:buffer');
// Переменная окружения: секрет приходит в Base64-кодировке
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// Поле JSON API: распакуйте, прежде чем делать что-либо ещё
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // «89504e47», снова сигнатура PNG
// MIME-письмо: переводы строк пропускаются, чистка не нужна
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"

Антипаттерн, который стоит заметить на этом туре, один и тот же везде: Base64 там, где сырые байты были уже разрешены. WebSocket-фрейм, файловый поток, колонка Postgres bytea, - все они переносят байты нативно, поэтому прогонка в Base64 и обратно там - чистый оверхед: 33-процентный налог на размер без какой-либо выгоды взамен. Когда существует нативный бинарный путь - идите по нему.

Декодирование по кускам: потоки и большие данные

Группы из четырёх символов в Base64 кодируют три байта, поэтому поток чанков может разрезать группу пополам. Наивный подход - декодировать каждый чанк и молиться - портит вывод на случайных границах. API ES2026 спроектировано именно для этого: setFromBase64() записывает в заранее выделенный массив и сообщает, сколько входных символов оно потребило, а режим "stop-before-partial" заставляет его остановиться на последней полной группе, оставляя фрагмент следующему чанку. Паттерн повторяет поточный API TextDecoder:

const { Buffer } = require('node:buffer');
const chunks = ['aGVsbG8', 'gd29ybGQ='];
let leftover = '';
const parts = [];
for (const chunk of chunks) {
  const pending = leftover + chunk;
  const space = new Uint8Array(Math.ceil(pending.length * 3 / 4));
  const { read, written } = space.setFromBase64(pending, { lastChunkHandling: 'stop-before-partial' });
  parts.push(Buffer.from(space.buffer, space.byteOffset, written));
  leftover = pending.slice(read);
}
parts.push(Buffer.from(Uint8Array.fromBase64(leftover)));
console.log(Buffer.concat(parts).toString('utf8')); // "hello world"

На рантаймах без новых методов (у линии LTS в Node их тоже долго не было) тот же цикл работает с маленьким пользовательским декодером, который отслеживает неполную группу, или вы просто буферизуете входящие чанки, пока не сможете разбить их на границах групп. Главная идея - перенос: никогда не декодируйте фрагмент поодиночке.

Большие пелоды подчёркивают ещё два предела. Первый - сама строка: buffer.constants.MAX_STRING_LENGTH в Node - это 536870888 символов, примерно 512 МиБ текста, что декодируется примерно в 400 МБ байтов. «base64-файл» больше этого требует потокового подхода, а не одного readFileSync. Второй - память: закодированная строка живёт в куче JavaScript как UTF-16, два байта на символ, и декодированный Buffer - это вторая копия данных. Для больших полезных нагрузок вы на короткое время держите в памяти и то и другое, поэтому держите закодированную форму как можно короче - насколько позволяет код, - а для всего, что по размеру файла, предпочитайте потоки.

Из терминала

Node по совместительству - вполне приличный командно-строковый Base64-декодер, и это удобно, когда вы отлаживаете запрос или осматриваете значение конфигурации:

# Декодируем классическую Base64-строку, переданную аргументом
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# Вариант, безопасный для URL, заполнение по желанию
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# Декодируем из stdin, для этого и нужны конвейеры
echo -n "aGVsbG8gd29ybGQ=" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(Buffer.from(d.trim(),"base64").toString("utf8")))'

Все три выводят hello world. Если на машине есть и классическая команда base64 из coreutils, она делает ту же работу с base64 -d, но Node-версии знают про base64url, чего не знает традиционный инструмент.

Ловушки с JavaScript-акцентом

Каждая из них была чьим-то потерянным послеполуднем в JavaScript или Node.js:

  • Тихий декодер: Buffer.from('!!!', 'base64') возвращает пустой Buffer, а не ошибку. Полупорченный вход декодируется в полупорченные данные без какого-либо предупреждения. Валидируйте недоверенный вход строгим регулярным выражением (или строгим режимом fromBase64) и относитесь к пустому Buffer из непустой строки как к красному флагу.
  • Пропущенный аргумент кодировки: Buffer.from('aGVsbG8=') без второго аргумента не декодирует ничего. Он строит Buffer из UTF-8-байтов тех самых букв, так что ваши «декодированные» данные - это и есть те буквы, переупакованные в байты. Аргумент 'base64' - весь трюк.
  • Костюм бинарной строки: вывод atob() - не текст, пока вы сами не скажете, что он текст. Запихав его в JSON-ответ, куку или строку лога, вы получаете кое-что, что «работает», и попутно оно сохраняет каждый null-байт, что одинаково удивляет лог-шипперов и сериализаторы. Немедленно преобразуйте его через charCodeAt() в Uint8Array или в UTF-8-текст.
  • Плюс в строке запроса: + в значении строки запроса после декодирования формы - это пробел к моменту, когда URLSearchParams передаёт его вам. Для всего, что живёт в URL, предпочитайте base64url, и никогда не вставляйте классический Base64-токен в строку запроса без экранирования.
  • Символ замещения: некорректный UTF-8 в UTF-8-режиме Buffer становится тихим ромбом с вопросительным знаком, а не ошибкой, так что испорченный пелод может пройти ваш конвейер и сесть в базу данных. Включите fatal: true в TextDecoder там, где порча должна быть громким сбоем.
  • Обход через Windows: просьба к TextDecoder о 'iso-8859-1' или 'latin1' выдаёт вам декодер Windows-1252, где байт 0x80 становится знаком евро. Для настоящего побайтового Latin-1 читайте Buffer через toString('latin1'). И помните, что 'binary' - просто вводящий в заблуждение алиас той же привязки Latin-1.
  • Потолки размера: buffer.constants.MAX_LENGTH - это 9007199254740991 байт (2 в 53 степени минус один) на 64-битных системах, но строка, несущая Base64, не может вырасти больше MAX_STRING_LENGTH в 536870888 символов. Значит, одна строка может нести немногим более 400 МБ декодированных данных; дальше - только потоком.
  • Счёт за память: Base64-строка стоит двух байтов кучи на символ (UTF-16), и декодированный Buffer - полная вторая копия. Файл в 100 МБ на короткое время превращается в вашем процессе примерно в 133 МБ строки плюс 100 МБ Buffer. Сократите окно, в котором закодированная форма остаётся в ссылках.
  • Несовпадение «строгий на той стороне, снисходительный здесь»: ваш Node-декодер прощает то, что строгий декодер где-то в другом месте отклоняет (Python-скрипт, Go-сервис, мобильное приложение). Если одна сторона вашей системы строгая, а другая снисходительная, баг проявится только при определённых длинах полезных нагрузок, а это худший вид багов. Договоритесь о строгости на уровне протокола, а не в голове.

Как JavaScript обзавёлся своими декодерами

У браузерной стороны длинная, скучная и надёжная история. atob() и btoa() были описаны в черновике HTML5 в начале 2011 года (браузеры имели их раньше, чем появилась спецификация), и с тех пор они сидят во всех крупных браузерах, более десяти лет без изменений в поведении. Они старше типизированных массивов в стандарте языка (ES2015), поэтому и говорят на «бинарных строках», а не на байтах.

Node.js обзавёлся своим декодером по другому расписанию. Класс Buffer стал глобальным в версии 0.1.103, летом 2010 года, почти за пять лет до Node 1.0, и нёс режим 'base64' с самого начала. Большую часть жизни Node это был единственный декодер в городе. Потом пришла волна веб-стандартов: Node 16 в 2021 году добавил atob() и btoa() как глобалы, чтобы код, написанный для браузера, запускался на сервере без полифилла, и с первого дня пометил оба как Legacy. Node 25, вышедший 15 октября 2025 года, обновил V8 до 14.1 и принёс в рантайм методы ES2026, Uint8Array.fromBase64(), setFromBase64() и их hex-братьев. По пути старый конструктор new Buffer() был объявлен устаревшим (в Node 10 предупреждения начались в 2018 году) в пользу Buffer.from(), alloc() и allocUnsafe(), отчасти потому что неинициализированное выделение могло выдать любую память, что там была раньше.

В браузерах та же волна пришла чуть раньше: Firefox 133 и Safari 18.2 поставили новые методы в 2024 году, а Chrome 140 (стабильная версия с 2 сентября 2025 года) довёл набор до конца, после чего фича была объявлена Baseline Newly available в программе Baseline браузерных вендоров. Bun, универсальный JavaScript-рантайм, получил их в версии 1.1.22 в августе 2024 года. А если вы не можете требовать свежий рантайм, core-js и пакет es-arraybuffer-base64 из проекта es-shims поставляют полифиллы для всего этого, - и это тот же путь, которым большинство фреймворков идёт внутри.

У формата, которому они служат, родословная ещё древнее. Алфавит впервые стандартизировали для Privacy-Enhanced Mail в 1987 году (RFC 989), пересмотр 1993 года (RFC 1421) сохранил тот же алфавит, а MIME подобрал его в 1996 году (RFC 2045), примерно через три года после того пересмотра, - с его 76-символьным переносом строк; RFC 3548 в 2003 году собрал base16, base32 и base64 в один документ, а RFC 4648 в 2006-м переиздал его, сохранив алфавит, безопасный для URL, который добавил RFC 3548, - тот самый, что через десятилетие оказался в каждом JWT. URL-безопасный вариант - приятный факт: он был предложен в посте в рассылке 2001 года о peer-to-peer идентификаторах, ещё до того как встретил какой-либо токен.

Весёлые факты для вашей следующей стендап-встречи

  • Ключ из примера RFC WebSocket, dGhlIHNhbXBsZSBub25jZQ==, декодируется в слова «the sample nonce». Комитет по стандартам спрятал подмигивание внутри собственного примера, и atob() в Node раскрывает шутку одним вызовом.
  • Buffer.from('!!!', 'base64') возвращает Buffer длины ноль. Настоящее выделение, внутри которого ничего нет. Ничего. Это ближе всего к пожиманию плечами из всего, что умеет Node.
  • Base64-декодеры Node двоязычны в том смысле, о котором спецификация никогда не просила: +, -, / и _ - все желанные и в режиме 'base64', и в режиме 'base64url', где каждая пара сопоставляется с одним и тем же значением.
  • Документация Node по atob() содержит фразу «Вместо этого используйте Buffer.from(data, 'base64')». Рантайм, который говорит вам перестать использовать одну из собственных глобальных переменных, - с официальным codemодом (npx codemod@latest @nodejs/buffer-atob-btoa), который сделает миграцию за вас.
  • Маленькие Buffer вырезаются из общего пласта памяти: Buffer.poolSize - 65536 байт, и каждое маленькое выделение переиспользует куски того пула. Поэтому создание Buffer быстрое, и поэтому «небезопасное» выделение - фраза, значение которой стоит знать.
  • Крошечный пакет base64-js, три функции и ноль зависимостей, набирает заметно больше 100 миллионов загрузок в неделю на npm, почти всё это - как скрытая зависимость внутри других пакетов. Base64 - самый контрабандный код в экосистеме.
  • У Uint8Array.fromBase64() есть режим под названием "stop-before-partial", который существует единственно ради того, чтобы вы могли декодировать поток, никогда не разрезая группу из четырёх символов. Режим, названный по тому, чего он отказывается делать, - редкий образец API-поэзии.
  • Мир Unix-паролей пользуется собственными Base64-алфавитами, без заполнителя, и, запутанно, все они в разном порядке. Классический алфавит «hash64» у crypt(3) - ./0-9A-Za-z, но bcrypt перетасовывает те же 64 символа в ./A-Za-z0-9. Версию bcrypt вы встретите в хэшах $2b$, которые многие JavaScript-проекты хранят для паролей пользователей, - и в этом причина, по которой «base64» в контексте безопасности может означать несколько разных алфавитов, а не только два.

Осталось одно направление

Декодирование Base64 в JavaScript и Node.js - это стопка из трёх честных инструментов: Buffer.from(string, 'base64'), снисходительная рабочая лошадка, принимающая оба алфавита и пропускающая каждый чужой символ, которую лучше всего охранять строгим регулярным выражением; TextDecoder - для настоящего текста в любой кодировке, которую когда-либо придумал старый веб, с режимом fatal, когда порча должна больно кусаться; и новый Uint8Array.fromBase64() - для байтово-ориентированного кода, которому нужны строгие алфавиты, строгие биты заполнения и потоки без акробатики. Определите кодировку, валидируйте то, что вам присылают незнакомцы, сравнивайте подписи через timingSafeEqual, и формат перестанет быть тайной по обе стороны разлома браузер/сервер.

А когда вы закончите вскрывать пакеты, помните, что кто-то должен был их запечатать. На стороне кодирования есть свои ловушки: Unicode-стена, которая останавливает btoa() посреди предложения, MIME-обвязка строк, правила заполнения base64url и новый Uint8Array.toBase64() с его опцией omitPadding. Эта история, с примерами кода на каждый шаг, подробно разобрана в сопутствующей статье о кодировании Base64 на нашем сайте-близнеце. Читайте её следующей, потому что на той стороне алфавита ловушки другие - и забавнее.

Последнее обновление: 2026-09-07

Связанная статья: Кодирование Base64 в JavaScript/Node.js: полное руководство