Base64 Encoding in Dart: A Complete Guide
You have bytes, and you need a string. The payload might be a file, an authentication credential, a configuration token, or a binary blob riding inside a JSON document, and the channel only accepts text. Base64 is the trade that solves that: every three input bytes become four characters from a 64-character alphabet, so the output is always a clean multiple of four and always safe in text-only worlds. The price is fixed at 33 percent more characters, and the format adds one or two = padding characters at the end when the last chunk is short. This guide is the Dart recipe for making that trade correctly.
Nothing to install. Base64 has shipped in dart:convert since Dart 1.13 in 2015, the API has been stable ever since, and both alphabets, standard and URL-safe, have been available for over a decade. The home page walks through the format in depth; here is the encoding side of the work: the full API surface, the bytes-first discipline that avoids the most common bug, padding and alphabet choices, and the real-world jobs: JWTs, data URIs, file uploads, HTTP headers, MIME, configuration, streams and the command line. Decoding, the reverse direction, has its own guide, linked at the end.
One Import, Two Alphabets, One Padding Rule
The entire public surface for encoding lives in dart:convert:
| Entry | Alphabet | Reach for it when |
|---|---|---|
base64Encode(bytes) |
standard: A-Z a-z 0-9 + /, padded |
APIs, MIME, Basic auth, most consumers |
base64UrlEncode(bytes) |
URL-safe: A-Z a-z 0-9 - _, still padded |
URLs, filenames, JWTs, object ids |
base64.encode(bytes) |
standard, identical to the top-level call | Stream transforms and codec pipelines |
Base64Encoder().convert(bytes) |
standard | You want a named encoder instance |
Two rules cover all four rows. First, the input must be a list of byte values, integers from 0 to 255; anything else, including negatives or 256 and above, throws an ArgumentError that names the bad index. Second, the output is always padded: there is no flag, constructor, or option that produces unpadded output, because the format's padding is a property of the data, and the specs that want it gone strip it as a separate, documented step. The smallest possible example, end to end:
import 'dart:convert';
void main() {
final text = 'Dart is open source';
final bytes = utf8.encode(text);
final encoded = base64Encode(bytes);
print(encoded); // RGFydCBpcyBvcGVuIHNvdXJjZQ==
}
Bytes First: The Order That Saves You
The most common Dart base64 bug is not about base64 at all. It is about the order of operations. A Dart String is a sequence of UTF-16 code units, and calling base64Encode(text.codeUnits) packs those 16-bit units, not the bytes the receiver expects. For pure ASCII the two happen to agree, which is why the bug hides until the first accented character, emoji, or CJK text arrives. Then the encoder refuses the work, because a code unit like 0x4e16 is not a byte value:
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);
}
}
The ArgumentError points at the exact offending index, so the failure is loud rather than silent. The discipline to keep: decide what the bytes are before you talk to base64. Text goes through a named encoding, utf8.encode for modern data, and the resulting List<int> is what gets packed. Bytes from a file or a network socket already arrive as a Uint8List, which is the right shape for the encoder without any conversion.
Padding: The Encoder's Job
Base64 maps groups of three bytes to four characters, so a payload whose length is not a multiple of three leaves a partial group at the end. The format marks that shortfall with = characters: one input byte becomes four characters plus two pads, two bytes become four characters plus one pad, three bytes become exactly four characters. The Dart encoder does this for you, unconditionally:
import 'dart:convert';
void main() {
print(base64Encode([0x41])); // QQ==
print(base64Encode([0x41, 0x42])); // QUI=
print(base64Encode([0x41, 0x42, 0x43])); // QUJD
}
That unconditional behavior is a feature: the output is always a legal, self-describing base64 string. When a spec asks for the unpadded variant, and JWTs are the usual reason, the stripping is your explicit, visible step, not a library setting:
base64UrlEncode(bytes).replaceAll('=', '')
Put the strip where the spec boundary is, name it, and document it. The decoder side of this trade, including how damaged or stripped input gets repaired, is covered in the decoding guide.
URL-Safe Base64
The standard alphabet contains +, / and =, and those three characters collide with URL syntax: query separators, path separators, and parameter delimiters. The URL-safe alphabet, standardized as base64url in RFC 4648, swaps + for - and / for _, so the output can sit in a path segment, a query value, or a filename without escaping. Here is the difference on bytes that exercise both swapped characters:
import 'dart:convert';
void main() {
final tricky = [0xfb, 0xff, 0xfe, 0xf9];
print(base64Encode(tricky)); // +//++Q==
print(base64UrlEncode(tricky)); // -__--Q==
}
Choose by the consumer, not by taste. If the value will live in a URL, a JWT, or a filename, encode with base64UrlEncode and strip the padding if the spec is unpadded. If the value will be a MIME body, a Basic auth header, or a field in an API contract that says "base64", use the standard alphabet, because base64 without qualification means the standard one. The two alphabets are not interchangeable in the eyes of strict consumers: a server expecting standard base64 may reject a payload containing - with a 400 and nothing more helpful.
Charsets: Which Bytes Are You Packing?
When the input is text, the encoding step decides which bytes base64 will see, and the consumer assumes a charset on the other side. If your assumption and the consumer's differ, the output is perfectly valid base64 of the wrong bytes, the worst kind of bug, because nothing throws. For any modern exchange, UTF-8 is the default; the other single-byte encodings exist for legacy data:
| Encoding | Use it for | Encode with |
|---|---|---|
utf8 |
Modern text, JSON, anything on the web | utf8.encode(text) |
latin1 |
Legacy Western single-byte data | latin1.encode(text) |
ascii |
Plain 7-bit text | 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=
}
Same word, different bytes, different base64. Note the lengths: UTF-8 needs six bytes for Héllo because the accent is a two-byte sequence, while Latin-1 fits it in five. If the consumer decodes with the encoding you did not use, they get mojibake, and it will look like the data was corrupted in transit when in fact it was corrupted in intent.
JWTs: Writing the Token
A JSON Web Token is three base64url parts joined by dots: header, payload, signature. RFC 7515 pins two details: the alphabet is URL-safe, and the padding is omitted, because the token is designed to sit in URLs and headers. The signature for the HS256 algorithm is the HMAC-SHA256 of header.payload, itself base64url without padding. Hand-rolling it with the crypto package is a few lines, and it is more transparent than it looks:
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);
}
The signature is computed over the exact bytes that were packed, so as long as you sign the same string you emit, verification on the other side is a repeat of the same steps. Three warnings. The old jwt package on pub.dev is from 2014 and predates null safety; the ecosystem's working answer is to do what is shown here with crypto. Never emit a token with alg: none, and never let a client choose the algorithm. And remember the payload is readable by anyone; put in it only what the token is meant to prove.
Data URIs: Shipping Files Inside Text
A data URI, defined by RFC 2397, is a URL whose payload is the data itself. Binary content inside a data URI is base64 encoded, which is why the format shows up everywhere text documents need to embed images, fonts, or attachments: HTML attributes, CSS, JSON, configuration files. Dart can build the URIs natively, with no URI package required:
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 always uses base64, which is the correct encoding for binary. Uri.dataFromString percent-encodes by default, because short text is shorter that way, and accepts a base64: true flag when you want the bytes-packed form. The practical pitfall is scale: the payload rides along inside the document, at 33 percent overhead, so data URIs are for small assets, icons and thumbnails, not for shipping megabytes through CSS.
Files: Packing Bytes for Text Channels
The everyday job: a file that must travel through JSON, a config file, or any text-only transport. The pattern is read bytes, encode, embed:
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');
}
The number to keep in your head is the growth: a 2,000-byte file becomes 2,668 base64 characters, and a bit more once the JSON keys join in. Two pitfalls. First, check that your input is not already encoded: base64-encoding an already-base64 string is the classic double-encode bug, and it decodes "successfully" into another wall of base64. Second, if the channel can carry binary, which is what multipart/form-data exists for, carry binary: it is a third smaller, and the base64 tax is pure waste.
HTTP and APIs: Headers and Payloads
The most familiar encoding job in HTTP is the Authorization: Basic header: the word Basic, a space, and the standard-alphabet base64 of 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://api.example.com/me'),
headers: {'Authorization': 'Basic $credentials'},
);
print(response.statusCode);
client.close();
}
With the http package, one dart pub add http away, the header is just a string in the request. The pitfall is the security framing: base64 here is obfuscation, not protection. Anyone can reverse it in one step, which is exactly why Basic auth belongs only on TLS connections, where the transport, not the encoding, is doing the protecting. For API payload fields, follow the contract: if it says base64, that is the standard alphabet with padding, and the URL-safe variant is a different thing that strict consumers will reject.
Email and MIME: Wrapping at 76
MIME, the system that lets email carry binary, uses base64 as a content transfer encoding, and RFC 2045 specifies that encoded lines must not exceed 76 characters, with CRLF between them. The limit is a convention inherited from the SMTP world, and every conforming encoder wraps. Dart's encoder produces one unbroken string, so the wrapping is a short post-processing step:
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));
}
Wrap the finished string, padding included, and let the final line be as long as it is, up to 76. The one thing not to do is strip the padding before wrapping in the hope of saving a character: the pads are part of the encoded content, and a consumer that reassembles the lines will reject the result without them.
Configuration: Making Secrets One-Liners
Tokens, keys, and credentials that contain quotes, newlines, or other awkward characters are sometimes base64 encoded so they fit a configuration line or a CI variable cleanly. The honest framing first: this is obfuscation, not encryption, and anything that ever reaches a repository or a log is public. Use the pattern for tidiness, never for secrecy. Encoding the value is one call:
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=
}
The value then sits in a .env file, a CI secret, or a compile-time define, and comes back as plain text after one decode. If the secret must be protected in transit at rest, reach for a secret manager or an encryption library; base64's job here is to keep the pipeline's text handling simple, nothing more.
Streams: Encoding Across Chunk Boundaries
When the bytes arrive in chunks, a network read, a file processed in blocks, the encoder copes without you aligning anything. The codec carries the partial group across chunk boundaries, so chunk sizes do not need to be multiples of three:
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
}
Two awkwardly sized chunks, 777 and 99,223 bytes, produce one correct 133,336-character string, because the encoder parks the leftover bits of each incomplete group until the next chunk arrives, and emits the padding only at the end. If you prefer sinks, base64.encoder.startChunkedConversion gives you the same state machine as a StringConversionSink, which is the natural fit for writing large outputs to a file or a socket without ever joining one big string.
Big Data: Throughput and Memory
The size math is exact and worth keeping: the output length is the input length divided by three, rounded up, times four. One, two, or three bytes all cost four characters; from there it is a flat 33 percent overhead. The formula, for when you need to reserve buffers or report progress:
import 'dart:convert';
int encodedLength(int n) => (n + 2) ~/ 3 * 4;
void main() {
print(encodedLength(100000)); // 133336
}
Speed is not the constraint; the encoder is a single table lookup pass that handles megabytes in milliseconds. The constraints are the size tax itself, charged on the wire and in memory, and the fact that the encoded form is a string. Keep both in mind at scale: for payloads that can grow large, stream the encode as shown above instead of accumulating one big list and one big string, and for repeated transfers of the same data, ask whether the channel has a binary mode, because 33 percent is a permanent surcharge that no algorithm can refund.
The Command-Line Encoder
The VM makes a clean CLI out of the encoder. This tool reads a file argument or standard input and prints the standard-alphabet encoding:
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;
}
Save it as bin/encode.dart and run dart run bin/encode.dart photo.jpg > photo.b64, or pipe it with cat config | dart run bin/encode.dart. The companion, a decoder that reads and flattens, is the first example in the decoding guide, and together the two scripts are a small but genuinely useful toolset for moving binary through text channels.
Pitfalls That Bite on the Way Out
- The codeUnits trap.
base64Encode(text.codeUnits)packs UTF-16 units, not bytes; it works for ASCII and throwsArgumentErrorat the first code unit above 255. Always encode text with a named encoding first. - Alphabet mismatch. Feeding URL-safe output to a consumer that expects the standard alphabet is a 400 waiting to happen. Decide the alphabet from the spec, encode once, and do not convert after the fact.
- Padding assumptions. Dart always pads. If the spec wants unpadded, strip with
replaceAll('=', '')as an explicit step at the boundary, and say so in the contract. - Charset drift. Encoding Latin-1 bytes for a consumer that decodes UTF-8 produces valid base64 of the wrong data. Nothing throws; the text is just wrong.
- Double encoding. Base64-encoding a value that is already base64, a token copied out of another config, is the classic "decodes to another wall of base64" bug.
- Privacy illusion. Base64 is a format, not a cipher. If the threat model involves a reader, the answer is encryption, not encoding.
- Outdated packages. The long-standing
jwtpackage on pub.dev predates null safety; for JWT work,cryptoplus the few lines above is the maintained path.
When to Reach for Something Else
- File uploads over HTTP. Use
multipart/form-data; it carries raw bytes, so you skip the 33 percent tax entirely. - Large or repetitive payloads. Compress first, encode second: base64 of gzipped text is dramatically smaller than base64 of the text, and the decompressing side already knows the format.
- Short text inside URLs. Percent-encoding is shorter for a handful of characters and keeps the value human-readable; data URIs even do it for you by default.
- Debug output and logs. Hex is twice as long as base64 but far easier to scan, diff, and hand to a colleague; for binary snippets in logs it usually wins.
Best Practices, the Encoder's List
- Encode bytes, never code units; text goes through a named encoding first.
- Pick the alphabet from the consumer's spec before you write the call.
- Strip padding only where the spec says unpadded, as a visible step at the boundary.
- State the charset explicitly in the contract; assume nothing about the other side.
- Stream anything that can grow large.
- Treat base64 as a format for text-only channels, never as a protection for sensitive data.
A Brief History of Two Alphabets
The format you just used is older than every Dart release, and the alphabet choices you have available were standardized decades before Dart arrived. The short version:
- 1993, RFC 1521: MIME introduces base64 as a content transfer encoding for email, with the standard 64-character alphabet and the 76-character line limit this article wraps at. The format's job, carrying binary through text channels, dates from here.
- 1997, RFC 2045: the MIME obsoletion that made base64's padding and line-length rules the durable standard.
- 2006, RFC 4648: the encoding is pulled out of MIME and standardized on its own, adding the URL-safe alphabet and the advice that decoders should reject invalid input. The two-alphabet choice you get in Dart comes from this document.
- 2015, RFC 7515: JSON Web Signatures specify base64url without padding, the convention behind every JWT.
- November 2015, Dart 1.13: base64 arrives in
dart:convert; the URL-safe variant follows in Dart 1.16 the next spring, and the top-levelbase64Encodeandbase64UrlEncodecalls you used above land in Dart 2.0 in 2018. - Today, Dart 3.13: both alphabets, always padded, one import away, the same strict and simple machine since 2015.
The 33 percent overhead has not changed since 1993 either. It is a property of the math, four symbols for three bytes, and every implementation you will ever use, in every language, pays it identically.
Fun Facts from the Encoding Bench
- The encoder cannot be turned off: there is no flag for unpadded output in the SDK, which is why "strip the pads" is always your code, at your boundary, in plain sight.
- One byte becomes four characters:
base64Encode([65])isQQ==. The shortest possible base64 string is four characters long, and only the last two of them carry information. - Both Dart encoders pad, even
base64UrlEncode. The "no padding" in base64url is a consumer convention from RFC 7515, not a property of the alphabet. - The standard alphabet was designed to be 7-bit printable with no characters that break URLs or filenames, which is exactly why
+and/earned their URL-safe replacements rather than being dropped. - The same 20 UTF-8 bytes of
Héllo Wörld 世界pack intoSMOpbGxvIFfDtnJsZCDkuJbnlYw=, while the string's 14 code units would crash the encoder at index 12. Same characters, two completely different outputs, one of them an error. - PEM files, the
-----BEGIN CERTIFICATE-----blocks in every TLS certificate, are base64 wrapped at 64 characters with headers, and the format dates from 1987, two years before MIME published base64 for email.
You now have the whole encoding side: the API surface, the bytes-first discipline, the padding and alphabet decisions, and the working patterns for JWTs, data URIs, files, HTTP, MIME, configuration, streams and the shell. The reverse direction, taking one of these strings apart, with all of the decoder's strictness, its percent-escape surprise, and its repair tools, is covered in the Base64 decoding guide, linked right below.
Last updated: 2026-08-30
Related article: Base64 Decoding in Dart: A Complete Guide