Codificação Base64 em JavaScript/Node.js: um guia completo
Você tem dados que precisam virar texto. Um arquivo que precisa viajar dentro de um campo JSON, uma imagem que quer morar num arquivo CSS, um segredo que vai ficar numa variável de ambiente, um token que vai viajar por uma query string. A resposta em JavaScript e Node.js é quase sempre a mesma: Base64. Este artigo é o manual de embalagem, do primeiro byte que você segura até o momento em que a sua string codificada sai da máquina.
A página inicial deste site explica o formato em detalhes, o alfabeto, a matemática, o padding, então aqui ele cabe numa única frase: cada três bytes viram quatro caracteres imprimíveis, e é por isso que a sua saída vai ficar cerca de 33 por cento maior que a entrada. Guarde isso no bolso, porque é a razão pela qual cada seção deste artigo existe, e é o número em que a sua fatura de armazenamento é calculada.
A parte reconfortante: você não instala nada. Todo navegador moderno entrega btoa() e o mais novo Uint8Array.toBase64(), e toda versão do Node.js que importa carrega a classe Buffer com um modo 'base64' e, desde a versão 15.7.0, um modo 'base64url' de primeira classe. A arte está em saber qual forma de entrada você segura, qual alfabeto o destino exige e quais regras de quebra de linha os formatos antigos ainda impõem.
Conheça a sua entrada antes de codificar
Toda pergunta de codificação começa com a mesma: o que exatamente você está segurando? Uma string JavaScript é texto UTF-16, um Buffer é um array de bytes, e a chamada certa depende de qual dos dois você tem:
| Você está segurando | Chame isso | Notas |
|---|---|---|
| Uma string só ASCII (caracteres abaixo de 256) | btoa(string) |
Caminho mais rápido em navegadores e Node.js 16+, mas para no primeiro caractere que não cabe num byte |
| Qualquer string Unicode | TextEncoder para bytes, depois uma chamada base64 |
A ponte UTF-8; o único caminho seguro para letras acentuadas e emoji |
| Um Buffer ou Uint8Array | buffer.toString('base64') ou bytes.toBase64() |
O cavalo de batalha do Node.js, e o método ES2026 em navegadores modernos e Node.js 25+ |
Três exemplos, um por linha da tabela:
// Texto só ASCII: o atalho legacy (navegadores e Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Qualquer texto no Node.js: Buffer lê UTF-8 por padrão
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// Bytes que você já possui
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (runtimes ES2026)
Repare no segundo exemplo: o mesmo texto produz uma string Base64 diferente dependendo do charset em que você o codifica. Isso não é bug - é o jogo inteiro. A camada Base64 codifica bytes, e uma string vira bytes só depois que você escolheu um charset, então "codifique este texto" sempre quer dizer secretamente "codifique os bytes UTF-8 deste texto" (ou os bytes Latin-1, se você disser que sim).
A muralha Unicode e as pontes sobre ela
btoa() é a API mais velha do recinto, e o contrato dela é dos anos 1990: cada caractere da string de entrada deve caber num único byte, code points entre 0 e 255. Qualquer coisa acima disso, um emoji, uma letra cirílica acentuada, um caractere chinês, lança erro:
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // "Invalid character" no Node; a redação da faixa Latin1 nos navegadores
}
A correção é parar de pensar em caracteres e começar a pensar em bytes. TextEncoder (um global em todo navegador e no Node.js) transforma a string na sua sequência de bytes UTF-8; você levanta esses bytes para uma string Latin-1, e o btoa() recebe exatamente o que prometeu processar:
function encodeUnicode (text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true, os mesmos bytes
Você também vai encontrar o idiom antigo em codebases, e ele funciona do mesmo jeito por baixo: btoa(unescape(encodeURIComponent(text))). A chamada encodeURIComponent produz bytes UTF-8 percent-encoded, e o unescape transforma os escapes percent de volta em caracteres brutos. Tanto escape quanto unescape são funções legacy, então código novo deveria preferir a ponte do TextEncoder, mas quando você herda a forma antiga, agora sabe exatamente o que ela está fazendo em vez de dar de ombros.
No Node.js a muralha é quase um não-problema, porque Buffer.from(text) assume UTF-8 e faz a conversão de bytes pela mesma chamada. A ponte importa mais no navegador, onde btoa() é a opção legacy e o passo do UTF-8 é seu para fazer explicitamente.
Bytes entrando, letras saindo: Buffers, padding e variações
Com os bytes em mãos, o lado da codificação no Node.js é um método: toString('base64'). Ele cuida da matemática dos grupos, do padding, de tudo, e sempre produz saída canônica no sentido do RFC 4648, o que significa que os bits de padding não usados do último grupo são zero:
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 bytes
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60 caracteres, o imposto de 33 por cento em ação
O padding no final está fazendo trabalho de verdade, não decoração. Um último grupo com um byte sobrando vira dois caracteres Base64 mais dois =, e um grupo com dois bytes sobrando vira três caracteres mais um =. Se a sua saída pode carregar esse padding depende do destino, e essa é a diferença entre as duas variações de Base64 que você vai usar todos os dias:
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2026, com padding)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", o Node joga o padding fora no modo base64url
Lembre a razão quando dimensionar as coisas: três bytes entram, quatro caracteres saem, então 1 MB de dados vira uns 1,33 MB de texto, e se você quebrar o texto em linhas para e-mail ou PEM, as quebras adicionam uns poucos por cento a mais por cima.
Fazendo bytes cruzarem o fio
O problema mais comum do fio em JavaScript é que o JSON não tem bytes. Ele tem strings, e a string que consegue viajar com segurança por qualquer parser JSON, qualquer proxy HTTP e qualquer sistema de log é a Base64. O padrão é o mesmo nas duas pontas da conexão: codifique na fronteira, decodifique na fronteira, e segure os bytes no meio:
const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
name: 'photo.jpg',
contentType: 'image/jpeg',
data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true, o arquivo agora viaja dentro de JSON comum
Saiba quando brigar com esse padrão. Se o seu transporte já suporta binário, use-o: um upload multipart/form-data envia o arquivo bruto sem o imposto de tamanho, um frame WebSocket carrega bytes brutos, e uma coluna bytea do Postgres os armazena nativamente. Base64 num lugar onde bytes brutos já eram permitidos é overhead puro, o imposto de 33 por cento sem nada para mostrar por ele. O Base64 paga o seu salário quando o canal é só texto: APIs JSON, corpos de e-mail, variáveis de ambiente, query strings de URL, e as muitas pontes (SDKs mobile, apps desktop, sistemas de chat) que só deixam texto passar.
Data URLs: imagens que moram em texto
A data URL, a string data:image/png;base64,..., é Base64 vestindo um rótulo MIME, e é o motivo pelo qual você pode colocar uma imagem inteira dentro de um único atributo HTML. O RFC de 1998 que definiu o esquema até diz que ele é "só útil para valores curtos", porque o HTML antigo tinha um limite de 1024 caracteres para valores de atributo. Navegadores modernos riem desse limite e renderizam com prazer data URLs de megabytes, o que é ao mesmo tempo um superpoder e uma armadilha.
No navegador, a API de canvas faz o trabalho inteiro por você: pixels entrando, data URL saindo:
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 24)); // "data:image/png;base64,iV"
E em qualquer runtime, incluindo o Node.js, montar uma é só concatenação de string com o metadado no lugar certo: um prefixo data:, o media type, o marcador opcional ;base64, uma vírgula, e o payload. Sem o marcador ;base64, o payload é esperado como texto percent-encoded, e é por isso que o marcador existe:
const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true
Os trade-offs honestos: uma data URL é parte do documento, então não é cacheável como recurso próprio, conta para o tamanho do HTML ou CSS em que mora, e o DOM tem que analisá-la e segurá-la. Para um ícone de 20 kilobytes, é um bom negócio. Para uma imagem de destaque de 4 megabytes, entregue o arquivo por HTTP, onde cache e compressão funcionam, e guarde a data URL para as coisas pequenas.
Arquivos que viajam como strings
De arquivo para Base64 é uma dança de duas etapas que os dois runtimes comprimem numa única chamada. No Node.js, o sistema de arquivos aceita 'base64' como encoding de leitura, e o lado da escrita aceita também:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // o arquivo, cerca de 33 por cento mais pesado
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
No navegador, o FileReader faz o mesmo trabalho, com um detalhe: o modo de leitura de dados dele te entrega uma data URL, então você corta o prefixo para obter o payload Base64 puro:
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:application/pdf;base64,..."
const payload = {
name: fileInput.files[0].name,
data: dataUrl.slice(dataUrl.indexOf(',') + 1)
};
console.log(payload.data.length); // o arquivo, pronto para uma requisição JSON
};
reader.readAsDataURL(fileInput.files[0]);
});
Se você precisa do Base64 puro sem o prefixo de data URL no navegador, file.arrayBuffer() seguido de Uint8Array.toBase64() (em runtimes que têm) pula o prefixo por completo e é o caminho mais limpo para pipelines de upload.
base64url: o alfabeto que sobrevive a URLs
O Base64 clássico carrega dois caracteres que as URLs têm alergia. O + vira espaço sempre que uma query string é form-decodificada, o / é um separador de caminho, e o padding de = parece uma atribuição. A variante segura para URL e nome de arquivo da seção 5 do RFC 4648, o base64url, troca os dois especiais por - e _ e joga o padding fora sempre que o comprimento é conhecido pelo contexto. É o alfabeto dos JWTs, dos tokens OAuth e dos deep links, e ele merece um lugar dedicado na sua caixa de ferramentas mental.
O Buffer do Node fala esse dialeto desde a versão 15.7.0, e o lado da codificação é um argumento:
const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"
Dois pontos para reparar. O + virou -, o / virou _, e o padding sumiu, porque o modo base64url omite por design. E a IETF é explícita de que isso é uma codificação diferente, não a mesma com um disfarce, então quando uma spec diz "base64url", você deveria produzir base64url, não Base64 clássico com um procurar-e-substituir. O método ES2026 faz as mesmas escolhas como opções explícitas, e a flag omitPadding te devolve o padding quando o contexto exige:
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", dois bytes precisam de um caractere de padding
Em runtimes que não têm nenhum dos dois, a conversão é uma troca de dois caracteres mais um corte do padding, e é um dos snippets mais copiados e colados do mundo JavaScript:
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"
Use base64url para qualquer coisa que vai morar numa URL, numa query string, num nome de arquivo ou num padrão de token. Use Base64 clássico para corpos MIME, data URLs e qualquer coisa que jamais vai encontrar um percent-decoder. Misturar os dois é o bug de interoperabilidade mais comum de todo esse formato.
Lacrando credenciais: Basic Auth, JWTs e PKCE
Três cantos da autenticação da web são construídos sobre Base64, e os três são baratos de montar à mão uma vez, o que é bom, porque saber o que acontece dentro da biblioteca é o que te mantém calmo quando a biblioteca te surpreende.
Primeiro, a autenticação Basic do HTTP (RFC 7617): o cliente envia a palavra do esquema Basic junto com o Base64 de user-id:password. Uma linha, e um aviso sério anexado:
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
Aqui o Base64 é obfuscação, não segurança. Qualquer um que consiga ler o cabeçalho consegue ler a senha, então esse esquema só é aceitável sobre HTTPS, e mesmo assim é um padrão legacy: prefira tokens. Segundo, o JWT: as duas primeiras partes separadas por ponto são base64url de JSON puro, e a terceira é a assinatura. Montar um token HMAC-SHA256 à mão são poucas linhas do módulo crypto embutido:
const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3 partes, base64url sem padding do início ao fim
Repare nos detalhes que fazem ou quebram um token: nenhum padding em lugar nenhum (o RFC 7515 omite), a assinatura é computada sobre a string literal header + '.' + payload, não sobre os objetos analisados, e o conjunto inteiro é tão secreto quanto a chave. Em produção você vai usar uma biblioteca, o jose (zero dependências, navegador e Node.js) ou o jsonwebtoken (Node.js), mas eles estão rodando exatamente essas chamadas por baixo. Terceiro, o PKCE (RFC 7636), a extensão que deixa clientes públicos como SPAs e apps mobile fazerem login com segurança: o cliente gera um code_verifier de alta entropia, publica BASE64URL(SHA256(verifier)) como o desafio e prova a posse do verifier na troca do token. Aleatoriedade importa, então o verifier vem do módulo crypto, jamais do Math.random():
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43, os dois dentro da faixa permitida de 43-128
Correio antigo precisa de embrulho: armadura MIME e PEM
Dois dos formatos Base64 mais antigos do mundo ainda impõem comprimentos de linha, e os dois têm uns 30 anos. O MIME, o padrão de e-mail do RFC 2045, quebra seu Base64 em 76 caracteres por linha e exige que as linhas terminem com CRLF, uma relíquia dos dias do SMTP 8-bit limpo, quando linhas muito longas quebravam servidores de mail de verdade. O RFC 7468, que escreve as regras PEM para certificados e chaves, é ainda mais rígido: os geradores devem quebrar exatamente a cada 64 caracteres por linha, a linha final mais curta, emoldurado por linhas de armadura -----BEGIN e -----END que nomeiam o conteúdo.
O embrulho em si é um one-liner, e a armadura é um template:
const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200 caracteres
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----
Dois avisos práticos. Quando você produz MIME ou PEM, embrulhe, porque consumidores rígidos (gateways de mail, ferramentas da era OpenSSL, key stores Java) vão rejeitar um blob Base64 de 4000 caracteres numa linha só. Quando você consome, geralmente não precisa, porque o decoder do Node pula espaços em branco e o Buffer.from cuida das quebras de linha para você, mas as linhas de armadura em si não são Base64, então remova as linhas -----BEGIN/-----END (ou isole apenas o corpo) antes de decodificar: Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64'). Essa assimetria é um presente, mas não significa que você pode pular o passo de remover a armadura quando o Base64 vai para algum lugar que não pula nada, como um parser DER.
Onde dados codificados moram: ambiente, config e bancos de dados
Base64 também é um formato de armazenamento, o que é conveniente e perigosamente fácil de confundir com segurança. Variáveis de ambiente são o lar clássico: vários secret managers e sistemas de CI te entregam valores codificados em Base64, e o decode é um one-liner:
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
Diga a frase importante em voz alta: codificar não é criptografar. Um "segredo" Base64 numa variável de ambiente, num arquivo .env ou num secret do Kubernetes (o k8s guarda seus secrets como Base64 na API e no etcd, e a documentação repete sem parar) é legível por qualquer um que consiga ler o ambiente do processo, o arquivo ou o cluster. Use Base64 ali porque o transporte (shell, YAML, JSON) é só texto, jamais porque você acredita que ele esconde alguma coisa.
Em bancos de dados, Base64 é a ponte padrão para binário dentro de document stores JSON, porque uma coluna jsonb ou um documento MongoDB não tem um tipo byte próprio:
const document = {
name: 'logo',
mime: 'image/png',
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}
Guarde o media type ao lado do payload, como o exemplo faz, e você vai se agradecer daqui a um ano quando alguém perguntar o que são os bytes. Se o seu banco tem um tipo binário nativo (o bytea do Postgres é o exemplo de referência), prefira ele: os bytes não custam nada a mais, e você pula o imposto de 33 por cento para sempre.
Codificando streams sem dividir grupos
Base64 opera em grupos de três bytes, então um encoder que recebe chunks arbitrários tem que carregar o resto: um ou dois bytes que ainda não formam grupo precisam esperar o próximo chunk para serem codificados. Faça a conta por chunk e emita apenas grupos completos, e a saída será byte-idêntica à de codificar o stream inteiro de uma vez:
const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
let pending = Buffer.alloc(0);
return new Transform({
transform (chunk, _encoding, done) {
pending = Buffer.concat([pending, chunk]);
const whole = Math.floor(pending.length / 3) * 3;
this.push(pending.subarray(0, whole).toString('base64'));
pending = pending.subarray(whole);
done();
},
flush (done) {
if (pending.length > 0) {
this.push(pending.toString('base64'));
}
done();
}
});
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h"; idêntico a um toString('base64') grande de uma vez
});
encoder.end(Buffer.from('hello world, this is a stream!'));
O callback flush é o detalhe que todo mundo esquece: o último um ou dois bytes, os que nunca encontraram um par num chunk regular, recebem o padding e são empurrados para fora no final. A mesma lógica de carry é a que você espelha no lado da decodificação, só que lá a API ES2026 te dá de graça: setFromBase64() com "stop-before-partial" para exatamente nas fronteiras dos grupos e te diz quantos caracteres consumiu.
Arquivos grandes e a conta de memória
Base64 é generoso com espaço, então arquivos grandes precisam de estratégia. Um arquivo de 1 GB vira uns 1,33 GB de texto Base64, e uma string JavaScript armazena UTF-16, dois bytes de heap por caractere, então só esse texto já pede cerca de 2,7 GB de memória antes do seu Buffer chegar. O teto é explícito no Node: buffer.constants.MAX_STRING_LENGTH é 536870888 caracteres, pouco menos de 512 MiB de texto, que decodifica para cerca de 400 MB de bytes. Além disso, uma string única não é mais opção, e streaming é a única opção prática:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
const joined = Buffer.concat([carried, chunk]);
const whole = Math.floor(joined.length / 3) * 3;
process.stdout.write(joined.subarray(0, whole).toString('base64'));
carried = joined.subarray(whole);
});
source.on('end', () => {
if (carried.length > 0) {
process.stdout.write(carried.toString('base64'));
}
process.stdout.write('\n');
});
O padrão é o encoder de stream da seção anterior, achato: leia em chunks de 64 KiB, carregue o resto de 1 a 2 bytes, emita grupos completos e faça flush da cauda. A pegada de memória fica em torno de um chunk mais um resto, qualquer que seja o peso do arquivo. E se a ponta que recebe consegue aceitar binário, pergunte-se por que você está pagando o imposto em primeiro lugar.
One-liners para o terminal
O Node também é um encoder Base64 de linha de comando, o que é útil quando você está empacotando um valor de configuração, debugando uma API ou movendo um arquivo pequeno entre máquinas por uma mensagem de chat:
# Codifica um arquivo para Base64 clássico no stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# A variante URL-safe, padding jogado fora
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# Lê do stdin, é para isso que existem os pipes
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'
Nenhum dos três adiciona uma quebra de linha final por conta própria, o que mantém a saída limpa para copiar e colar e para substituição $(...) em scripts de shell. Se você quer um arquivo bonitinho com linhas quebradas, pipe o resultado para o seu editor favorito, ou acrescente um \n no final do one-liner.
Pegadas que custaram horas a desenvolvedores
Cada uma dessas já custou uma tarde de verdade num codebase de verdade:
- A muralha Unicode:
btoa('héllo ⛳')lançaInvalidCharacterError, porque a bandeirinha de golfe não cabe num byte. A correção é a ponte UTF-8:TextEncoderpara bytes primeiro, depois codificar. No Node.js você pula o problema inteiro comBuffer.from(text), que assume UTF-8. - O idiom legacy: código antigo cheio de
btoa(unescape(encodeURIComponent(x)))funciona, masescapeeunescapesão funções legacy deprecated. Quando você refatorar esse código, substitua pela ponte doTextEncodere o comportamento permanece idêntico. - O argumento de encoding ausente, ao contrário: a pegada do lado do decode é
Buffer.from(str)sem o modo 'base64', e a gêmea do lado do encode é assumir queBuffer.from(someString)faz qualquer coisa especial com Base64. Não faz. Sem um encoding explícito, ele constrói um Buffer a partir dos bytes UTF-8 da string, e a sua saída "codificada" é o Base64 dos bytes das letras da string, o que quase nunca era o que se queria. Seja explícito nos dois sentidos. - O descompasso de padding: JWTs e a maioria dos padrões de token querem base64url sem padding, o MIME quer Base64 clássico com padding, e os dois são fáceis de cruzar. Um
=de padding dentro de um segmento JWT quebra verificadores rígidos, e padding faltando onde o comprimento é desconhecido quebra decoders lenientes. Combine com o padrão, não com o seu hábito. - A cauda não canônica: o RFC 4648 exige que os bits de padding não usados do último grupo sejam zero. Os encoders embutidos todos produzem saída canônica, mas um encoder feito à mão que move bits manualmente pode deixar lixo nesses bits, e um decoder rígido vai rejeitar o seu payload sem motivo aparente. Se você escrever o seu próprio encoder, teste contra os vetores de teste do RFC 4648, não só contra os seus próprios dados.
- O wrapping esquecido: o MIME quer linhas de 76 caracteres e o PEM quer 64, e consumidores rígidos (gateways de mail, ferramentas de chave Java) rejeitam um blob de uma linha só. O inverso é mais raro mas real: alguns parsers são orientados a linha, e um CRLF faltando no final de um arquivo PEM já quebrou mais builds do que qualquer bug no próprio Base64.
- A ilusão de segurança: Base64 numa variável de ambiente, num arquivo
.envou num secret do Kubernetes não é criptografia. Ele decodifica com uma linha de código, em qualquer linguagem, para qualquer um que consiga ler o arquivo ou o cluster. Trate-o como um disfarce de transporte, e deixe os controles de verdade (permissões, TLS, rotação de chaves) fazendo a proteção. - O inchaço do JSON: Base64 dentro de JSON custa 33 por cento mais escaping, e um upload de 5 MB vira uma string de 6,7 MB que o seu parser JSON tem que copiar para a memória. Para qualquer coisa em tamanho de arquivo por HTTP,
multipart/form-dataou um body binário bruto é o transporte melhor, e Base64 é para quando o canal é só texto. - A conta de heap: uma string codificada é UTF-16 no heap JavaScript, dois bytes por caractere, e o Buffer decodificado ou de origem é uma segunda cópia dos dados. Um arquivo de 100 MB brevemente significa uns 270 MB de string mais 100 MB de Buffer. Faça stream dos grandes, e mantenha a forma codificada referenciada pelo menor tempo que o código permitir.
- Os globals legacy no Node: a própria documentação do Node marca
btoa()eatob()como Stability 3, Legacy, e te manda usarBufferno lugar. Num navegador,btoa()é uma ferramenta perfeitamente boa para texto ASCII, mas no Node.js, vá de Buffer e deixe os globals para o código com cara de polyfill que precisa deles.
Como o JavaScript aprendeu a empacotar bytes
O lado do navegador é uma história longa e silenciosa. btoa() foi especificado no rascunho do HTML5 no início de 2011, e está em todos os navegadores importantes desde meados dos anos 2000, com comportamento inalterado, com seu contrato de um byte por caractere e sua saída sempre com padding. Esse contrato antecede os typed arrays - strings binárias eram o único jeito de carregar bytes antes de 2009 - o que explica por que btoa() ainda pensa em "strings binárias". A metade moderna da história é muito recente: a proposta TC39 que adicionou Base64 nativo aos typed arrays (junto com hex) foi padronizada como parte do ES2026, e pousou no Firefox 133 e no Safari 18.2 em 2024, no Chrome 140 em 2 de setembro de 2025, e então foi declarada Baseline Newly available. O Bun lançou os mesmos métodos na versão 1.1.22 em agosto de 2024.
O Node.js empacotou bytes num relógio 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 toString('base64') foi o encoder da escolha por mais de uma década, com os maneirismos de alfabeto da época (ele já aceitava os caracteres URL-safe quando decodificava, um hábito bilíngue que a spec nunca pediu). A versão 15.7.0 em janeiro de 2021 adicionou o modo 'base64url' como nome de encoding de primeira classe, o Node 16 no mesmo ano adicionou os globals btoa()/atob() do navegador (marcados Legacy imediatamente), e o Node 22 em 2024 trouxe mais trabalho de performance de V8 e base64. Então o Node 25, lançado em 15 de outubro de 2025, atualizou o V8 para 14.1 e trouxe os métodos ES2026, toBase64() com sua opção omitPadding e setFromBase64() para a outra direção, para o runtime. Para runtimes que não conseguem acompanhar, o core-js traz polyfills (features/typed-array/to-base64 / from-base64), e o pacotinho base64-js (três funções, zero dependências) carregou o ecossistema em silêncio por anos como dependência transitiva.
O formato que eles servem é mais velho que tudo isso. O alfabeto foi padronizado pela primeira vez para o Privacy-Enhanced Mail em 1987 (RFC 989), a revisão de 1993 (RFC 1421) o manteve, e o MIME o adotou meses depois, no mesmo ano, com sua quebra a cada 76 caracteres; o RFC 3548 consolidou a família Base-N em 2003 e adicionou a variante URL-safe, que o RFC 4648 republicou em 2006. Uma década depois, os RFCs 7515 e 7519 tornaram o base64url sem padding a espinha dorsal de todo JWT, e o RFC 7636 o colocou no fluxo PKCE do OAuth. Os encoders deste artigo são a última milha de um formato com trinta anos e ainda ganhando passageiros.
Vale saber numa festa
btoa('GIF89a')devolve"R0lGODlh", o header mágico inteiro de um GIF em oito caracteres. É o menor "hello" que um arquivo binário consegue dizer em Base64, e é por isso que é o primeiro exemplo de Web API no artigo da Wikipedia.toBase64()tem uma opçãoomitPaddingque obtoa()nunca poderia ter, porque o contrato da Web API faz padding incondicionalmente. Duas décadas do mesmo alfabeto, e a API mais nova consegue fazer uma coisa que a mais velha nunca foi permitida.- Um alfabeto, dois comprimentos de linha oficiais: o MIME quebra em 76, o PEM em 64. Os mesmos 64 caracteres, o mesmo padding, duas opiniões diferentes com 30 anos sobre o quão larga uma linha de texto pode ser.
- O número de 33 por cento é exato: quatro caracteres por três bytes é uma razão 4/3, e o e-mail da era RFC adicionou mais ou menos outros 3,5 por cento pelas quebras de linha. A sua string de configuração "pequena" está 37 por cento mais gorda sem motivo.
- O pacotinho
base64-jspuxa mais de 100 milhões de downloads por semana no npm, quase todo ele escondido nas árvores de dependência de outros pacotes. Base64 é o código mais contrabandeado do ecossistema JavaScript. - Buffers pequenos não são alocados um a um: o Node os corta de uma pool compartilhada de 65536 bytes (
Buffer.poolSize), o que explica por que a criação de Buffer é rápida, e por que as variantes de alocação "unsafe" existem para os casos em que os dados do inquilino anterior não importam. - O RFC que definiu data URLs em 1998 avisa que elas são "só útil para valores curtos", citando o limite de 1024 caracteres de atributo HTML. Navegadores modernos embutem imagens de megabytes como data URLs no mesmo atributo, o que é progresso ou arrogância, dependendo da sua imagem de destaque.
- Hashes de senha Unix usam seus próprios alfabetos com sabor de Base64, sem padding, e, confusamente, a ordem difere por esquema: hashes clássicos do
crypt(3)usam./0-9A-Za-z, enquanto as strings bcrypt$2b$que projetos JavaScript guardam para senhas de usuário embaralham os mesmos 64 caracteres para./A-Za-z0-9. É um bom lembrete de que "Base64" num contexto de segurança é uma família, não um formato único. - O decoder do Node aceita
-,_,+e/nos dois modos,'base64'e'base64url', quatro caracteres, uma tabela. O encoder, claro, só fala o dialeto que você pediu.
Metade de uma viagem de ida e volta
Codificar Base64 em JavaScript e Node.js se resume a três decisões: quais bytes você segura (string precisa de charset, Buffer não), qual alfabeto o destino exige (clássico para MIME e data URLs, base64url para tokens e URLs, padding opcional pelo contexto), e quais regras de linha o formato ainda impõe (76 para e-mail, 64 para PEM, nenhuma para JSON). Responda essas e os embutidos fazem o resto: Buffer.toString() no Node, btoa() mais a ponte UTF-8 no navegador, e Uint8Array.toBase64() nos runtimes modernos que finalmente tiveram um.
E todo pacote que você sela aqui, alguém vai abrir um dia. O lado da decodificação tem o próprio conjunto de armadilhas: o decoder tolerante que engole lixo sem fazer som, o disfarce de string binária que o atob() te entrega, as decisões de charset que acontecem do lado do leitor da muralha, e a lógica de streaming que espelha o padrão de carry que você acabou de aprender. Essa história, com exemplos de código para cada passo, é coberta em profundidade no artigo relacionado de decodificação Base64 no nosso site irmão. Leia em seguida, porque as armadilhas daquele lado do alfabeto são mais silenciosas, e silencioso é exatamente como elas vencem.
Última atualização: 2026-09-08
Artigo relacionado: Decodificação Base64 em JavaScript/Node.js: um guia completo