Do you have to deal with Base64 format? Then this site is perfect for you! Use our super handy online tool to encode or decode your data.

Base64 Decoding in Dart: A Complete Guide

It arrives in an API response, inside a URL, or pasted into a support ticket: a long run of letters and digits with the occasional +, /, -, or _, and maybe a pair of = signs dangling at the end. Someone calls it Base64, and you need what is inside. This guide is the Dart recipe for getting it back. Quick orientation, because the home page walks through the format in depth: Base64 rewrites every three input bytes as four characters from a 64-character alphabet, and tacks one or two = pads onto the end when the final chunk is short. Decoding is the shrinking direction of that trade: four characters go in, three bytes come out, so the result always takes about a quarter less space than the input.

The good news: there is nothing to install. Base64 has shipped in the dart:convert library since Dart 1.13 in 2015, and the API has been stable ever since. One import gives you a fast, strict decoder that reads the standard alphabet, the URL-safe alphabet, and even the percent-escapes you meet in data URIs.

One honest boundary: this is the decoder side of the story. You will learn what the decoder accepts and refuses, how padding works, how to turn bytes back into text without mojibake, and how to meet Base64 in JWTs, data URIs, files, streams, email, configuration and the command line. The other direction, packing bytes into a string, has its own guide, linked at the end of this one.

The Four Doors Into One Strict Machine

Here is the whole public surface you will use, all of it in dart:convert:

Entry What it is Reach for it when
base64Decode(source) Top-level function, decodes into a Uint8List Everyday decoding, almost always this one
base64.decode(source) The codec's decode method, identical behavior You want the codec for fuse or stream transforms
base64Url.decode(source) The URL-safe codec's decode method The input was documented as URL-safe (the machine is the same)
base64Url.normalize(source) Validates and repairs a string, returns it padded Input may lack padding, mix alphabets, or use percent-escapes

Two things to notice. First, all four roads lead to the same decoder: one strict state machine with one lookup table. Second, the last row is not a decoder at all. It is a repair station, and it will earn its keep the first time a stripped JWT or a half-cleaned config value shows up.

Your First Decode

Ninety percent of decoding life fits in five lines. Here is the smallest example that shows the whole shape of the work:

import 'dart:convert';
void main() {
  final bytes = base64Decode('TWFu');
  final text = utf8.decode(bytes);
  print(text); // Man
}

Three sentences about what just happened. First, the entry point hands back bytes, not text: base64Decode returns a Uint8List, and that is deliberate, because the payload might be a sentence, a JPEG, or a hash, and none of them should be treated the same before you know what you have. Second, the jump from bytes to text is a separate, explicit step with an explicit encoding, and that step is where "café" turns into mojibake if you are careless. Third, the empty string is a first-class value: base64Decode('') gives you a zero-length list with no exception and no fuss.

What the Decoder Accepts and Refuses

Dart's decoder is strict by design. RFC 4648 says implementations should reject input with characters outside the alphabet, and Dart follows that reading to the letter: no skipping spaces, no ignoring line breaks, no second chances. When the input is wrong, you get a FormatException that shows the input and points at the exact character. Here is the behavior on the classic troublemakers:

Input What is wrong Exact error
'SGVs bG8s' a space sneaked in FormatException: Invalid character (at character 5)
'SGVs\nbG8s' a line break sneaked in FormatException: Invalid character (at character 5)
'SGVs$bG8s' a dollar sign is not in the alphabet FormatException: Invalid character (at character 5)
'Zm8' no padding at all FormatException: Invalid length, must be multiple of four (at character 4)
'Zm8==' two pads where one belongs FormatException: Invalid padding character (at character 5)
'Zm=8' padding in the middle of the data FormatException: Invalid encoding before padding (at character 3)
'Zm8=xx' junk after the pads FormatException: Invalid padding character (at character 5)
'Zé' a non-ASCII character FormatException: Invalid character (at character 2)

The position in the message is a one-based character count, and the input is printed right below the caret, so bisecting a corrupted payload is quick. One pleasant surprise hides in the strictness: the decoder accepts both alphabets. A - or _ in the middle of a standard string is fine, and a + or / in a URL-safe string is fine too. The alphabet choice only matters when you are the one producing the text, not when you are reading it.

Padding: The Non-Negotiable

Here is the rule that surprises most people: the Dart decoder requires correct padding. The input must be a multiple of four characters long, and the trailing = signs must be present in the exact right amount. There is no lenient mode, no flag to loosen it, and no configuration to change. The reasons are sound: unpadded decoding is ambiguous in corner cases, and the RFC points out that being liberal about padding can open a covert channel, so the strict reading is the safe one. What this means in practice:

Input Result
'' empty Uint8List, no error
'QQ==' 1 byte: A
'QUI=' 2 bytes: AB
'QUJD' 3 bytes: ABC
'Zm8' FormatException: invalid length
'Zm8==' FormatException: invalid padding character

When the input comes from a system that strips padding, and JWTs are full of those, the repair step is one call to normalize. It validates the string, converts URL-safe characters to the standard alphabet, and adds the missing pads:

import 'dart:convert';
void main() {
  final stripped = '-__--Q';
  final repaired = base64Url.normalize(stripped);
  print(repaired); // +//++Q==
  final bytes = base64Decode(repaired);
  print('decoded ${bytes.length} bytes'); // decoded 4 bytes
}

The Percent Sign Surprise

This one is a Dart original. When Base64 appears in a data URI, some tools percent-encode the padding, writing %3D instead of =, because a bare = can mean "parameter separator" in URL syntax. Most languages would want you to unescape first. Dart's decoder does not: its lookup table treats %3D as a native spelling of the padding character, so you can hand it the raw payload:

import 'dart:convert';
void main() {
  final fromDataUri = 'SGVsbG8%3D';
  final bytes = base64Decode(fromDataUri);
  print(utf8.decode(bytes)); // Hello
}

The escape is accepted exactly where padding is legal, which is the trailing position. Put %3D where a = would be rejected and it is rejected the same way, and %25 (an escaped percent sign) is not a letter, so it throws Invalid character. In practice this means a ;base64, payload copied straight out of a browser's developer tools decodes without any preprocessing, a small but genuinely convenient trick.

URL-Safe Base64

RFC 4648 defines a second alphabet for one reason: the standard one has three characters, +, / and =, that collide with URL syntax. The URL-safe alphabet, called base64url in the RFC, swaps + for - and / for _, and often drops the padding as well. It is the alphabet of JWTs, object IDs, share links and anything that lives inside a URL or a filename.

On the decoding side, Dart gives you a single answer: both alphabets are read by the same machine. base64Decode and base64Url.decode are two names for the same decoder, so the only real work is padding, because URL-safe producers very often ship without it. That is exactly what normalize is for:

import 'dart:convert';
void main() {
  final bytes = [0xfb, 0xff, 0xfe, 0xf9];
  final urlSafe = base64UrlEncode(bytes);
  print(urlSafe); // -__--Q==
  final repaired = base64Url.normalize(urlSafe.replaceAll('=', ''));
  print(repaired); // +//++Q==
  print(base64Decode(repaired).length); // 4
}

Two pitfalls to leave here. Do not hand-roll a --to-+ replacement before decoding; it is unnecessary, and normalize already does the alphabet conversion when needed. And do not assume a URL-safe string arrives unpadded: some producers keep the pads, and the decoder accepts both, as long as the padding is correct.

From Bytes to Text: The Charset Decision

Base64 decoding hands you bytes. If those bytes are text, you must choose the encoding that turns them back into a String, and that choice is yours to make explicitly. The default assumption in modern systems is UTF-8, and utf8.decode is the workhorse:

import 'dart:convert';
void main() {
  final payload = base64Encode(utf8.encode('Héllo Wörld'));
  final bytes = base64Decode(payload);
  print(utf8.decode(bytes)); // Héllo Wörld
  final legacy = base64Encode(latin1.encode('Héllo'));
  print(latin1.decode(base64Decode(legacy))); // Héllo
}

When the bytes are not valid UTF-8, utf8.decode throws a FormatException, which is the right behavior, far better than silent mojibake. If you know the data is legacy single-byte text, use the matching encoding:

Encoding Use it for Decode with
utf8 Modern text, JSON, anything on the web utf8.decode(bytes)
latin1 Legacy Western single-byte data latin1.decode(bytes)
ascii Plain 7-bit text ascii.decode(bytes)

One trap deserves its own warning: String.fromCharCodes is not a charset. It reads bytes as UTF-16 code units, so feed it the UTF-8 bytes of Héllo and it prints Héllo with a straight face. If you see that mojibake pattern in your output, the fix is almost always utf8.decode.

JWTs: Reading the Token

A JSON Web Token is three base64url parts joined by dots: header, payload, signature. Base64 is used here for compactness and URL safety, not for secrecy. Anyone with the token can read the header and payload, and that is by design. The signature is what you verify, with the shared secret or the issuer's public key. Decoding the readable parts in Dart takes a few lines:

import 'dart:convert';
Map<String, dynamic> readJwtPayload(String token) {
  final parts = token.split('.');
  if (parts.length != 3) {
    throw FormatException('Not a compact JWT');
  }
  final padded = base64Url.normalize(parts[1]);
  final bytes = base64Decode(padded);
  return jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>;
}
void main() {
  const token =
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
      '.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkRhcnQgRGV2IiwiaWF0IjoxNTE2MjM5MDIyfQ'
      '.c2lnbmF0dXJl';
  print(readJwtPayload(token)['name']); // Dart Dev
}

Notice the padding dance: JWTs are built without padding, so a part will fail a direct base64Decode whenever its length is not a multiple of four. (In the example above the header happens to be 36 characters long and decodes directly; the payload is 74 characters and does not.) The normalize call makes the repair uniform regardless of length. Two more warnings. Decoding is not verification: checking the signature and the exp claim is a separate, mandatory step, usually with the crypto package for HMAC algorithms. And be suspicious of tokens that claim alg: none; a parser that accepts them is a vulnerability, not a feature.

Data URIs: Files Wearing a URL Costume

A data URI, defined by RFC 2397, is a URL whose payload is the data itself: data:image/png;base64, followed by the encoded bytes. They exist so that text-only channels, HTML attributes, CSS rules, JSON documents, can carry binary without a separate file. Base64 is the payload format of choice because the alternative, percent-encoding, is far longer for binary data.

And Dart can parse them natively: data URI support has been in dart:core since 2016, so no URI library is needed:

import 'dart:convert';
void main() {
  final uri = Uri.parse('data:image/png;base64,iVBORw0KGgo=');
  final data = uri.data!;
  print(data.mimeType); // image/png
  print(data.isBase64); // true
  print('decoded ${data.contentAsBytes().length} bytes');
  final textUri = Uri.parse('data:text/plain;base64,SGVsbG8sIERhcnQh');
  print(textUri.data!.contentAsString()); // Hello, Dart!
}

The UriData object gives you the mime type, the isBase64 flag, the raw payload text, and the decoded content as a string or as bytes. Two pitfalls: the declared mime type can lie, so for security-sensitive code check the actual magic bytes; and data URIs are for small assets, because the whole payload rides along inside the document that references it.

Files: Base64 on Disk

Base64 files show up in export formats, provisioning bundles, and any text-only transfer that needs to carry binary. The recipe is: read the text, flatten it, decode, write the bytes:

import 'dart:convert';
import 'dart:io';
Future<void> main() async {
  final encoded = await File('image.b64').readAsString();
  final flat = encoded.replaceAll(RegExp(r'\s+'), '');
  final bytes = base64Decode(flat);
  await File('image.png').writeAsBytes(bytes);
  print('wrote ${bytes.length} bytes');
}

That replaceAll is doing real work. Text files are full of line breaks, often the 76-character MIME wrapping, and the strict decoder rejects them, so flatten first. The regex removes every whitespace character, which is exactly what you want for a pure base64 file. If the file might contain other annotations, like a PEM header, strip those explicitly before decoding, and let the decoder's errors catch anything that is actually corrupted.

HTTP and APIs

Base64 in HTTP wears two costumes. First, API responses: a JSON field that carries binary as a string. Second, the Authorization: Basic header, where credentials are base64 encoded with the standard alphabet and padding:

import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> main() async {
  final response = await http.get(
    Uri.parse('https://api.example.com/files/1'),
  );
  final payload = jsonDecode(response.body) as Map<String, dynamic>;
  final bytes = base64Decode(payload['attachment'] as String);
  print('got ${bytes.length} bytes');
  final credentials = utf8.decode(base64Decode('b2N0b2NhdDpzZWNyZXQ='));
  print(credentials.split(':').first); // octocat
}

The http package is the standard client, one dart pub add http away. For Basic auth you decode the part after the Basic prefix. Two pitfalls: some APIs send URL-safe or unpadded values where the docs say base64, so if direct decoding throws, run the value through base64Url.normalize first; and remember that Basic auth is obfuscation, not protection, which is why it only belongs on TLS connections.

Email and MIME: The Line-Break Problem

Email is the oldest base64 customer. MIME wraps base64 lines at 76 characters, a limit inherited from SMTP, and RFC 2045 tells decoders to ignore the line breaks. Dart's decoder does not, on purpose: it rejects them. The fix is to flatten before decoding:

import 'dart:convert';
List<int> decodeMimeBody(String wrapped) {
  final flat = wrapped.replaceAll(RegExp(r'\s+'), '');
  return base64Decode(flat);
}
void main() {
  const wrapped =
      'SGVsbG8gZnJvbSBhbiBlbWFpbCBhdHRhY2htZW50LCB3cmFwcGVkIGF0IDc2IGNoYXJhY3RlcnMg'
      '\r\n'
      'dGhlIHdheSBNSU1FIHdhbnRzIGl0IHRvIGJlLCB3aXRoIENSTEYgYmV0d2VlbiB0aGUgbGluZXMu';
  print(utf8.decode(decodeMimeBody(wrapped)));
}

The rule is simple: strip whitespace, nothing else. Do not strip other characters in the hope of being helpful; the decoder is the validator, and you want it to complain about real corruption. If you are processing email at scale, the flatten step is cheap, one regex pass, and it keeps the rest of the pipeline honest.

Configuration and Environment Variables

Tokens and credentials that live in text configuration are sometimes base64 encoded to keep them on one line and looking like tokens. The honest framing: base64 is obfuscation, not encryption, so this pattern is for tidiness, never for secrecy. The pattern itself is trivial:

import 'dart:convert';
import 'package:dotenv/dotenv.dart';
Future<void> main() async {
  final env = DotEnv()..load();
  final encoded = env['API_TOKEN_B64'];
  if (encoded == null) {
    return;
  }
  final token = utf8.decode(base64Decode(encoded));
  print('loaded a ${token.length}-char token');
}

With the dotenv package the value sits in a .env file as API_TOKEN_B64=c2stbGl2ZS1hYmMxMjM= and comes back as plain text after the decode. The same shape works with String.fromEnvironment for compile-time dart-define values, with the warning that dart-define values are baked into the compiled binary, so anything secret belongs in runtime configuration or a secret manager, not there.

Streams: Chunk by Chunk

When the encoded text arrives in pieces, a network stream, a large file read in blocks, the decoder copes. Its state machine carries the partial group across chunk boundaries, so the chunks do not need to align on four-character boundaries:

import 'dart:convert';
Future<void> main() async {
  final incoming = Stream.fromIterable(['TWF', 'uaGVsbG8=']);
  final text = await incoming
      .transform(base64.decoder)
      .map(utf8.decode)
      .join();
  print(text); // Manhello
}

The transform call uses the decoder as a stream transformer; the first chunk, three characters, parks its bits in the decoder's state, and the second chunk completes the group. Errors surface as stream errors with the same FormatException details, and an empty stream simply produces no output. If you prefer sinks, base64.decoder.startChunkedConversion gives you a StringConversionSink wired to the same state machine.

Big Data: The Math and the Memory

Decoding shrinks: four characters become three bytes, so the output is always a bit under three quarters of the input length. That means the output size is knowable before you decode, which makes memory predictable. A small helper computes it from the string alone:

import 'dart:convert';
int decodedLength(String encoded) {
  var padding = 0;
  for (var i = encoded.length - 1; i >= 0 && padding < 2; i--) {
    if (encoded.codeUnitAt(i) == 0x3d) {
      padding++;
    } else {
      break;
    }
  }
  return (encoded.length ~/ 4) * 3 - padding;
}
void main() {
  print(decodedLength('QQ==')); // 1
  print(decodedLength('QUI=')); // 2
  print(decodedLength('QUJD')); // 3
}

The built-in decoder is fast: a single pass over a lookup table with no per-character string allocations, so multi-megabyte strings are routine. Where base64 does cost you is in the input side: the encoded text is about 33 percent larger than the data, and it is a string, which on the VM lives as UTF-16 code units, roughly double the byte length of the encoded characters. For payloads that can grow large, stream the decode instead of joining one big string.

From the Command Line

Dart's VM makes a clean CLI out of the decoder. This little tool reads a file argument or standard input, flattens whitespace, and writes raw bytes to standard output:

import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
  String encoded;
  if (args.isNotEmpty) {
    encoded = await File(args[0]).readAsString();
  } else {
    encoded = await stdin
        .transform(utf8.decoder)
        .join();
  }
  final flat = encoded.replaceAll(RegExp(r'\s+'), '');
  stdout.add(base64Decode(flat));
  await stdout.flush();
}

Save it as bin/decode.dart and run dart run bin/decode.dart image.b64 > image.png, or pipe it: cat token.b64 | dart run bin/decode.dart. The stdout.add call takes the Uint8List directly, no intermediate string, which is exactly how binary should move through a pipeline.

Pitfalls That Bite Dart Developers

  • The padding wall. JWT-style and URL-tool input often arrives without = signs, and the decoder refuses it with Invalid length, must be multiple of four. Run untrusted input through base64Url.normalize first.
  • The whitespace trap. Text files, email and copy-paste all introduce line breaks, and the decoder never skips them. Flatten with replaceAll(RegExp(r'\s+'), '') before you decode.
  • Alphabet confidence. Because both alphabets decode everywhere, do not build logic on which decoder a string came from. The string is the contract, not the producer's settings.
  • String.fromCharCodes is not a charset. It reads UTF-16 code units, so it turns UTF-8 text into mojibake. Use utf8.decode or an explicit encoding.
  • Two different error types. Decode problems are FormatExceptions; the encoder throws ArgumentError for values outside 0 to 255. Catch them separately if you are building a boundary.
  • The result is fixed-length. Uint8List cannot grow, so bytes.add(1) throws an UnsupportedError. Copy with List<int>.fromList(bytes) when you need a growable list.
  • Do not unescape %3D by hand. The decoder reads percent-escaped padding natively; a premature replaceAll('%3D', '=') couples your code to a detail the SDK already owns.
  • Decoding a JWT payload is not verifying it. Reading the claims and trusting them is a security bug waiting for a determined user.

Best Practices, Short List

  • Default to base64Decode; reach for normalize only at the boundary where input is untrusted.
  • Be explicit about the charset, utf8.decode(bytes), even when you assume UTF-8.
  • Keep bytes as bytes until you know what they are; the Uint8List travels cleanly into File.writeAsBytes and friends.
  • At trust boundaries, catch FormatException and log the input position the message gives you.
  • Stream anything that might exceed a few megabytes.
  • Treat base64 as a format, not a protection: it hides nothing from anyone who knows it is base64.

A Short History of Base64 in Dart

The decoder you just met has been around longer than Dart 3, null safety, and the Flutter era. The short version:

  • November 18, 2015, Dart 1.13: Base64 arrives in dart:convert as the BASE64 constant plus the Base64Codec, Base64Encoder and Base64Decoder classes. Before this release, the SDK had no base64 at all.
  • January 28, 2016, Dart 1.14: Base64Decoder.convert gains start and end range parameters, and the same release adds data URI support to dart:core, the Uri.parse path this article leans on.
  • April 26, 2016, Dart 1.16: the URL-safe alphabet joins as BASE64URL and the Base64Codec.urlSafe constructor.
  • August 7, 2018, Dart 2.0: constants are renamed to lowercase base64 and base64Url, the top-level base64Decode and friends arrive, and decoding returns a Uint8List instead of a growable List<int>.
  • 2021, Dart 2.12: Base64Codec.normalize lands, turning validation and repair into a one-call step.
  • Today, Dart 3.13: the classes are marked final, and the behavior you met above is the same strict, both-alphabets, percent-aware machine that has run since 2015.

The strictness is not an accident of the implementation. It is the decoder following RFC 4648's instruction that implementations should reject non-alphabet characters, with the MIME-style leniency left to the applications that need it, which in Dart means a flatten step before the decode.

Fun Facts

  • The decoder reads %3D as native padding. Hand it the raw payload of a data URI, escape and all, and it decodes. Very few language runtimes will do that without a preprocessing step.
  • base64.decoder and base64Url.decode are literally the same object: both are the canonicalized const Base64Decoder() instance. The "URL-safe decoder" is the standard decoder in a different costume.
  • The whole decoder fits in one 128-entry lookup table, an Int8List shared between the interpreter and AOT-compiled code, with + and - both pointing at alphabet slot 62 and / and _ both pointing at 63.
  • Dart's base64 and its data URI support landed two releases apart, in 1.13 and 1.14, and they were clearly planned as a pair: one to read the format, one to read it straight out of a URL.
  • The empty string decodes to an empty Uint8List with no error, and the empty string encodes to the empty string: base64 treats the absence of data as a perfectly valid message.
  • In 2018, when Dart 2.0 renamed its constants, BASE64 became base64 as part of an SDK-wide move to lowercase constant names, the same wave that gave you ascii, json and utf8.

You now have the full decoder: what it accepts, what it refuses, how to repair damaged input, and how to meet it in JWTs, data URIs, files, streams, email and the shell. The other direction of the trade, taking bytes and producing one of the two alphabets, with the padding decisions and the size math, is covered in detail in the Base64 encoding guide, which is linked at the end of this page.

Last updated: 2026-08-30

Related article: Base64 Encoding in Dart: A Complete Guide