Decodificação Base64 em JavaScript/Node.js: um guia completo
O seu aplicativo recebe uma string Base64. Pode ser o cabeçalho Authorization de uma requisição entrante, um campo dentro de um payload JSON, uma imagem escondida numa data URL, ou um certificado colado num arquivo de configuração. Todos eles são a mesma coisa: bytes brutos vestindo um disfarce de ASCII. Este artigo é sobre tirar esse disfarce em JavaScript e Node.js, e sobre fazer isso sem perder um único byte no caminho.
Uma palavra rápida sobre o próprio formato: Base64 é uma codificação de texto que mapeia cada três bytes de entrada em quatro caracteres imprimíveis. A página inicial deste site explica o alfabeto, a matemática e o padding em detalhes, então aqui ela fica com uma única frase. Uma consequência vale guardar no bolso: os dados codificados ficam cerca de 33 por cento maiores que os bytes que carregam, o que significa que decodificar é uma operação de encolhimento, e nada neste artigo adiciona ou remove qualquer segredo. Você está desempacotando, não abrindo um lacre.
A boa notícia: você não instala nada. Navegadores entregam atob() há duas décadas, o Node.js tem a classe Buffer com um modo base64 embutido, e os runtimes modernos agora entregam Uint8Array.fromBase64(), um estreante rigoroso e configurável vindo da especificação ES2026. O ofício está em escolher a ferramenta certa para o trabalho, e em saber exatamente o que cada ferramenta perdoa, porque num servidor você decodifica dados de estranhos, e é no perdão que as coisas saem do trilho.
Escolhendo um decodificador
Três APIs cobrem a grande maioria do trabalho de decodificação. Elas diferem em temperamento, e essa diferença é a história inteira:
| Decodificador | Disponível em | Temperamento |
|---|---|---|
Buffer.from(string, 'base64') |
Node.js (toda versão que importa) | Tolerante: pula caracteres desconhecidos, para no primeiro =, nunca lança erro |
atob(string) |
Todos os navegadores, Node.js 16 e superiores | Rigorosa: lança InvalidCharacterError em entrada ruim, pula espaços em branco ASCII, perdoa padding ausente |
Uint8Array.fromBase64(string) |
Chrome 140+, Firefox 133+, Safari 18.2+, Node.js 25+ | Configurável: você escolhe o alfabeto e o quão rigoroso o último chunk deve ser |
As três abrem o mesmo payload clássico da mesma forma:
// O cavalo de batalha do Node.js
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// O par legacy (todo navegador, Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world", como uma string binária
// O método moderno ES2026 (Chrome 140+, Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"
Um aviso antes de você se apoiar em atob(): ela devolve uma string, mas uma string binária, uma string em que cada caractere carrega um byte bruto como um code point de 0 a 255. Imprimir uma está bem. Armazená-la em JSON, num banco de dados ou num cookie leva esses valores de bytes brutos junto na viagem, então converta-a em bytes de verdade ou texto de verdade imediatamente depois de decodificar.
O decodificador tolerante e o que ele engole
O Buffer do Node é um leitor que perdoa, e isso é uma espada de dois gumes. Ele é maravilhoso para dados que viajaram por estradas ruins: e-mail MIME com suas quebras de linha, strings copiadas à mão, saída de log com espaços perdidos. É perigoso para dados que você não produziu, porque ele nunca reclama. Aqui está o que realmente acontece:
| Entrada | O que o Buffer.from(input, 'base64') faz |
|---|---|
'!!!' |
Devolve um Buffer vazio. Todo o lixo é pulado, nada decodifica, sem erro. |
'aGVsbG8== garbage' |
Devolve "hello". O primeiro = encerra a decodificação; o resto é ignorado. |
'aG!VsbG8' |
Devolve "hello". O bang é pulado, não é um erro. |
'aGVs=bG8' |
Devolve "hel". Um = no meio da string encerra o show antes da hora. |
'aGVsbG8====' |
Devolve "hello". Padding extra no final é ignorado. |
'=aGVsbG8' |
Devolve um Buffer vazio. Padding antes dos dados não significa nada. |
A correção para entrada não confiável é um validador, e a gramática do Base64 é pequena o suficiente para caber numa única expressão regular:
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"
}
A expressão regular verifica a forma: grupos de quatro com o padding correto. Uma regra que ela não consegue verificar é a regra de codificação canônica do RFC 4648, que diz que os bits de padding não usados do último grupo devem ser zero. O modo strict do Uint8Array.fromBase64() verifica sim, então no Node.js 25 ou em qualquer navegador moderno você pode pular a expressão regular por completo e deixar a plataforma fazer a auditoria:
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello", o modo loose perdoa padding ausente
try {
Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError", os bits de padding não são zero
}
A opção lastChunkHandling tem três configurações que valem a pena conhecer. "loose" (o padrão) pula espaços em branco, aceita padding ausente e ignora bits de padding sobrando. "strict" exige um último grupo completo com padding e todos os bits de padding zerados. E "stop-before-partial" decodifica apenas grupos completos de quatro caracteres e deixa o fragmento final para você carregar adiante, que é a peça que torna a decodificação em streaming prazerosa, como você verá mais tarde neste artigo.
De bytes a texto: a decisão do charset
Decodificar Base64 entrega bytes. Bytes viram texto só quando você escolhe um charset, e essa escolha é sua, geralmente com base no que o remetente prometeu. O padrão do Node é o que você quer na maior parte do tempo:
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // os dois bytes C3 A9
console.log(bytes.toString('utf8')); // "é", os dois bytes se juntam num único caractere
console.log(bytes.toString('latin1')); // "é", os mesmos bytes lidos um caractere de cada vez
Há um detalhe chato com UTF-8: quando uma sequência de bytes não é UTF-8 válido, o Node não lança erro. Ele substitui pelo caractere de substituição do Unicode (U+FFFD, o losango com um ponto de interrogação) e segue em frente, o que significa que um payload corrompido pode navegar pelo seu pipeline até o seu banco de dados. O decodificador de texto de verdade da plataforma, TextDecoder (um global no Node.js e em todo navegador), tem uma opção fatal que transforma corrupção num TypeError que você pode capturar:
const stray = new Uint8Array([0xe9]); // um byte solto, não é UTF-8 válido
console.log(new TextDecoder().decode(stray)); // o caractere de substituição, sem erro
try {
new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
console.log(error.name); // "TypeError"
}
Sistemas legados nunca morrem, e o TextDecoder ainda sabe lê-los. Ele aceita a tabela completa de rótulos do padrão Encoding da WHATWG, então um payload Base64 de um app Windows dos anos 1990, de um mainframe japonês ou de um espelho FTP antigo ainda pode ser decodificado com rótulos como 'windows-1250', 'shift_jis', 'euc-kr' ou 'gb18030', todos sem diferenciar maiúsculas de minúsculas. Um rótulo merece um aviso, porque já custou tempo de debug de verdade: a spec usa 'iso-8859-1', 'latin1' e até 'us-ascii' como apelidos do decodificador Windows-1252. O byte 0x80, um caractere de controle no Latin-1 de verdade, sai como o símbolo do euro:
console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€", não o Latin-1 que você pediu
// Para uma leitura Latin-1 byte a byte de verdade, use o lado do Buffer:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // o caractere de controle 0x80 bruto
Se você precisa de verdade daquele mapeamento bruto, a codificação 'latin1' do Buffer (cujo apelido legacy 'binary' é, nas palavras da documentação do Node, um nome muito enganoso) mapeia o byte N para o code point N, sem o desvio Windows. Para tudo o que é moderno, UTF-8 e fatal: true é o par seguro.
Abrindo um JWT
O payload Base64 mais decodificado por um serviço JavaScript é, de longe, o JSON Web Token, a string xxxxx.yyyyy.zzzzz que viaja no cabeçalho Authorization de metade da web. Segundo o RFC 7515, um JWS compacto é três partes separadas por ponto, e as duas primeiras são objetos JSON codificados em base64url sem padding. Lê-los no Node.js não exige cerimônia, porque o modo base64url é uma codificação de primeira classe:
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' }
Dito o suficiente, mas vale repetir: decodificar não é verificar. O header e o payload estão de roupa nova, não criptografados, e qualquer um que tenha o token consegue ler os dois. A parte que você precisa checar é a terceira, a assinatura. Para um token HMAC-SHA256 clássico, a checagem inteira são poucas linhas do módulo crypto embutido, e a única parte sutil é comparar com timingSafeEqual, para que um atacante não consiga medir o tempo da sua comparação byte a byte:
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
Num serviço de verdade, você geralmente não faz isso à mão. O pacote jose (zero dependências, roda em Node.js, navegadores e edge runtimes) e o consagrado pacote jsonwebtoken (Node.js) embrulham essa dança, tratam as famílias de algoritmos RSA e ECDSA e aplicam as claims exp, aud e iss. Seja qual for a biblioteca que você escolher, a tubulação Base64 por baixo são as mesmas duas chamadas que você acabou de ver.
HTTP: cabeçalhos, query strings e cookies
Há três cantos do fio cheios de Base64. O mais antigo é a autenticação Basic do HTTP, definida no RFC 7617: o cliente envia Authorization: Basic junto com o Base64 de user-id:password. No servidor, são um slice e um decode, com o pequeno detalhe de protocolo de que só o primeiro dois-pontos separa o nome de usuário da senha, então a senha pode legalmente conter mais dois-pontos, enquanto o nome de usuário não pode:
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"
E lembre do que a autenticação Basic realmente é: obfuscação, não segurança. As credenciais cruzam o fio de disfarce, e é por isso que o esquema só é aceitável sobre HTTPS. O segundo canto é a query string, e é nela que mora a armadilha mais perigosa da terra do Base64:
const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=", o mais virou espaço
O seu Base64 não se corrompeu sozinho. A camada de URL fez isso educadamente em seu nome, seguindo as regras de form-encoding, que tratam + como espaço. É exatamente por isso que tokens que moram em query strings usam o alfabeto seguro para URL, coberto na seção abaixo. O terceiro canto é o cookie: cookies são apenas ASCII, então qualquer valor não-ASCII armazenado num deles é quase certamente Base64, e o padrão antigo de transformar um blob JSON em Base64 para guardar num cookie está vivo em um número surpreendente de sistemas de produção. O decode é o mesmo que você já conhece. Só valide a forma antes, porque cookie é o tipo de lugar onde um usuário, ou uma extensão de navegador, pode te entregar lixo.
Arquivos, imagens e data URLs
O sistema de arquivos do Node fala Base64 diretamente, então um arquivo inteiro pode cruzar uma fronteira JSON em uma única linha:
const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // o arquivo, cerca de 33 por cento mais pesado
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);
O outro payload em forma de arquivo é a data URL, a string data:image/png;base64,... que front-ends adoram para imagens inline. A receita é a mesma em qualquer runtime: corte na primeira vírgula, analise o metadado que vem antes e decodifique o resto. Aqui está um PNG de um pixel de verdade voltando à vida:
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", a assinatura do PNG
Checer a assinatura é um hábito barato. Os primeiros 8 bytes de um PNG são sempre 89 50 4E 47 0D 0A 1A 0A, e um JPEG começa com FF D8 FF. Se uma "imagem base64" de um cliente não começa com os bytes mágicos que prometeu, você agora sabe antes de fazer qualquer coisa cara com ela.
Base64 seguro para URL: o alfabeto dos tokens
O Base64 clássico usa + e / como seus dois caracteres especiais (RFC 4648, seção 4), e os dois são problema em URLs: + vira espaço no form-decoding, e / é um separador de caminho. A variante segura para URL e nome de arquivo da seção 5, que todo mundo chama de base64url, troca os dois por - e _, e pode até jogar fora o padding de = final quando o comprimento é conhecido pelo contexto. Essa é exatamente a combinação que JWTs, tokens OAuth e deep links precisam, então base64url é o alfabeto que você mais vai encontrar no mundo real.
O Buffer do Node faz a coisa toda não ser notícia. Tanto o modo de decodificação 'base64' quanto o 'base64url' aceitam os quatro caracteres especiais e os mapeiam para os mesmos valores, então um segmento JWT, um token OAuth e um blob de Base64 clássico decodificam sem qualquer cerimônia de troca de caracteres:
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", os mesmos seis bytes
A API ES2026 é mais exigente de propósito, e te dá a mesma flexibilidade com um botão explícito. A opção alphabet escolhe entre "base64" (o padrão, + e /) e "base64url" (- e _), e alimentar com um caractere do alfabeto errado é um SyntaxError, não um decode silencioso cruzando alfabetos:
console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
Uint8Array.fromBase64('aGVs-bG8'); // o alfabeto padrão é o clássico
} catch (error) {
console.log(error.name); // "SyntaxError", o traço não é um caractere clássico
}
Num navegador que ainda não tem os métodos novos, o desvio é uma pequena troca antes de entregar a string para atob(), que só conhece o alfabeto clássico. Você também tem que restaurar o padding se o remetente o jogou fora, que é o padrão para payloads em formato de token:
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 no mundo real: onde payloads se escondem
Base64 é o serviço postal de bytes no mundo JavaScript. Um passeio pelos lugares onde ele aparece, com a receita de decode para cada parada:
- Campos de API JSON, de longe o transportador mais comum: avatares, miniaturas, documentos gerados e uploads chegam como strings Base64 dentro de JSON comum, porque JSON não tem palavra para "esses são bytes". Decodifique o campo antes de fazer qualquer outra coisa com ele.
- Variáveis de ambiente e arquivos de configuração: vários secret managers, sistemas de CI e a própria CLI do npm te entregam blobs Base64 (versões antigas do npm guardavam a credencial do registry em
.npmrccomo o Base64 deuser:password; npm moderno escreve um bearer token cru em_authToken). Decodifique uma vez na inicialização e mantenha o texto puro em memória apenas pelo tempo que precisar. - Kubernetes e ferramentas de cluster: secrets do k8s são famosamente codificados em Base64 na API e no
etcd, e a documentação oficial repete sem parar que é codificação, não criptografia. Seu código de decode deve tratar o resultado como um segredo, não como prova de segurança. - Bancos de dados: qualquer coisa binária guardada numa coluna JSON (Postgres
jsonb, documentos MongoDB, Redis) é frequentemente uma string Base64. Decodifique no caminho de leitura para um Buffer ou um Uint8Array, e deixe o banco de dados só com texto. - E-mail: Base64 MIME com sua quebra de linha a cada 76 caracteres é como anexos e cabeçalhos binários cruzam o SMTP, um protocolo que era originalmente só 7 bits. O decoder do Node pula as quebras de linha para você, então o corpo inteiro decodifica numa chamada, sem limpeza.
- Pipelines de CI e CD: sistemas de build e injetores de segredos passam tokens como valores de ambiente Base64. Decodifique no script do pipeline, e nunca faça echo do valor decodificado num log.
- Dados de diretório e SAML: arquivos LDIF guardam atributos binários (pense: certificados) como Base64, e respostas SAML são frequentemente descomprimidas e então codificadas em Base64 antes de cruzar uma fronteira HTTP.
- Worker threads e edge runtimes: strings Base64 cruzam a fronteira de
worker_threadscomo strings comuns e structured-cloneáveis, então um decode pesado pode morar num worker enquanto o event loop da thread principal fica livre.
Duas dessas paradas merecem uma olhada de mais perto, porque aparecem tanto em entrevistas quanto em produção:
const { Buffer } = require('node:buffer');
// Variável de ambiente: o segredo chega codificado em Base64
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// Campo de API JSON: desempacote antes de qualquer outra coisa
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", a assinatura do PNG de novo
// E-mail MIME: as quebras de linha são puladas, sem limpeza necessária
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"
O anti-padrão para spotting neste passeio é o mesmo em todo lugar: Base64 num lugar onde bytes brutos já eram permitidos. Um frame WebSocket, um stream de arquivo, uma coluna bytea do Postgres, todos eles carregam bytes nativamente, então um round-trip Base64 ali é overhead puro, o imposto de 33 por cento de tamanho sem nada para mostrar por ele. Quando existe um caminho binário nativo, use-o.
Decodificando em pedaços: streams e dados grandes
Base64 codifica três bytes em grupos de quatro caracteres, então um stream de chunks pode dividir um grupo ao meio. A abordagem ingênua de decodificar cada chunk e rezar corrompe a saída em fronteiras aleatórias. A API ES2026 foi desenhada para exatamente isso: setFromBase64() escreve num array pré-alocado e relata quantos caracteres de entrada consumiu, e o modo "stop-before-partial" faz ele parar no último grupo completo, deixando o fragmento para o próximo chunk. O padrão espelha a API de stream do 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"
Em runtimes que não têm os métodos novos (e a linha LTS do Node também não os teve por um bom tempo), o mesmo loop funciona com um pequeno decoder userland que acompanha um grupo parcial, ou simplesmente bufferizando os chunks que chegam até você conseguir dividir nas fronteiras dos grupos. A ideia importante é o carry: nunca decodificar um fragmento sozinho.
Payloads grandes chamam mais dois limites para a sua atenção. Primeiro, a string em si: buffer.constants.MAX_STRING_LENGTH do Node é 536870888 caracteres, aproximadamente 512 MiB de texto, que decodifica para cerca de 400 MB de bytes. Um "arquivo base64" maior do que isso precisa de uma abordagem em streaming, não de um único readFileSync. Segundo, memória: a string codificada vive no heap JavaScript como UTF-16, dois bytes por caractere, e o Buffer decodificado é uma segunda cópia dos dados. Com payloads grandes, você segura os dois brevemente, então mantenha a forma codificada viva pelo menor tempo que o código permitir, e prefira streams para qualquer coisa em tamanho de arquivo.
Do terminal
O Node também é um decodificador Base64 de linha de comando perfeitamente decente, o que é útil quando você está debugando uma requisição ou inspecionando um valor de configuração:
# Decodifica uma string Base64 clássica passada como argumento
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# A variante URL-safe, padding opcional
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# Decodifica do stdin - é para isso que existem os pipes
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")))'
Os três imprimem hello world. Se a máquina também tem o clássico comando base64 do coreutils, ele faz o mesmo trabalho com base64 -d, mas as versões do Node conhecem base64url, o que a ferramenta tradicional não faz.
Armadilhas com sotaque JavaScript
Cada uma dessas já foi a tarde perdida de alguém em JavaScript ou Node.js:
- O decodificador silencioso:
Buffer.from('!!!', 'base64')devolve um Buffer vazio, não um erro. Entrada pela metade corrompida decodifica para dados pela metade corrompidos sem nenhum aviso. Valide entrada não confiável com a regex strict (ou o modo strict defromBase64), e trate um Buffer vazio vindo de uma string não vazia como bandeira vermelha. - O argumento de encoding ausente:
Buffer.from('aGVsbG8=')sem o segundo argumento não decodifica nada. Ele constrói um Buffer a partir dos bytes UTF-8 daquelas letras, então seus dados "decodificados" são as próprias letras, reempacotadas como bytes. O argumento'base64'é o truque inteiro. - O disfarce de string binária: a saída de
atob()não é texto até você dizer que é. Enfiá-la numa resposta JSON, num cookie ou numa linha de log "funciona", e ela ainda preserva cada byte nulo, o que surpreende log shippers e serializadores na mesma medida. Converta comcharCodeAt()para um Uint8Array, ou para texto UTF-8, na hora. - O mais na query string: um
+num valor de query form-decoded é um espaço até oURLSearchParamste entregar. Prefira base64url para qualquer coisa que mora numa URL, e nunca cole um token de Base64 clássico não escapado numa query string. - O caractere de substituição: UTF-8 inválido vira um losango-com-interrogação silencioso no modo UTF-8 do Buffer em vez de um erro, então um payload corrompido pode passar pelo seu pipeline e cair num banco de dados. Ligue
fatal: truecomTextDecoderonde corrupção deveria ser uma falha barulhenta. - O desvio Windows: pedir ao
TextDecodero'iso-8859-1'ou o'latin1'te dá o decodificador Windows-1252, onde o byte 0x80 vira o símbolo do euro. Para um Latin-1 byte a byte de verdade, leia o Buffer comtoString('latin1')em vez disso. E lembre que'binary'é só um apelido enganoso para o mesmo mapeamento Latin-1. - Os tetos de tamanho:
buffer.constants.MAX_LENGTHé 9007199254740991 bytes (2 elevado à 53, menos um) em sistemas de 64 bits, mas a string que carrega o Base64 não pode crescer além doMAX_STRING_LENGTHde 536870888 caracteres. Uma única string pode portanto carregar pouco mais de 400 MB de dados decodificados, e além disso, faça stream. - A conta de memória: uma string Base64 custa dois bytes de heap por caractere (UTF-16), e o Buffer decodificado é uma segunda cópia completa. Um arquivo de 100 MB vira brevemente uns 133 MB de string mais 100 MB de Buffer no seu processo. Enxugue a janela em que a forma codificada fica referenciada.
- O descompasso strict remoto, lenient local: o seu decodificador Node perdoa o que um decodificador strict em outro lugar rejeita (um script Python, um serviço Go, um app mobile). Se um lado do seu sistema é strict e o outro é tolerante, o bug só aparece em certos comprimentos de payload, o que é o pior tipo de bug. Combinem o nível de rigor no nível do protocolo, não na sua cabeça.
Como o JavaScript criou seus decodificadores
O lado do navegador tem uma história longa, sem novidades e confiável. atob() e btoa() foram especificados no rascunho do HTML5 no início de 2011 (os navegadores as tinham antes da spec), e estão em todos os navegadores importantes desde então, com comportamento inalterado por mais de uma década. Elas antecedem os typed arrays no padrão da linguagem (ES2015), o que explica por que falam em "strings binárias" em vez de bytes.
O Node.js criou seu decodificador numa linha do tempo diferente. A classe Buffer virou um global na versão 0.1.103, no verão de 2010, quase cinco anos antes do Node 1.0, e ela carregou o modo 'base64' desde o início. Por quase toda a vida do Node, esse era o único decodificador da cidade. Então a onda dos padrões web chegou: o Node 16 em 2021 adicionou atob() e btoa() como globals para que código escrito para o navegador rodasse no servidor sem polyfill, e marcou as duas como Legacy desde o primeiro dia. O Node 25, lançado em 15 de outubro de 2025, atualizou o V8 para 14.1 e trouxe os métodos ES2026, Uint8Array.fromBase64(), setFromBase64() e seus primos em hex, para o runtime. No caminho, o antigo construtor new Buffer() foi deprecated (o Node 10 começou os avisos em 2018) em favor de Buffer.from(), alloc() e allocUnsafe(), em parte porque uma alocação não inicializada podia vazar a memória que estivesse lá antes.
Nos navegadores, a mesma onda pousou um pouco antes: o Firefox 133 e o Safari 18.2 lançaram os novos métodos em 2024, e o Chrome 140 (estável em 2 de setembro de 2025) completou o conjunto, momento em que o recurso foi declarado Baseline Newly available no programa Baseline dos fornecedores de navegadores. O Bun, o runtime JavaScript all-in-one, os teve na versão 1.1.22 em agosto de 2024. E se você não pode exigir um runtime recente, o core-js e o pacote es-arraybuffer-base64 do projeto es-shims trazem polyfills para tudo, que também é o caminho que a maioria dos frameworks segue internamente.
O formato que eles servem tem uma linhagem ainda mais antiga. O alfabeto foi padronizado pela primeira vez para o Privacy-Enhanced Mail em 1987 (RFC 989), a revisão de 1993 (RFC 1421) manteve o mesmo alfabeto, e o MIME o adotou em 1996 (RFC 2045), uns três anos depois dessa revisão, com sua quebra de linha a cada 76 caracteres; o RFC 3548 em 2003 consolidou base16, base32 e base64 num único documento, e o RFC 4648 em 2006 o republicou, mantendo o alfabeto seguro para URL que o RFC 3548 havia adicionado - o mesmo que, uma década depois, acabaria em todo JWT. A variante URL-safe é uma boa curiosidade: ela foi proposta num post de mailing list de 2001 sobre identificadores peer-to-peer, antes de jamais conhecer um token.
Fatos divertidos para o seu próximo standup
- A chave de exemplo do RFC do WebSocket,
dGhlIHNhbXBsZSBub25jZQ==, decodifica para as palavras "the sample nonce". O comitê de padrões escondeu um piscadela dentro do próprio exemplo, e oatob()do Node estoura a piada numa chamada. Buffer.from('!!!', 'base64')devolve um Buffer de comprimento zero. Uma alocação de verdade com nada dentro. Nada. É o mais perto que o Node chega a um encolher de ombros.- Os decodificadores Base64 do Node são bilíngues de um jeito que a spec nunca pediu:
+,-,/e_são todos bem-vindos tanto no modo'base64'quanto no'base64url', cada par mapeando para o mesmo valor. - A documentação do Node para
atob()contém a frase "Use Buffer.from(data, 'base64') instead". Um runtime te mandando parar de usar um dos próprios globals, completo com um codemod oficial (npx codemod@latest @nodejs/buffer-atob-btoa) para fazer a migração por você. - Buffers pequenos são cortados de uma lâmina compartilhada:
Buffer.poolSizeé 65536 bytes, e toda alocação pequena reutiliza pedaços daquela pool. É por isso que a criação de Buffer é rápida, e por que "unsafe" allocation é uma expressão cujo significado você deveria conhecer. - O pacotinho
base64-js, três funções e zero dependências, puxa mais de 100 milhões de downloads por semana no npm, quase todo ele como dependência escondida dentro de outros pacotes. Base64 é o código mais contrabandeado do ecossistema. Uint8Array.fromBase64()tem um modo chamado"stop-before-partial"que existe puramente para que você possa decodificar um stream sem jamais dividir um grupo de quatro caracteres. Um modo batizado com a coisa que ele se recusa a fazer é uma rara peça de poesia de API.- O mundo de senhas Unix usa seus próprios alfabetos com sabor de Base64, sem padding, e, confusamente, eles não estão todos na mesma ordem. O alfabeto "hash64" do clássico
crypt(3)é./0-9A-Za-z, mas o bcrypt embaralha os mesmos 64 caracteres para./A-Za-z0-9. Você vai encontrar a versão do bcrypt nos hashes$2b$que muitos projetos JavaScript guardam para senhas de usuário, e é por isso que "base64" num contexto de segurança pode significar vários alfabetos diferentes, não apenas dois.
Falta uma direção
Decodificar Base64 em JavaScript e Node.js é uma pilha de três ferramentas honestas: Buffer.from(string, 'base64'), o cavalo de batalha tolerante que aceita os dois alfabetos e pula cada caractere perdido, melhor guardado por uma regex strict; TextDecoder, para texto de verdade em qualquer charset que a web antiga inventou, com modo fatal quando a corrupção deveria doer; e o novo Uint8Array.fromBase64(), para código byte-first que quer alfabetos strict, bits de padding strict e streaming sem acrobacias. Fixe o charset, valide o que estranhos te enviam, compare assinaturas com timingSafeEqual, e o formato para de ser mistério dos dois lados da divisão navegador/servidor.
E quando você terminar de abrir pacotes, lembre que alguém teve que selá-los. O lado da codificação tem as próprias armadilhas: a muralha Unicode que para o btoa() no meio da frase, o wrapping de linha do MIME, as regras de padding do base64url, e o novo Uint8Array.toBase64() com sua opção omitPadding. Essa história, com exemplos de código para cada passo, é coberta em profundidade no artigo relacionado de codificação Base64 no nosso site irmão. Leia em seguida, porque as armadilhas são diferentes e mais engraçadas daquele lado do alfabeto.
Última atualização: 2026-09-07
Artigo relacionado: Codificação Base64 em JavaScript/Node.js: um guia completo