Base64 Decoding in Rust: A Complete Guide
A string lands in your Rust program: letters and digits, the occasional plus and slash, and a suspicious = or two dangling off the end. That is Base64, and this guide is about getting the original bytes back without surprises. The home page of this site walks through the format in full depth, so only the shape of the trade needs restating here: four alphabet characters stand for three input bytes, and a tail of one or two = characters marks where the real data ended. Decoding runs that trade in reverse, and everything below is about doing it deliberately.
The one twist that sets Rust apart from most languages: the standard library has no Base64 at all. There is no base64_decode() hiding in std, and no use std::... that summons one. The ecosystem settled on a single crate simply named base64, and it became load bearing: version 0.23.1 shipped on August 4, 2026, the crate has published 45 versions since its first release in December 2015, and its download counter sits near 1.5 billion. You are almost certainly decoding Base64 through this crate already, directly or pulled in by something like jsonwebtoken, pem or serde_with, which all depend on it.
The One Crate and Its Circle
If Rust itself is not on the machine yet, your operating system ships it: rustc and cargo on Debian and Ubuntu, a package or installer on macOS and Windows, or the official installer that sets up rustup:
# Debian / Ubuntu
sudo apt install rustc cargo
# or the official installer, which sets up rustup and cargo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Then the crate, inside any cargo project. This single line is the entire installation, and it pulls in exactly zero dependencies:
cargo new my-app
cd my-app
cargo add base64
Three optional features shape the build. std is on by default and enables the std::io streaming types, the standard Error implementations and heap allocation. alloc provides the allocating APIs for embedded no_std builds without a full standard library. simd-unsafe is on by default and gates the vectorized engines you will meet later. The minimum supported Rust version is 1.71.0, so anything recent will run it. Around the core crate orbits a small circle of specialists, each for an edge the core deliberately leaves to you:
| Crate | Version (2026) | What it brings | Reach for it when |
|---|---|---|---|
base64ct |
1.8 | Constant-time decoding from the RustCrypto project; the heap APIs sit behind an alloc feature |
The bytes you decode may leak information through timing, such as key material |
data-encoding |
2.11 | Base64 alongside base32, hex and friends, with permissive MIME variants and streaming readers and writers | One component must parse dirty, wrapped or multi-protocol input |
base64-turbo |
0.3 | A newer codec that peaks past 100 GiB/s, with AVX512, AVX2 and NEON kernels plus a safe scalar fallback | Throughput is the whole point and the standard engine leaves cycles on the table |
None of these replaces the daily work. For the vast majority of Rust programs, base64 alone is the correct and complete answer, and the rest of this article uses only that one crate.
Three Lines to Your Bytes
Ninety percent of decoding life fits in three lines. The canonical smoke test uses the famous TWFu string:
use base64::prelude::*;
fn main() {
let packed = "TWFu";
let bytes = BASE64_STANDARD.decode(packed).expect("valid base64");
println!("{}", String::from_utf8(bytes).expect("valid utf-8"));
}
The output is Man, and three things in that tiny ceremony are worth keeping. First, decode() always hands you a Vec<u8>, never a string. That is a feature, not an accident: Base64 can carry a sentence, a JPEG or a certificate, and none of them should be treated differently before you know what you have. Second, the jump from bytes to text is a separate, deliberate step through String::from_utf8(), and that step is where the charset decision lives. Third, the prelude module quietly hands you two things at once: the BASE64_STANDARD engine and the Engine trait whose methods you are calling. If you prefer explicit imports, use base64::engine::general_purpose::STANDARD; together with use base64::Engine; is the same door with the nameplate on.
And because you will eventually decode something you encoded yourself, here is the round trip that proves the two directions agree. Encoding gets its own complete guide over at the sister site; it appears here only to manufacture test data:
use base64::prelude::*;
fn main() {
let packed = BASE64_STANDARD.encode("Hello, world!");
println!("{packed}"); // SGVsbG8sIHdvcmxkIQ==
let back = BASE64_STANDARD.decode(packed).unwrap();
println!("{}", String::from_utf8(back).unwrap()); // Hello, world!
}
Keep TWFu in your back pocket as a smoke test for any decode path you write: if it turns TWFu into Man, the machine is honest.
Four Ways to Say No
This is the section that saves you at 2 a.m., because when a production string explodes you want to know precisely what the crate is complaining about. The good news: it complains loudly and precisely. DecodeError has exactly four variants, and here is how each one sounds on a family of typical offenders, all fed through the strict standard engine:
| Input | What is wrong | Exact error |
|---|---|---|
"SGVs bG8s" |
a space sneaked in | Invalid symbol 32, offset 4. |
"SGVs\nbG8s" |
a line break sneaked in | Invalid symbol 10, offset 4. |
"SG=VsbG8="" |
padding in the middle of the string | Invalid symbol 61, offset 2. |
"SGVsbG8sIHdvcmxkIQ==xx" |
junk trailing the padding | Invalid symbol 61, offset 18. |
"S" |
one symbol cannot form a byte | Invalid input length: 1 |
"SGV" |
three symbols without the padding that must follow | Invalid padding |
"SGVs$bG8="" |
a $ is not in the alphabet |
Invalid symbol 36, offset 4. |
Notice how the Invalid symbol message tells you both the value of the offending byte and its offset, so you can jump straight to the crime scene. The InvalidLength variant is the picky one: since version 0.22.0 it fires specifically when the number of valid symbols is impossible, which means a length that is one more than a multiple of four, while other bad lengths surface as padding errors. Here is the full match, for the days you want to handle each failure differently:
use base64::DecodeError;
use base64::prelude::*;
fn triage(dirty: &str) {
match BASE64_STANDARD.decode(dirty) {
Ok(_) => println!("{dirty:?} sailed through"),
Err(DecodeError::InvalidByte(offset, byte)) =>
println!("{dirty:?}: symbol {byte} at {offset} is not in the alphabet"),
Err(DecodeError::InvalidLength(symbols)) =>
println!("{dirty:?}: {symbols} valid symbols is impossible"),
Err(DecodeError::InvalidLastSymbol { offset, .. }) =>
println!("{dirty:?}: trailing bits at {offset} suggest truncation"),
Err(DecodeError::InvalidPadding) =>
println!("{dirty:?}: padding is wrong or missing"),
}
}
The deliberate strictness traces back to the standard itself. RFC 4648 section 12 warns that characters outside the alphabet can be abused as a covert channel to smuggle out-of-band information, or to poke bugs in sloppy parsers, and it recommends that decoders reject them. The MIME spec is the famous exception, explicitly telling decoders to ignore stray characters, and that is the input shape the email section below shows you how to tame.
Three Stances on Padding
Every Base64 string in the wild makes a silent promise about padding, and version 0.23 lets you choose which promise to enforce through the DecodePaddingMode enum. There are three modes, and the behavioral difference is worth memorizing. Here is the scorecard for Zm8, which is the word fo without its =:
| Mode | "Zm8", unpadded |
"Zm8=", padded |
Use it when |
|---|---|---|---|
RequireCanonical, the default |
Err(Invalid padding) |
Ok([102, 111]) |
You produce and consume the data yourself |
Indifferent |
Ok([102, 111]) |
Ok([102, 111]) |
Receiving data from mixed sources |
RequireNone |
Ok([102, 111]) |
Err(Invalid padding) |
Running a no-padding protocol |
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD_PAD_INDIFFERENT};
use base64::engine::DecodePaddingMode;
use base64::prelude::*;
let strict = BASE64_STANDARD; // RequireCanonical is the default
let flexible = STANDARD_PAD_INDIFFERENT;
let bare = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::RequireNone),
);
println!("{:?}", strict.decode("Zm8")); // Err(Invalid padding)
println!("{:?}", flexible.decode("Zm8")); // Ok([102, 111])
println!("{:?}", flexible.decode("Zm8=")); // Ok([102, 111])
println!("{:?}", bare.decode("Zm8=")); // Err(Invalid padding)
The defaults follow the standard: RFC 4648 section 3.2 says implementations must include appropriate pad characters at the end of encoded data unless the surrounding specification says otherwise, which is why a stock STANDARD engine demands them. And the choice matters for security, not just pedantry. Accepting both padded and unpadded spellings of the same data makes Base64 malleable: the same logical payload can be written two ways, and a verifier that compares strings after decoding can be surprised. The 2022 paper "Base64 Malleability in Practice" (Chatzigiannis and Chalkias, ePrint 2022/361) documents real consequences, and the crate's own documentation links to it. The practical rule: pick one mode per protocol, and be strict at every boundary where you do not control the producer.
The Last Symbol's Hidden Bits
Here is a corruption that survives every character check. Each Base64 symbol carries 6 bits, and 3 input bytes (24 bits) become exactly 4 symbols. When the input is only 1 or 2 bytes, the final symbol has unused bits, and the RFC is clear: conforming encoders must set those spare bits to zero. A buggy or malicious encoder can instead leave junk there, and the result still passes the alphabet test, the length test and the padding test, while quietly carrying a corrupted tail. The strict engine has your back, with a uniquely detailed error that even shows you the suspicious bits:
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
println!("{:?}", BASE64_STANDARD.decode("MT=="));
// Err(Invalid last symbol 0x54 ('T') at offset 1, decoded as 0b00010011.)
let lenient = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
println!("{}", String::from_utf8_lossy(&lenient.decode("MT==").unwrap()));
// 1
That 0b00010011 in the error is the decoded value of the offending symbol with the illegal high bits included, and version 0.23.0 made that detail visible precisely because it is so hard to debug otherwise. If you know your producers are sloppy, with_decode_allow_trailing_bits(true) swallows the junk instead of rejecting it. Browsers made the opposite bet: WHATWG's forgiving-base64 algorithm, the one behind JavaScript's atob(), is explicitly lenient about trailing bits, while Rust's default is the forensic examiner. Know which side of the table you sit on.
Base64url: The Alphabet That Travels
Standard Base64 spends its last two alphabet slots on + and /, which is exactly where URLs do not want them: in a query string, plus means space, and slash starts a new path segment, and a dangling = reads like a separator. So RFC 4648 section 5 defines the URL and filename safe alphabet, which swaps the two troublemakers for - and _ and, because the length is usually recoverable, usually drops the padding too. The RFC even warns that this encoding should not be regarded as the same as standard Base64, and the engine names agree:
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}"); // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}"); // [fb, ef, be]
// the standard engine refuses the same input,
// because a dash is not in its alphabet at all
println!("{:?}", base64::prelude::BASE64_STANDARD.decode("----"));
// Err(Invalid symbol 45, offset 0.)
Now the reason most developers meet base64url at all: JSON Web Tokens. A JWT is three base64url parts joined by dots, and peeking inside one is a five line affair:
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let parts: Vec<&str> = jwt.split('.').collect();
let header = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
println!("{header}");
println!("{payload}");
// {"alg":"HS256","typ":"JWT"}
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
Two honest disclaimers. Decoding a JWT is peeking, not trusting: the third part is the signature, and it means nothing until it is checked against a key, which is the job of the jsonwebtoken crate (version 11 in 2026). Version 11 has one sharp edge: it needs exactly one of the rust_crypto or aws_lc_rs features enabled in Cargo.toml or it panics at startup, and its Validation builder treats the exp claim as required by default, so tokens minted for other libraries may need an adjusted validation. And decoding is where alphabet choice bites: feed a URL-safe string to BASE64_STANDARD, or vice versa, and you get a rejection, because -, _ and missing padding are all invalid to the other engine. Match the engine to the protocol, every time.
Bytes Are Not Words
Every decoder in this article stops at the bytes on purpose, and in Rust that is easier than in most languages, because there is no hidden charset step that can go wrong. Base64 is a byte format, full stop. The question "what text was that?" is yours to answer, and the default answer for the modern web is UTF-8, which you gate in one line of standard library:
use base64::prelude::*;
let packed = "Y2Fmw6k="; // the word cafe with an accent, packed
let bytes = BASE64_STANDARD.decode(packed).unwrap();
match std::str::from_utf8(&bytes) {
Ok(text) => println!("{text}"),
Err(_) => eprintln!("not utf-8: {bytes:02x?}"),
}
The multibyte happy path covers everything you will meet on the wire:
| Original text | Base64 | Decodes back |
|---|---|---|
café |
Y2Fmw6k= |
yes |
日本語 |
5pel5pys6Kqe |
yes |
naïve résumé |
bmHDr3ZlIHLDqXN1bcOp |
yes |
😀 |
8J+YgA== |
yes |
π ≈ 3.14159 |
z4Ag4omIIDMuMTQxNTk= |
yes |
And when the payload is not text at all, the same code just gets a different ending. Here is the magic number of a PNG file, the four bytes 89 50 4E 47 plus the CRLF pair that follows:
use base64::prelude::*;
let packed = "iVBORw0KGgo=";
let bytes = BASE64_STANDARD.decode(packed).unwrap();
println!("{bytes:02x?}"); // [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
assert!(std::str::from_utf8(&bytes).is_err());
std::fs::write("sprite.png", &bytes).unwrap(); // bytes out, not text out
The rule of thumb is short: assume UTF-8, verify with std::str::from_utf8(), and treat everything that fails as a byte payload for fs::write, a database blob or whatever sink it came from. The one time you reach for a real charset is legacy data that never migrated. The encoding_rs crate (version 0.8) names the old encodings and converts:
use base64::prelude::*;
use encoding_rs::Encoding;
let packed = "Y2Fm6Q=="; // cafe with an accent, packed from Latin-1 bytes
let bytes = BASE64_STANDARD.decode(packed).unwrap();
let (text, _, _) = Encoding::for_label(b"windows-1252").unwrap().decode(&bytes);
println!("{text}"); // cafe with an accent, as UTF-8
There is no "decode as Latin-1" mode to misconfigure inside the base64 crate, because it never guesses for you. That is the discipline you keep: the crate gives you bytes, and you decide what they mean.
Input That Survived Email
Base64 that has survived a mail system carries line breaks: MIME wraps at 76 characters per line (PEM blocks at 64), and the MIME spec explicitly tells compliant decoders to ignore characters outside the alphabet, line breaks included. Our engine is the opposite of compliant-MIME: it rejects the very first line break, and the streaming reader reports the refusal as an I/O error wrapping the same precise DecodeError:
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
let wrapped_in = "SGVs\nbG8s";
let mut reader = DecoderReader::new(wrapped_in.as_bytes(), &BASE64_STANDARD);
let mut out = Vec::new();
println!("{:?}", reader.read_to_end(&mut out).map(|_| out));
// Err(Custom { kind: InvalidData, error: Invalid symbol 10, offset 4. })
Both positions trace back to the same RFC, which leaves the choice to the surrounding specification, and the base64 crate chose to be the strict one. It is not the first time it changed its mind: version 0.5.0 shipped built-in MIME line wrapping and whitespace handling, and version 0.10.0 removed both, on the grounds that wrapping was too opinionated for a general library and complicated the no_std story. So the recipe for real-world wrapped input is the same one the crate's own documentation suggests: strip the non-alphabet characters first, then decode. For an in-memory string it is one filter:
use base64::prelude::*;
fn strip_non_b64(input: &[u8]) -> Vec<u8> {
input.iter().copied().filter(|b| !b" \n\r\t\x0b\x0c".contains(b)).collect()
}
fn main() {
let wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
let clean = strip_non_b64(wrapped.as_bytes());
let bytes = BASE64_STANDARD.decode(clean).unwrap();
println!("{}", String::from_utf8_lossy(&bytes)); // Hello, standard
}
If you would rather have a decoder that looks straight through the breaks, the data-encoding crate's BASE64_MIME_PERMISSIVE constant is exactly that: it decodes "SGVsbG8s\r\nd29ybGQh\r\n" into Hello,world! without you touching a line. For streams where you cannot hold the whole input, the crate's FAQ points at the iter_read crate for filtering the byte stream, or at writing a tiny Read wrapper that drops the unwanted bytes as they arrive. One warning before you build a "lenient decoder" by hand: silently ignoring non-alphabet characters is the exact behavior RFC 4648 section 12 flags as a covert channel, so strip only the whitespace you expect, and reject everything else.
When the whole message is on hand, a mail parser does the base64 for you. The mail-parser crate (version 0.11) decodes every Content-Transfer-Encoding: base64 part while it parses, so attachments come back as raw bytes, already unwrapped and decoded:
use mail_parser::MessageParser;
let email = br#"From: art@vandelay.com
To: jane@example.com
Subject: gift
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="festivus"
--festivus
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: base64
SGVsbG8gZnJvbSBlbWFpbA==
--festivus
Content-Type: image/gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment; filename="tiny.gif"
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--festivus--
"#;
let message = MessageParser::default().parse(email).unwrap();
for part in message.attachments() {
let name = part
.headers()
.iter()
.find(|h| h.name().eq_ignore_ascii_case("content-disposition"))
.and_then(|h| h.value().clone().unwrap_content_type()
.attribute("filename").map(|n| n.to_string()));
let bytes = part.contents().to_vec();
println!("{name:?}: {} bytes", bytes.len());
}
// Some("tiny.gif"): 42 bytes
That GIF attachment decodes to 42 bytes starting with the four bytes 47 49 46 38, the ASCII letters GIF8. You never wrote a line of Base64 code, and that is the point of using a parser: the encoding details are the library's problem.
The Big Payload
Strings are easy; files are where Base64 earns its keep, and the crate answers with the same streaming philosophy as the rest of Rust's io. The read::DecoderReader wraps any reader and transparently hands out decoded bytes as you read, so a multi-gigabyte encoded file never has to fit in memory. For the example below, save the string dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw== to a plain text file named fox.b64:
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn main() {
let packed = std::fs::read("fox.b64").unwrap();
let mut decoder = DecoderReader::new(&packed[..], &BASE64_STANDARD);
let mut plain = Vec::new();
decoder.read_to_end(&mut plain).unwrap();
println!("{}", String::from_utf8_lossy(&plain));
// the quick brown fox jumps over the lazy dog
}
The same idea shrinks to one line with io::copy: build a DecoderReader around a file and copy it into any writer, and the decoding happens en route. There is also a lovely trick from the official documentation for validating a payload in constant space, using a statically sized buffer and no allocation of the decoded data at all:
use std::io::Cursor;
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn is_valid_base64(input: &str) -> bool {
let mut cursor = Cursor::new(input.as_bytes());
let mut decoder = DecoderReader::new(&mut cursor, &BASE64_STANDARD);
let mut buf = [0u8; 128];
loop {
match decoder.read(&mut buf) {
Ok(0) => return true, // read to the end without error
Ok(_) => continue,
Err(_) => return false, // something was not base64
}
}
}
fn main() {
println!("{}", is_valid_base64("SGVsbG8sIHdvcmxkIQ==")); // true
println!("{}", is_valid_base64("dt==")); // false
}
For payloads that are big but still fit in a buffer you manage, the slice API is the zero-surprise option: base64::decoded_len_estimate(len) gives a conservative maximum decoded size for len symbols, and decode_slice() writes straight into your pre-allocated buffer, returning exactly how many bytes it wrote:
use base64::prelude::*;
let packed = "SGVsbG8sIHdvcmxkIQ==";
let cap = base64::decoded_len_estimate(packed.len()); // 15, conservative max
let mut buf = vec![0u8; cap];
let written = BASE64_STANDARD.decode_slice(packed, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap()); // Hello, world!
If your buffer is too small you get a clean DecodeSliceError::OutputSliceTooSmall instead of a panic, and there is a decode_slice_unchecked() variant that panics by design, for the places where "too small" is a programmer error you would rather crash on than shuffle around. Since 0.22.0 the slice check is conservative in your favor: it only fails when the output truly cannot fit, so an exactly-sized buffer works.
Where Decoded Bytes Go
Base64 shows up in Rust projects far more often than "a random string" suggests:
- API responses and webhooks that embed binary or nested JSON as Base64 text inside their payloads, the classic file-upload-as-JSON pattern.
- JWT inspection, where you decode the header and payload to peek at the claims and hand the signature to
jsonwebtokenfor the part that actually means something. - Email-shaped data: MIME attachments and anything that has been through a mail system, which is why the whitespace section exists at all.
- PEM blocks in certificates and keys, the
-----BEGIN CERTIFICATE-----sections that every TLS stack chews on; thepemcrate parses them for you and, fittingly, builds on thebase64crate internally. - Data URIs hiding inside HTML and CSS you are scraping or rendering, the
data:image/png;base64,...kind. - HTTP Basic auth headers, where
Basic TWFuOnBhc3M=is justMan:passwearing a disguise. - Databases and config files, where someone wanted binary inside a text column or an environment variable.
- Cross-language handoffs: a Python service packs a blob, Rust unpacks it, and both sides speak the same alphabet by definition.
For the JSON case there is a shortcut worth knowing: the serde_with crate (version 3) can annotate a struct field so that serde handles the Base64 in both directions, encoding Vec<u8> fields to text on the way out and decoding them back on the way in, with a URL-safe variant a parameter away:
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Config {
#[serde_as(as = "serde_with::base64::Base64")]
blob: Vec<u8>,
}
let cfg = Config { blob: b"stored in a database".to_vec() };
let json = serde_json::to_string(&cfg).unwrap();
// {"blob":"c3RvcmVkIGluIGEgZGF0YWJhc2U="}
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);
Data URIs take a two step string operation followed by an ordinary decode: find the comma, keep what is after it, and check that the metadata ends in the word base64:
use base64::prelude::*;
let uri = "data:image/png;base64,iVBORw0KGgo=";
let comma = uri.find(',').unwrap();
let meta = &uri[..comma];
let payload = &uri[comma + 1..];
let is_b64 = meta.rsplit(';').next().unwrap() == "base64";
let bytes = BASE64_STANDARD.decode(payload).unwrap();
println!("{is_b64}: {} bytes from {meta}", bytes.len());
// true: 8 bytes from image/png;base64
And the one rule that governs all of it, worth repeating because it still catches people in the act: Base64 is packing tape, not a lock. It is not encryption and it is not compression, it is the opposite of compression, and anyone with this article can reverse everything it does. Decode freely, trust selectively.
A Decade of Careful Steps
The crate's own history reads like a slow tightening of the screws. It first appeared on crates.io in December 2015, and version 0.5.0 proudly added MIME support with configurable line endings and wrapping. Then version 0.10.0 in 2018 removed the wrapping and the whitespace handling, the library deciding that a general-purpose crate should decode and leave the poetry to the application layer; the same release added the streaming encoder and the detection of invalid trailing symbols. Version 0.20.0 in 2022 introduced the engine abstraction and flipped the padding default so the stock engine requires canonical padding; 0.21.0 deprecated the old free functions like base64::decode() in favor of engine methods, with the compiler note "Use Engine::decode" (they still work, which is why a lot of legacy code compiles happily). In 2024, version 0.22.0 sharpened the error semantics, refined what InvalidLength means, and sped decoding up by 5 to 10 percent. And in July 2026, version 0.23.0 arrived with the SIMD engines, custom padding symbols, the clearer InvalidLastSymbol message and the MSRV bump to 1.71, with the 0.23.1 patch on August 4 fixing the test suite for non-SIMD architectures. A decade of small, careful steps, and the crate that started as "It's base64. What more could anyone want?" now ships vectorized kernels.
The format is older than the web, which is why the strictness feels personal. In 1987, the Privacy Enhanced Mail protocol (RFC 989) needed to carry binary data over 7 bit mail channels, and it standardized this encoding with 64 character lines; every -----BEGIN CERTIFICATE----- block your TLS stack has ever trusted is a direct descendant of that decision. In 1996 the MIME spec (RFC 2045) adopted the scheme, named it "base64" after its 64 character alphabet, and set the 76 character line length that still wraps your email attachments today. In 2006, RFC 4648 became the standard everyone quotes: the alphabet tables, the base64url variant in section 5, and the strictness rules this crate implements with such visible relish.
Fun Facts
Because a complete guide should end on a smile:
- The word "base64" encodes to
YmFzZTY0. A format describing itself is the technical equivalent of a mirror that speaks in Morse. - The empty string decodes to zero bytes, but
"AA=="decodes to one byte: a NUL. In Base64, "nothing" and "a zero" are different creatures. - Every Base64-encoded PNG you have ever seen starts with
iVBORw0K. That is the PNG magic number in disguise, and it is one of the most recognizable prefixes on the internet. - YouTube video IDs are base64url without padding: eleven bytes of ID become a short string you can paste anywhere in a URL. One of the most visible uses of the unpadded mode on the entire internet.
- Bash has been counting in base 64 for years: the
$((64#...))arithmetic literal takes its digits in the order0-9,a-z,A-Z, and finally@and_for values 62 and 63, so your shell carries a 64 character alphabet in plain sight. - The old
crypt(3)password hashes used a Base64 variant whose alphabet starts with./, and it has a lovely property: sorting the encoded strings gives the same order as sorting the original bytes. Genealogy files (GEDCOM) use the same alphabet to this day, and thebase64crate ships it asalphabet::CRYPT. - MIME math, as the old rule of thumb still calculates it: a wrapped email payload costs about 1.37 times its original size, plus roughly 814 bytes of headers. The 1990s mail infrastructure really did charge that toll on every attachment.
- The whole crate is
#![forbid(unsafe_code)], except for the opt-insimd-unsafefeature. One word, "unsafe", and it is the name of a feature flag. - Base64 is malleable in a way that spooks security researchers: the same bytes can be written with or without padding, and with junk in the trailing bits, and lenient decoders will not notice. A 2022 paper demonstrated real-world consequences, which is why the strict default in this crate feels like a bodyguard.
- Base64 is not encryption. If it were, you could not read the output of any example in this article. It is a window seat, not a vault.
Wrap Up
Pick your engine by the company you keep: BASE64_STANDARD for everything you produce and control, STANDARD_PAD_INDIFFERENT for the mixed-source boundary, URL_SAFE_NO_PAD for tokens and URLs, and a hand-built GeneralPurpose when the protocol demands its own rules. Let the four DecodeError variants do their precise complaining, gate your bytes through std::str::from_utf8() before calling them text, stream the big things with DecoderReader, strip only the whitespace you expect, and reach for base64ct when timing is a threat. Decode everything, trust only what verifies. And if one day you need to go the other direction, packing bytes into a string for the road instead of unpacking it, the sister article covers encoding in Rust, from the size math to the streaming finish.
Last updated: 2026-08-30
Related article: Base64 Encoding in Rust: A Complete Guide