Codificação Base64 em Dart: um guia completo
Você tem bytes, e precisa de uma string. O payload pode ser um arquivo, uma credencial de autenticação, um token de configuração ou um blob binário viajando dentro de um documento JSON, e o canal só aceita texto. O Base64 é a troca que resolve isso: cada três bytes de entrada viram quatro caracteres de um alfabeto de 64 caracteres, então a saída sempre é um múltiplo limpo de quatro e sempre segura em mundos só de texto. O preço é fixo em 33 por cento a mais de caracteres, e o formato acrescenta um ou dois caracteres de padding = no final quando o último bloco fica curto. Este guia é a receita em Dart para fazer essa troca corretamente.
Nada para instalar. O Base64 vem no dart:convert desde o Dart 1.13 em 2015, e a API está estável desde então; os dois alfabetos, padrão e URL-safe, estão disponíveis há mais de uma década. A página inicial passeia pelo formato em profundidade; aqui está o lado da codificação do trabalho: a superfície de API completa, a disciplina de bytes primeiro que evita o bug mais comum, as escolhas de padding e alfabeto, e os trabalhos do mundo real: JWTs, data URIs, upload de arquivos, cabeçalhos HTTP, MIME, configuração, streams e linha de comando. A decodificação, a direção inversa, tem o seu próprio guia, linkado no final.
Um import, dois alfabetos, uma regra de padding
A superfície pública inteira para codificação mora no dart:convert:
| Entrada | Alfabeto | Quando usar |
|---|---|---|
base64Encode(bytes) |
padrão: A-Z a-z 0-9 + /, com padding |
APIs, MIME, Basic auth, a maioria dos consumidores |
base64UrlEncode(bytes) |
URL-safe: A-Z a-z 0-9 - _, ainda com padding |
URLs, nomes de arquivo, JWTs, IDs de objeto |
base64.encode(bytes) |
padrão, idêntico à chamada de nível superior | transformações de stream e pipelines de codec |
Base64Encoder().convert(bytes) |
padrão | você quer uma instância de codificador com nome |
Duas regras cobrem todas as quatro linhas. Primeiro, a entrada tem que ser uma lista de valores de bytes, inteiros de 0 a 255; qualquer outra coisa, incluindo negativos ou 256 para cima, lança um ArgumentError que nomeia o índice ruim. Segundo, a saída sempre vem com padding: não existe flag, construtor ou opção que produza saída sem padding, porque o padding do formato é uma propriedade dos dados, e as especificações que querem ele fora o removem como uma etapa separada e documentada. O menor exemplo possível, do início ao fim:
import 'dart:convert';
void main() {
final text = 'Dart is open source';
final bytes = utf8.encode(text);
final encoded = base64Encode(bytes);
print(encoded); // RGFydCBpcyBvcGVuIHNvdXJjZQ==
}
Bytes primeiro: a ordem que te salva
O bug mais comum de base64 em Dart não é sobre base64 de jeito nenhum. É sobre a ordem das operações. Uma String em Dart é uma sequência de unidades de código UTF-16, e chamar base64Encode(text.codeUnits) empacota essas unidades de 16 bits, não os bytes que o receptor espera. Para ASCII puro as duas coisas coincidem por acaso, que é por que o bug se esconde até o primeiro caractere acentuado, emoji ou texto CJK chegar. Aí o codificador recusa o trabalho, porque uma unidade de código como 0x4e16 não é um valor de byte:
import 'dart:convert';
void main() {
final message = 'Héllo Wörld 世界';
print(utf8.encode(message).length); // 20
print(message.codeUnits.length); // 14
print(base64Encode(utf8.encode(message)));
try {
base64Encode(message.codeUnits);
} on ArgumentError catch (e) {
print(e);
}
}
O ArgumentError aponta o índice ofensivo exato, então a falha é barulhenta em vez de silenciosa. A disciplina para manter: decidir o que são os bytes antes de falar com o base64. Texto passa por uma codificação nomeada, utf8.encode para dados modernos, e o List<int> resultante é o que se empacota. Bytes de um arquivo ou de um socket de rede já chegam como Uint8List, que é a forma certa para o codificador sem qualquer conversão.
Padding: o trabalho do codificador
O Base64 mapeia grupos de três bytes para quatro caracteres, então um payload cujo comprimento não é múltiplo de três deixa um grupo parcial no final. O formato marca esse déficit com caracteres =: um byte de entrada vira quatro caracteres mais dois pads, dois bytes viram quatro caracteres mais um pad, três bytes viram exatamente quatro caracteres. O codificador do Dart faz isso por você, incondicionalmente:
import 'dart:convert';
void main() {
print(base64Encode([0x41])); // QQ==
print(base64Encode([0x41, 0x42])); // QUI=
print(base64Encode([0x41, 0x42, 0x43])); // QUJD
}
Esse comportamento incondicional é um recurso: a saída sempre é uma string base64 legal e autoexplicativa. Quando uma especificação pede a variante sem padding, e JWTs são o motivo usual, o corte é o seu passo explícito e visível, não uma configuração de biblioteca:
base64UrlEncode(bytes).replaceAll('=', '')
Coloque o corte onde está a fronteira da especificação, nomeie ele e documente. O lado da decodificação dessa troca, incluindo como entrada danificada ou cortada é reparada, é coberto no guia de decodificação.
Base64 URL-safe
O alfabeto padrão contém +, / e =, e esses três caracteres colidem com a sintaxe de URL: separadores de query, separadores de caminho e delimitadores de parâmetro. O alfabeto URL-safe, padronizado como base64url na RFC 4648, troca + por - e / por _, então a saída pode morar num segmento de caminho, num valor de query ou num nome de arquivo sem escape. Aqui está a diferença em bytes que exercitam os dois caracteres trocados:
import 'dart:convert';
void main() {
final tricky = [0xfb, 0xff, 0xfe, 0xf9];
print(base64Encode(tricky)); // +//++Q==
print(base64UrlEncode(tricky)); // -__--Q==
}
Escolha pelo consumidor, não pelo gosto. Se o valor vai viver numa URL, num JWT ou num nome de arquivo, codifique com base64UrlEncode e remova o padding se a especificação for sem padding. Se o valor vai ser um corpo MIME, um cabeçalho de Basic auth ou um campo de um contrato de API que diz "base64", use o alfabeto padrão, porque base64 sem qualificativo significa o padrão. Os dois alfabetos não são intercambiáveis aos olhos de consumidores estritos: um servidor esperando base64 padrão pode rejeitar um payload com - com um 400 e nada mais útil.
Charsets: quais bytes você está empacotando?
Quando a entrada é texto, a etapa de codificação decide quais bytes o base64 vai ver, e o consumidor assume um charset do outro lado. Se a sua suposição e a do consumidor divergirem, a saída é um base64 perfeitamente válido dos bytes errados, o pior tipo de bug, porque nada lança erro. Para qualquer troca moderna, UTF-8 é o padrão; as outras codificações de um byte existem para dados legados:
| Codificação | Quando usar | Codifique com |
|---|---|---|
utf8 |
texto moderno, JSON, qualquer coisa na web | utf8.encode(text) |
latin1 |
dados ocidentais legados de um byte | latin1.encode(text) |
ascii |
texto puro de 7 bits | ascii.encode(text) |
import 'dart:convert';
void main() {
final modern = base64Encode(utf8.encode('Héllo'));
final legacy = base64Encode(latin1.encode('Héllo'));
print(modern); // SMOpbGxv
print(legacy); // SOlsbG8=
}
Palavra igual, bytes diferentes, base64 diferente. Repare nos comprimentos: o UTF-8 precisa de seis bytes para Héllo porque o acento é uma sequência de dois bytes, enquanto o Latin-1 cabe em cinco. Se o consumidor decodificar com a codificação que você não usou, ele ganha mojibake, e vai parecer que os dados foram corrompidos no trânsito quando na verdade foram corrompidos na intenção.
JWTs: escrevendo o token
Um JSON Web Token é três partes base64url unidas por pontos: cabeçalho, payload, assinatura. A RFC 7515 fixa dois detalhes: o alfabeto é URL-safe, e o padding é omitido, porque o token é desenhado para morar em URLs e cabeçalhos. A assinatura para o algoritmo HS256 é o HMAC-SHA256 de header.payload, ele mesmo em base64url sem padding. Montar na mão com o pacote crypto leva poucas linhas, e é mais transparente do que parece:
import 'dart:convert';
import 'package:crypto/crypto.dart';
String base64UrlNoPadding(List<int> bytes) {
return base64UrlEncode(bytes).replaceAll('=', '');
}
String createJwt(Map<String, dynamic> header, Map<String, dynamic> payload,
List<int> secretKey) {
final signingInput =
'${base64UrlNoPadding(utf8.encode(jsonEncode(header)))}.'
'${base64UrlNoPadding(utf8.encode(jsonEncode(payload)))}';
final mac = Hmac(sha256, secretKey).convert(utf8.encode(signingInput));
final signature = base64UrlNoPadding(mac.bytes);
return '$signingInput.$signature';
}
void main() {
final token = createJwt(
{'alg': 'HS256', 'typ': 'JWT'},
{'sub': 'user-42', 'exp': 1893456000},
utf8.encode('a-32-byte-secret-key-0123456789'),
);
print(token);
}
A assinatura é calculada sobre os bytes exatos que foram empacotados, então desde que você assine a mesma string que emite, a verificação do outro lado é a repetição dos mesmos passos. Três avisos. O pacote jwt antigo no pub.dev é de 2014 e antecede a null safety; a resposta que funciona no ecossistema é fazer o que está mostrado aqui com o crypto. Nunca emita um token com alg: none, e nunca deixe um cliente escolher o algoritmo. E lembre-se de que o payload é legível por qualquer um, então inclua só o que o token precisa provar.
Data URIs: embarcando arquivos dentro de texto
Uma data URI, definida pela RFC 2397, é uma URL cujo payload é o próprio dado. Conteúdo binário dentro de uma data URI é codificado em base64, que é por que o formato aparece em todo lugar onde documentos de texto precisam embutir imagens, fontes ou anexos: atributos HTML, CSS, JSON, arquivos de configuração. O Dart consegue montar as URIs nativamente, sem nenhum pacote de URI:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final png = await File('icon.png').readAsBytes();
final imageUri = Uri.dataFromBytes(png, mimeType: 'image/png');
print(imageUri); // data:image/png;base64,iVBOR...
final note = Uri.dataFromString('Hello, Dart!');
print(note); // data:,Hello,%20Dart!
}
Uri.dataFromBytes codifica em base64 por padrão (tem um opt-in percentEncoded: true para a outra forma), que é a codificação certa para binário. Uri.dataFromString faz percent-encoding por padrão, porque texto curto fica mais curto assim, e aceita a flag base64: true quando você quer a forma com bytes empacotados. O percalço prático é a escala: o payload viaja junto dentro do documento, com sobrecarga de 33 por cento, então data URIs são para assets pequenos, ícones e miniaturas, não para embarcar megabytes por CSS.
Arquivos: empacotando bytes para canais de texto
O trabalho do dia a dia: um arquivo que precisa viajar por JSON, um arquivo de configuração ou qualquer transporte só de texto. O padrão é ler bytes, codificar, embutir:
import 'dart:convert';
import 'dart:io';
Future<void> main() async {
final image = await File('photo.jpg').readAsBytes();
final encoded = base64Encode(image);
final upload = jsonEncode({
'name': 'photo.jpg',
'size': image.length,
'data': encoded,
});
print('payload ${upload.length} chars for ${image.length} bytes');
}
O número para guardar na cabeça é o crescimento: um arquivo de 2.000 bytes vira 2.668 caracteres base64, e um pouco mais quando as chaves do JSON entram. Dois percalços. Primeiro, confira que a sua entrada não está já codificada: codificar em base64 uma string que já é base64 é o bug clássico de codificação dupla, e ele decodifica "com sucesso" para outro muro de base64. Segundo, se o canal pode carregar binário, que é para isso que o multipart/form-data existe, carregue binário: ele é um quarto menor, e o imposto do base64 é puro desperdício.
HTTP e APIs: cabeçalhos e payloads
O trabalho de codificação mais familiar do HTTP é o cabeçalho Authorization: Basic: a palavra Basic, um espaço, e o base64 de alfabeto padrão de username:password:
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
final credentials = base64Encode(utf8.encode('octocat:secret'));
final client = http.Client();
final response = await client.get(
Uri.parse('https://httpbin.org/basic-auth/octocat/secret'),
headers: {'Authorization': 'Basic $credentials'},
);
print(response.statusCode);
client.close();
}
Com o pacote http, a um dart pub add http de distância, o cabeçalho é só uma string na requisição. O percalço é o enquadramento de segurança: o base64 aqui é ofuscação, não proteção. Qualquer pessoa consegue reverter em um passo, que é exatamente por que Basic auth só cabe em conexões TLS, onde é o transporte, não a codificação, que faz a proteção. Para campos de payload de API, siga o contrato: se diz base64, é o alfabeto padrão com padding, e a variante URL-safe é outra coisa que consumidores estritos vão rejeitar.
E-mail e MIME: quebrando em 76
O MIME, o sistema que deixa o e-mail carregar binário, usa base64 como uma codificação de transferência de conteúdo, e a RFC 2045 especifica que as linhas codificadas não podem passar de 76 caracteres, com CRLF entre elas. O limite é uma convenção MIME - 76 mais CRLF cabe com folga num display de 80 colunas - e todo codificador conforme quebra a linha. O codificador do Dart produz uma string sem quebras, então a quebra de linha é uma etapa curta de pós-processamento:
import 'dart:convert';
String wrapForMime(String base64Text, [int lineLength = 76]) {
final buffer = StringBuffer();
for (var i = 0; i < base64Text.length; i += lineLength) {
final end = i + lineLength > base64Text.length
? base64Text.length
: i + lineLength;
buffer
..write(base64Text.substring(i, end))
..write('\r\n');
}
return buffer.toString();
}
void main() {
final encoded = base64Encode(utf8.encode('Hello from an email attachment'));
print(wrapForMime(encoded));
}
Quebre a string pronta, padding incluído, e deixe a linha final ter o comprimento que tiver, até 76. A única coisa a não fazer é remover o padding antes de quebrar na esperança de salvar um caractere: os pads fazem parte do conteúdo codificado, e um consumidor que remontar as linhas vai rejeitar o resultado sem eles.
Configuração: tornando segredos uma linha só
Tokens, chaves e credenciais que contêm aspas, quebras de linha ou outros caracteres incômodos às vezes são codificados em base64 para caberem numa linha de configuração ou numa variável de CI limpinha. O enquadramento honesto primeiro: isso é ofuscação, não criptografia, e qualquer coisa que chegar a um repositório ou a um log é pública. Use o padrão para organização, nunca para sigilo. Codificar o valor é uma chamada:
import 'dart:convert';
String forEnvFile(String secret) {
return base64Encode(utf8.encode(secret));
}
void main() {
final line = 'API_TOKEN_B64=${forEnvFile('sk-live-abc123')}';
print(line); // API_TOKEN_B64=c2stbGl2ZS1hYmMxMjM=
}
O valor então mora num arquivo .env, num segredo de CI ou num define de compilação, e volta como texto simples depois de uma decodificação. Se o segredo precisa ser protegido no trânsito ou em repouso, vá a um gerenciador de segredos ou a uma biblioteca de criptografia; o trabalho do base64 aqui é manter o tratamento de texto do pipeline simples, nada mais.
Streams: codificando além das fronteiras dos blocos
Quando os bytes chegam em blocos, uma leitura de rede, um arquivo processado em pedaços, o codificador dá conta sem você alinhar nada. O codec carrega o grupo parcial pelas fronteiras dos blocos, então o tamanho dos blocos não precisa ser múltiplo de três:
import 'dart:convert';
import 'dart:typed_data';
Future<void> main() async {
final data = Uint8List(100000);
for (var i = 0; i < data.length; i += 31) {
data[i] = i % 256;
}
final chunks = <List<int>>[data.sublist(0, 777), data.sublist(777)];
final encoded = await Stream.fromIterable(chunks)
.transform(base64.encoder)
.join();
print('in: ${data.length}, out: ${encoded.length}'); // in: 100000, out: 133336
}
Dois blocos de tamanho incômodo, 777 e 99.223 bytes, produzem uma única string correta de 133.336 caracteres, porque o codificador estaciona os bits sobrando de cada grupo incompleto até o próximo bloco chegar, e emite o padding só no final. Se você prefere sinks, o base64.encoder.startChunkedConversion te dá a mesma máquina de estados como um ByteConversionSink (você alimenta com blocos de bytes, ele emite strings), que é o encaixe natural para escrever saídas grandes num arquivo ou num socket sem nunca juntar uma string gigante.
Big data: vazão e memória
A matemática do tamanho é exata e vale a pena guardar: o comprimento da saída é o comprimento da entrada dividido por três, arredondado para cima, vezes quatro. Um, dois ou três bytes custam quatro caracteres; a partir daí é uma sobrecarga plana de 33 por cento. A fórmula, para quando você precisar reservar buffers ou reportar progresso:
import 'dart:convert';
int encodedLength(int n) => (n + 2) ~/ 3 * 4;
void main() {
print(encodedLength(100000)); // 133336
}
Velocidade não é a restrição; o codificador é uma passada única de tabela de busca que lida com megabytes em milissegundos. As restrições são o imposto de tamanho em si, cobrado no fio e na memória, e o fato de a forma codificada ser uma string. Guarde as duas na cabeça em escala: para payloads que podem crescer muito, codifique em stream como mostrado acima em vez de acumular uma lista gigante e uma string gigante, e para transferências repetidas dos mesmos dados, pergunte se o canal tem um modo binário, porque 33 por cento é um acréscimo permanente que nenhum algoritmo reembolsa.
O codificador de linha de comando
A VM faz uma CLI limpa a partir do codificador. Esta ferramenta lê um argumento de arquivo ou a entrada padrão e imprime a codificação de alfabeto padrão:
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
final bytes = await _read(args);
stdout.writeln(base64Encode(bytes));
}
Future<List<int>> _read(List<String> args) async {
if (args.isNotEmpty) {
return File(args[0]).readAsBytes();
}
final all = <int>[];
await for (final chunk in stdin) {
all.addAll(chunk);
}
return all;
}
Salve como bin/encode.dart e rode dart run bin/encode.dart photo.jpg > photo.b64, ou use pipe com cat config | dart run bin/encode.dart. O companheiro, um decodificador que lê e achata, é o primeiro exemplo no guia de decodificação, e juntos os dois scripts formam um toolset pequeno, mas genuinamente útil, para mover binário por canais de texto.
Percalços que mordem na saída
- A armadilha do codeUnits.
base64Encode(text.codeUnits)empacota unidades UTF-16, não bytes; funciona para ASCII e lançaArgumentErrorna primeira unidade de código acima de 255. Sempre codifique texto com uma codificação nomeada primeiro. - Alfabeto errado. Alimentar um consumidor que espera o alfabeto padrão com saída URL-safe é um 400 esperando acontecer. Decida o alfabeto pela especificação, codifique uma vez e não converta depois.
- Suposições de padding. O Dart sempre dá padding. Se a especificação quer sem padding, remova com
replaceAll('=', '')como etapa explícita na fronteira, e diga isso no contrato. - Deriva de charset. Codificar bytes Latin-1 para um consumidor que decodifica UTF-8 produz um base64 válido dos dados errados. Nada lança erro; o texto é só errado.
- Codificação dupla. Codificar em base64 um valor que já é base64, um token copiado de outra configuração, é o bug clássico de "decodifica para outro muro de base64".
- Ilusão de privacidade. Base64 é um formato, não um cifrador. Se o modelo de ameaça envolve um leitor, a resposta é criptografia, não codificação.
- Pacotes desatualizados. O pacote
jwtde longa data no pub.dev antecede a null safety; para trabalho com JWT, ocryptomais as poucas linhas acima é o caminho mantido.
Quando procurar outra coisa
- Upload de arquivos por HTTP. Use
multipart/form-data; ele carrega bytes crus, então você pula o imposto de 33 por cento inteiro. - Payloads grandes ou repetitivos. Comprima primeiro, codifique depois: base64 de texto comprimido é dramaticamente menor que base64 do texto, e o lado que descomprime já conhece o formato.
- Texto curto dentro de URLs. Percent-encoding é mais curto para uma mão cheia de caracteres e mantém o valor legível por humanos; data URIs até fazem isso por você por padrão.
- Saída de debug e logs. Hex é 50 por cento mais longo que base64 (o dobro do tamanho bruto, base64 só quatro terços), mas muito mais fácil de varrer, diffar e entregar a um colega; para trechos binários em logs ele costuma vencer.
Boas práticas, a lista do codificador
- Codifique bytes, nunca unidades de código; texto passa por uma codificação nomeada primeiro.
- Escolha o alfabeto pela especificação do consumidor antes de escrever a chamada.
- Remova padding só onde a especificação diz sem padding, como passo visível na fronteira.
- Deixe o charset explícito no contrato; não assuma nada sobre o outro lado.
- Use stream para qualquer coisa que possa crescer muito.
- Trate o base64 como um formato para canais só de texto, nunca como uma proteção para dados sensíveis.
Uma breve história de dois alfabetos
O formato que você acabou de usar é mais antigo que qualquer release do Dart, e as escolhas de alfabeto que você tem disponíveis foram padronizadas décadas antes do Dart chegar. A versão curta:
- 1993, RFC 1521: o MIME introduz o base64 como codificação de transferência de conteúdo para e-mail, com o alfabeto padrão de 64 caracteres e o limite de linha de 76 caracteres no qual este artigo quebra. O trabalho do formato, carregar binário por canais de texto, é desta época.
- 1996, RFC 2045: a RFC que tornou o MIME obsoleto e estabeleceu as regras de padding e de comprimento de linha do base64 como padrão durável.
- 2006, RFC 4648: a codificação é puxada do MIME e padronizada sozinha, adicionando o alfabeto URL-safe e o conselho de que decodificadores devem rejeitar entrada inválida. A escolha de dois alfabetos que você tem no Dart vem deste documento.
- 2015, RFC 7515: as JSON Web Signatures especificam base64url sem padding, a convenção por trás de todo JWT.
- Novembro de 2015, Dart 1.13: o base64 chega no
dart:convert; a variante URL-safe segue no Dart 1.16 na primavera seguinte, e as chamadas de nível superiorbase64Encodeebase64UrlEncodeque você usou acima aterrissam no Dart 2.0 em 2018. - Hoje, Dart 3.13: os dois alfabetos, sempre com padding, a um import de distância, a mesma máquina estrita e simples desde 2015.
A sobrecarga de 33 por cento também não mudou desde 1993. É uma propriedade da matemática, quatro símbolos para três bytes, e toda implementação que você já usou, em qualquer linguagem, paga a taxa de forma idêntica.
Fatos curiosos da bancada de codificação
- O codificador não pode ser desligado: não existe flag para saída sem padding no SDK, que é por que "remover os pads" é sempre o seu código, na sua fronteira, em plena vista.
- Um byte vira quatro caracteres:
base64Encode([65])éQQ==. A string base64 mais curta possível tem quatro caracteres, e só os dois primeiros carregam informação; os dois últimos são padding. - Os dois codificadores do Dart dão padding, até o
base64UrlEncode. O "sem padding" do base64url é uma convenção do consumidor da RFC 7515, não uma propriedade do alfabeto. - O alfabeto padrão foi desenhado para ser imprimível em 7 bits, e ele segue sendo o padrão desde então; o fato de
+e/terem acabado ganhando substitutos URL-safe é um sinal de quão central ele se tornou, não um defeito. - Os mesmos 20 bytes UTF-8 de
Héllo Wörld 世界empacotam paraSMOpbGxvIFfDtnJsZCDkuJbnlYw=, enquanto as 14 unidades de código da string derrubariam o codificador no índice 12. Mesmos caracteres, duas saídas completamente diferentes, uma delas um erro. - Arquivos PEM, os blocos
-----BEGIN CERTIFICATE-----de todo certificado TLS, são base64 quebrado a cada 64 caracteres com cabeçalhos, e o formato é de 1987, seis anos antes do MIME publicar o base64 para e-mail.
Agora você tem o lado inteiro da codificação: a superfície de API, a disciplina de bytes primeiro, as decisões de padding e alfabeto, e os padrões funcionais para JWTs, data URIs, arquivos, HTTP, MIME, configuração, streams e o shell. A direção inversa, desmontar uma dessas strings, com toda a estriteza do decodificador, a surpresa do percent-escape e as ferramentas de reparo, é coberta no guia de decodificação Base64, linkado logo abaixo.
Última atualização: 2026-09-08
Artigo relacionado: Decodificação Base64 em Dart: um guia completo