Base64 Encoding in Java: A Complete Guide
Here is the situation: you have bytes. A file, a password, a certificate, a 13-byte greeting, a 200-megabyte upload. And you need them inside something that only understands text: a JSON field, an HTTP header, a database column, a URL, a config file. That is the entire job of Base64, and this guide is the Java handbook for doing it well. Quick orientation, because the home page walks through the format step by step: Base64 rewrites every three bytes of data as four characters from a 64-letter alphabet, with one or two = pads tacked on when the last chunk is short. The price of the trip is size: every three bytes become four characters, so the encoded output lands about 33 percent larger than the input, plus a touch more if line breaks are involved.
The headline news, and it is a good one. Since March 18, 2014, every JDK has shipped a complete Base64 toolkit in the standard library: java.util.Base64. No download, no Maven coordinate, no native library. One import, three encoder personalities, and the same behavior from Java 8 through today's Java 26. Everything in this article is built on that one class, and it never throws on the data itself: the encoder's job cannot fail on invalid input, because every possible byte is encodable.
One honest boundary before we start: this is the encoder's side of the story. You will learn the string-to-bytes decision that actually determines correctness, the padding and wrapping dials, base64url and its no-padding mode for tokens, and the use cases where Java developers meet encoded output most often. Decoding, where most of the real pain lives, gets its own guide and is linked at the end of this one.
One Import, Zero Downloads
Installing Base64 in Java is the one-line answer you give at the whiteboard: "It is in the JDK." The class java.util.Base64 has been part of the java.base module since 1.8, and its javadoc still says Since: 1.8 twelve years on. The only thing you install is a JDK, any Java 8 or newer from any vendor (Oracle, Eclipse Temurin, Amazon Corretto, Zulu) works, and on a Debian-based box that is a single command:
sudo apt install openjdk-17-jdk-headless
The API is a factory: you never construct an encoder, you ask the class for one. The encoder side has four doors, all returning instances of the nested class Base64.Encoder:
| Factory method | Alphabet | Output shape |
|---|---|---|
getEncoder() |
A-Z a-z 0-9 + / |
Padded, no line breaks |
getUrlEncoder() |
A-Z a-z 0-9 - _ |
Padded, no line breaks |
getMimeEncoder() |
A-Z a-z 0-9 + / |
Padded, 76-character lines, CRLF |
getMimeEncoder(int, byte[]) |
A-Z a-z 0-9 + / |
Padded, your line length, your separator |
Three properties are worth knowing up front. The instances are thread-safe, and the factory returns the same shared instance on every call, so Base64.getEncoder() == Base64.getEncoder() is true; build one in a static field and share it everywhere. The encoders never throw on the data: every byte value has an encoding, so there is no "invalid input" state to handle, and the only exceptions you will meet are about misconfiguration (a bad line separator) or a too-small destination array. And every encoder in this list adds padding by default; the dial that turns it off, withoutPadding(), appears in the base64url section, because that is where you will need it.
You will still meet older libraries in codebases, so a quick map of the landscape. Apache Commons Codec (currently 1.22.1) has shipped its own org.apache.commons.codec.binary.Base64 since 1.0, with a Builder API that exposes the strict-or-lenient policy, line length, and separator as dials; it is the right tool only if you must support pre-Java-8 JVMs. Guava ships com.google.common.io.Base64, a similarly capable veteran, still common in big data stacks. For anything on a modern JVM, java.util.Base64 is the default: zero dependencies, and community benchmarks keep finding it the fastest of the bunch (more on that in the security and speed section).
Your First Encode
Ninety percent of encoding life fits in three lines. Here is the whole ceremony, using the smallest example the RFC itself uses to explain the alphabet:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstEncode {
public static void main(String[] args) {
byte[] text = "Man".getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(text);
System.out.println(packed); // TWFu
}
}
The string TWFu is the RFC's own example, so if your encoder turns "Man" into it, the machine is honest. But look at the first line of that example, because it is the line where encoding actually happens in Java. There is no encodeToString(String) method on purpose. A Java String is a sequence of UTF-16 code units, not bytes, and Base64 is a byte format, so the API makes you decide the byte question yourself: "Man".getBytes(StandardCharsets.UTF_8). That one call, with an explicit charset, is where "café" stays correct for the next hundred years, and it is the single most important habit in this whole article. The next section is dedicated to it, because the alternative is the classic mojibake bug.
Two notes on the second line. encodeToString() returns a String built from the encoded bytes; the javadoc explains that it constructs the result using the ISO-8859-1 charset, which is a non-issue in practice because every Base64 output character is plain ASCII and looks identical in Latin-1, UTF-8, and most of the rest of the charset zoo. And if you would rather own the output buffer yourself, encode(byte[]) returns a fresh byte[], and encode(byte[] src, byte[] dst) writes into a destination you supply, returning the count (and throwing IllegalArgumentException: Output byte array is too small for encoding all input bytes if the destination is short, without writing a single byte).
The Charset Decision
Let us make the string-to-bytes step concrete with the classic case. The word "café" is one word, but in bytes it depends entirely on the charset you chose:
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetEncode {
public static void main(String[] args) {
byte[] utf8 = "café".getBytes(StandardCharsets.UTF_8);
byte[] latin1 = "café".getBytes(Charset.forName("ISO-8859-1"));
System.out.println(utf8.length + " vs " + latin1.length);
// 5 vs 4: the accent is two bytes in UTF-8, one in Latin-1
System.out.println(Base64.getEncoder().encodeToString(utf8));
// Y2Fmw6k=
System.out.println(Base64.getEncoder().encodeToString(latin1));
// Y2Fm6g==
}
}
Two different Base64 strings for one word, and both are "correct" as long as the reader is told which charset to use. The whole lesson fits in one line: the encoder is faithful to the bytes you give it, and you are responsible for the bytes. In practice that means: agree on UTF-8 with your counterpart, pass StandardCharsets.UTF_8 explicitly, and write the charset down in the spec, the schema, or the commit message, because nobody on the receiving side can guess it from the Base64 alone. The decoder-side twin of this bug is the topic of the sister guide.
One version note, because it changes the failure mode of lazy code. The no-argument new String(bytes) and the no-charset String.getBytes() use the platform default charset, which historically was Cp1252 on Windows and something locale-dependent on Linux. Since JDK 18 (JEP 400, "UTF-8 by Default") the default is UTF-8 on every platform, so on a modern JVM the lazy form happens to be right. That does not make it safe: your code will outlive the JDK it was written for, and the person inheriting it should not have to know what the default is. Write the charset.
A related design detail: there is no encode(String) overload anywhere in the API, and that is deliberate. Every other step of the pipeline (arrays, buffers, streams) takes bytes, and a String-accepting method would have to pick a charset for you, which is exactly the decision the JDK refuses to make. The one String-typed method that exists, encodeToString, is on the output side, where the charset question does not exist: Base64 output is pure ASCII. The API's whole shape is a small argument for "decide your bytes on purpose".
Padding, Wrapping And The MIME Dial
Java's encoders make two formatting decisions for you by default, and both are worth understanding because both are dials you can turn. The first is padding: every encoder adds the = characters that make the output a multiple of four, as RFC 4648 asks: implementations MUST include appropriate pad characters at the end of encoded data unless the referring specification says otherwise. The second is line wrapping: only the MIME encoder wraps, at 76 characters with a carriage return and line feed, and it does not add a line separator after the final partial line, a detail the javadoc calls out explicitly and other tools get wrong:
| Encoder | Pads the output | Wraps lines | Line separator |
|---|---|---|---|
getEncoder() |
yes | no | n/a |
getUrlEncoder() |
yes | no | n/a |
getMimeEncoder() |
yes | yes, 76 chars | CRLF |
getMimeEncoder(64, "\n") |
yes | yes, 64 chars | LF |
The MIME dial is the most useful part of the API for people who inherit other people's formats. The standard constructor is getMimeEncoder() (76, CRLF, straight from RFC 2045); the two-argument version, getMimeEncoder(int lineLength, byte[] lineSeparator), lets you reproduce other conventions. The two quirks to know: the line length is "rounded down to nearest multiple of 4", so asking for 77 quietly gives you 76, and a rounded value that is not positive gives you no wrapping at all; and the separator must not contain any character of the Base64 alphabet, or the constructor throws an IllegalArgumentException on the spot, because a separator that could be confused with data is a bug waiting to happen. Here is the dial in action, MIME-standard and PEM-flavored:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WrapDials {
public static void main(String[] args) {
byte[] data = "Hello, wrapped world! This line goes on and on and on.".getBytes(StandardCharsets.UTF_8);
Base64.Encoder mime = Base64.getMimeEncoder();
Base64.Encoder pem = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.ISO_8859_1));
System.out.println(mime.encodeToString(data));
// 76-character lines, CRLF between them
System.out.println(pem.encodeToString(data));
// 64-character lines, bare LF between them
}
}
Two practical notes. If your consumer expects a wrapped string to end with a line break (some email tooling does), add it yourself after the encode: the JDK deliberately stops after the last partial line. And if you are producing data that will live in a URL or a token, wrapping is the wrong dial entirely; those consumers want one long line and usually no padding, which is the next section.
base64url And The No-Padding Dial
Standard Base64 ends its alphabet with + and /, and those are exactly the two characters that do not behave in URLs: a + in a query string is already a space before the server ever parses it, a / is a path separator, and a dangling = wants percent-encoding into a three-character monster. RFC 4648 section 5 draws the fix: the URL and Filename safe alphabet, where + becomes -, / becomes _, and the trailing = padding is typically dropped when the length is known implicitly. The RFC is adamant about the name: this encoding "should not be regarded as the same as the base64 encoding", and the name you will hear is base64url. JSON Web Tokens, OAuth state parameters, API session IDs, and eleven-character video IDs all live in this dialect.
Java gives you the alphabet with getUrlEncoder(), but here is the dial that catches people: the URL-safe encoder still pads by default, and the token standards do not want padding. RFC 7515 is explicit that JWS parts use base64url "with all trailing '=' characters omitted ... and without the inclusion of any line breaks, whitespace, or other additional characters". So the canonical Java JWT recipe is a two-method chain:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TokenParts {
public static void main(String[] args) {
Base64.Encoder url = Base64.getUrlEncoder().withoutPadding();
byte[] header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8);
byte[] payload = "{\"sub\":\"1234567890\",\"name\":\"John Doe\"}".getBytes(StandardCharsets.UTF_8);
System.out.println(url.encodeToString(header));
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
System.out.println(url.encodeToString(payload));
// eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0
}
}
The withoutPadding() call returns a new encoder instance that behaves identically except that it omits the trailing pads; the original is untouched, and the javadoc says so precisely. The decoder side accepts both padded and unpadded input, so a value you produce without padding will still be readable by a strict decoder, which is why unpadded is the safe choice for anything that crosses an API boundary. Now, one big disclaimer: the two parts above are the unsigned halves of a JWT. A real token needs a signature computed over "header.payload", and that is cryptography, not encoding. For production, mint and verify tokens with a JOSE library: JJWT (0.13.0) or nimbus-jose-jwt (10.9.1). JJWT's API artifact, for example, is one coordinate away:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<!-- add jjwt-impl and jjwt-jackson at runtime, per the project docs -->
YouTube IDs are the other face of this dial: eleven characters of base64url without padding, an identifier that has to survive being pasted anywhere a URL is allowed. If your system generates identifiers that travel in URLs, the withoutPadding() chain above is the shape to copy.
Encoding Files
The everyday file job is the mirror of the decoder's favorite: read a file, encode it, write the text out. Four lines with java.nio.file:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class EncodeFile {
public static void main(String[] args) throws Exception {
byte[] raw = Files.readAllBytes(Paths.get("report.pdf"));
String packed = Base64.getEncoder().encodeToString(raw);
Files.write(Paths.get("report.pdf.b64"), packed.getBytes(StandardCharsets.ISO_8859_1));
System.out.println(raw.length + " -> " + packed.length());
}
}
That last print is the 33 percent bill, made visible. A 1 MB file becomes roughly 1.34 MB of text (plus at most two pad characters), and if you wrapped it MIME-style, the line breaks add a few percent more: the old mail-era math, still true, is 4/3 times 78/76, or about 1.37 times the original for a wrapped MIME payload. Two consequences. First, size any storage or message field from the encoded length, not the raw length: a VARCHAR(255) column that happily holds a 180-byte raw value will reject its 240-character encoding. Second, the encoding direction is the one that makes memory worse, so for large files the array version is the wrong tool and the streaming section is the right one. A small joy for the file crowd: because the first output characters are a pure function of the first input bytes, every Base64-encoded PNG starts with iVBORw0K and every encoded GIF with R0lGOD; you can recognize the file type before a single byte is decoded.
JSON, APIs And Data URIs
Two of the most common places encoded output lives on the wire.
One: binary inside JSON. File upload endpoints, content APIs, secret stores, and webhooks embed binary as Base64 text inside JSON, because raw bytes would break JSON string escaping. The encoder side is a one-liner at the boundary, and the one decision is which dialect the spec asks for:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class JsonField {
public static void main(String[] args) throws Exception {
byte[] image = Files.readAllBytes(Paths.get("logo.png"));
// Spec says base64url, unpadded:
String field = Base64.getUrlEncoder().withoutPadding().encodeToString(image);
// Hand "field" to your JSON library as a plain string value.
System.out.println(field.length());
}
}
The pitfall is not encoding; it is reading the spec. Some APIs want standard Base64 with padding, some want base64url without, and a few are lenient about both. When the spec is silent, the cheapest fix is to look at an example value from the other side: a - or _ anywhere settles the alphabet, and trailing = settles the padding. Getting the dialect wrong does not usually crash the other side; it usually corrupts the file, which is the slowest kind of bug to find.
Two: data URIs. The data:image/png;base64,... string that inlines an image into HTML or CSS is RFC 2397's data URI: data:, an optional media type, an optional ;base64 flag, a comma, then the data. Building one is string concatenation, and the one decision is whether the flag is there (no flag means the payload is percent-encoded text, which nobody wants for binary):
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DataUriBuild {
public static void main(String[] args) throws Exception {
byte[] icon = Files.readAllBytes(Paths.get("icon.png"));
String b64 = Base64.getEncoder().encodeToString(icon);
String uri = "data:image/png;base64," + b64;
System.out.println(uri.substring(0, Math.min(40, uri.length())) + "...");
// data:image/png;base64,iVBORw0KGgo...
}
}
The RFC's own advice applies with interest: data URIs are for short values. Inlining a 50 KB icon is a normal trade (one fewer request); inlining a 5 MB photo is a performance bug wearing a convenience costume. Keep the flag, keep the media type honest, and keep the bytes small.
Building The Basic Auth Header
The oldest authentication header on the web is still the easiest Base64 use case in Java, because it is exactly one encode call. Per RFC 7617, a Basic request sends Authorization: Basic followed by the Base64 encoding of username:password; the RFC's own example, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, is "Aladdin:open sesame" wearing a disguise. On the client side, building the header is two lines of Base64 plus a modern HTTP call:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuthClient {
public static void main(String[] args) throws Exception {
byte[] credentials = ("alice:secret123").getBytes(StandardCharsets.UTF_8);
String header = "Basic " + Base64.getEncoder().encodeToString(credentials);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/status"))
.header("Authorization", header)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
}
Three cautions belong to this header. First, the RFC is explicit that Basic is encoding, not protection: the credentials are readable by anyone who can see the packets, so this header is only as strong as the HTTPS underneath it, and it is a bad idea on anything but TLS. Second, the charset: the RFC expects US-ASCII credentials (UTF-8 for anything else, and the charset auth-parameter is advisory), so pick StandardCharsets.UTF_8 and stay consistent on both sides. Third, a note on versions: the java.net.http client is from Java 11; on an older JVM the same header goes on a HttpURLConnection with one setRequestProperty call, and the Base64 line is identical either way. On the server side of the same header, parsing and decoding is the sister guide's example, with the first-colon split and the constant-time comparison. The two sides are two calls of the same API, which is the quiet elegance of this one.
Values In Configs, Env Vars And Columns
Base64 is a text container, which is why it shows up in places you would not expect: a database DSN with semicolons in an env file, a password with quotes in a properties file, a multi-line certificate in a config map, a binary blob in a TEXT column because the schema was designed before anyone considered BLOBs. The encoding side is one call, and the honest framing is what it is: a format-safety trick, not a secrecy trick:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigEncode {
public static void main(String[] args) {
String dsn = "pg:host=db;password=qu\"ote";
byte[] raw = dsn.getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(raw);
System.out.println(packed);
// cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
System.out.println("DB_DSN_B64=" + packed);
}
}
Two rules keep this honest. First, never store a secret as Base64 and call it encrypted: Base64 adds no entropy and removes no information, the moment a developer reads the file they can decode the value in one call, and the RFC's security section points at exactly this failure, people revealing credentials by pasting "encoded" protocol exchanges. If the value is secret, encrypt it first, and only then pack the ciphertext into Base64 if the channel demands text. Second, budget the size: the stored value is about a third larger than the original, and a column or field that fit the raw value will not fit the encoded one. And when the value comes back, decode it at the boundary and keep it as bytes (for binary) or an explicit-charset string (for text); that direction is the sister guide's territory.
Streaming For Large Data
Encoding is the direction that makes memory worse, so the big-file story here is about keeping the working set small. The array version of the example in the files section is fine up to the point where the file stops fitting comfortably in memory; beyond that, the stream adapter is the move. wrap(OutputStream) returns an output stream that encodes as you write, so a multi-gigabyte file is never held as a single byte array:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamEncode {
public static void main(String[] args) throws Exception {
OutputStream packed = Base64.getEncoder().wrap(Files.newOutputStream(Paths.get("bigfile.b64")));
InputStream raw = Files.newInputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = raw.read(buf)) != -1) {
packed.write(buf, 0, n);
}
packed.close();
raw.close();
}
}
There is one behavior on this stream that deserves a highlight, because the javadoc itself points at it: the wrapped stream can hold a few leftover bytes internally, and the recommended practice is to "promptly close the returned output stream after use, during which it will flush all possible leftover bytes to the underlying output stream". If you stop writing and read the output file before closing, the tail of your data is still sitting in the encoder, and the file looks truncated. That is why the example closes packed before anything else touches the file, and in production you would put both streams in a try-with-resources block. Get the habit: on the encoding stream, closing is part of encoding.
Meeting The Old Guard
Inherited codebases are full of Base64 APIs that predate java.util.Base64, and recognizing them saves you from "why does this wrap my output" mysteries. The four you will actually meet:
| API | Where you will meet it | What to do |
|---|---|---|
sun.misc.BASE64Encoder / BASE64Decoder |
Pre-Java-8 code, old Android code | Migrate to java.util.Base64; removed in Java 9 |
javax.xml.bind.DatatypeConverter |
XML-era code, old web services | Removed in Java 11 (JEP 320); migrate |
org.apache.commons.codec.binary.Base64 |
Code that must run on pre-8 JVMs | Keep for pre-8 support; otherwise the JDK class is the default |
com.google.common.io.Base64 |
Guava-heavy and big data stacks | Works fine; the JDK class has no dependencies |
The sun.misc pair is the one with the drama. It was an internal, unsupported API (the kind that compiles today and vanishes without a deprecation warning), and its output had its own habits, like line-wrapping the encoded text, which is where a surprising number of "my Base64 has newlines in it" bugs come from. When Java 9 shipped in September 2017, the module-system cleanup removed it, and the official migration guide does not mince words: "Notably, sun.misc.BASE64Encoder and sun.misc.BASE64Decoder have been removed. Instead, use the supported java.util.Base64 class, which was added in JDK 8". If you run jdeps on code that still references the old classes, the tool flags the dependency as "JDK removed internal API", which is as close to a traffic cone as the JDK gets. The DatatypeConverter from JAXB had a longer but similar life, deprecated with the Java EE modules in the Java 9 era and removed outright in Java 11 by JEP 320, "Remove the Java EE and CORBA Modules". Both migrations are mechanical: the old printBase64Binary and BASE64Encoder().encode calls map one-to-one onto getEncoder().encodeToString, modulo the wrapping differences, and once the code is on java.util.Base64 it runs on every JDK from 8 to 26 without further thought.
Security And Speed
The security section is short, because the encoder's job cannot fail on data, but it is not empty. Base64 is not encryption, and the standard says so in so many words: Base encoding "visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality", and the same section notes that this "has been known to cause security incidents". The practical corollaries for the encoder side: do not encode a secret to make it safe (it is now less safe, because it fits in more channels); if the value is secret, encrypt first and encode the ciphertext; and keep in mind the malleability twin, where a receiver can swap one valid spelling for another (different padding, junk in the spare bits) without changing the decoded data. A deterministic encoder helps here: java.util.Base64 produces exactly one output for exactly one input, so if your own system both writes and reads a value, the spelling is stable, and it is the external values at the trust boundary that need canonical-form checking.
On speed, the encoder side has the same story as the decoder side: on a modern JVM the built-in implementation is fast enough that Base64 is almost never the bottleneck, and it is the benchmark's reference point. The same 2025 gRPC-java benchmark mentioned in the sister guide (issue 11857, JMH on JDK 17 and 21) put the JDK encoder at roughly 2.5 to 3.8 times the throughput of Guava's, with the biggest gap on x86. Two practical notes: for hot paths, share one encoder instance (the factory already returns the same shared one) and prefer encode(byte[], byte[]) into a pre-sized array to skip the allocation; for huge data, the streaming section is the memory story, and the cost of wrapping is noise next to the disk. The only real performance tax in Base64 is the size itself, and no implementation, including this one, can negotiate that down.
The Trap Checklist
Every trap collected in one place, all of them Java-specific:
- The missing charset.
text.getBytes()without an explicit charset uses the platform default: right by accident on JDK 18+, wrong on anything older, and wrong in principle everywhere. PassStandardCharsets.UTF_8and write the charset in the spec. - The padded JWT.
getUrlEncoder()pads by default, and tokens want no padding. ThewithoutPadding()call is part of the recipe, not an optional extra; a token with trailing=is a token that some validators will reject and some will mangle. - The wrapped output. The MIME encoder wraps at 76 with CRLF and adds no trailing line break. If the consumer expects a trailing break, add it; if the consumer expects no breaks at all, do not use the MIME encoder.
- The double encode. Encoding a value that is already Base64 produces a perfectly valid, perfectly useless string of squares. The classic cause: a field arrives pre-encoded from an API and your code "helpfully" encodes it again. Check before you encode.
- Plus signs in URLs. Standard Base64 output contains
+, which is a space in a query string before the server ever sees it. If a standard-alphabet value must travel in a URL, percent-encode it, or generate it in the URL-safe alphabet from the start. - The 33 percent bill. A value that fits the raw column will not fit the encoded one. Size storage, message fields, and headers from
4 * ceil(n / 3), and remember that wrapped MIME output is a few percent on top of that. - The un-closed stream. The wrapped output stream holds leftover bytes until close. Reading the file before the close gives you a truncated encoding. Try-with-resources, every time.
- Secrets in plain sight. Base64 is packing tape, not a lock. Encoded credentials in a config file, a log, or an env var are readable credentials. Encrypt first, or not at all.
- The Android wall. On Android,
java.util.Base64only exists from API level 26; below that the framework class isandroid.util.Base64with its own flag constants (FLAG_NO_PADDING,FLAG_URL_SAFE). Hard-coding one without a check breaks on exactly the devices you never tested. - The line length quirk.
getMimeEncoder(77, ...)quietly wraps at 76, because the length is rounded down to a multiple of four, and asking for 3 or less disables wrapping entirely. If your format demands an odd line length, the MIME dial is not the tool.
From sun.misc To The Standard Library
The Java story is a short one with a clear before and after. Before 2014, if you needed Base64 inside the JDK you got the internal pair sun.misc.BASE64Encoder and sun.misc.BASE64Decoder, unsupported since day one, with their own 76-character wrapping habits, or you reached for javax.xml.bind.DatatypeConverter in XML code, or you added Apache Commons Codec or Guava to the build, which is how a lot of enterprise codebases ended up with three Base64 implementations and no idea which was which. On March 18, 2014, Java 8 shipped java.util.Base64: one class, three alphabets, the RFC 4648 and RFC 2045 rules implemented properly, the factory pattern, the padding and wrapping dials, and stream adapters in both directions. It was the Base64 the language should have had from the start, and the javadoc has said Since: 1.8 ever since.
The cleanup came on a two-year schedule. Java 9 (September 21, 2017) removed the sun.misc pair as part of the module-system cleanup, with the migration guide pointing every developer at the JDK 8 class, and Java 11 removed the JAXB module and its DatatypeConverter along with it (JEP 320). Java 18 (March 22, 2022) landed JEP 400, "UTF-8 by Default", which did not touch Base64 at all but changed the failure mode of the lazy getBytes() calls that feed it: the platform default charset became UTF-8 on every OS, so old mojibake patterns simply stopped reproducing on new JVMs. Since 1.8 the public API has not changed a single method. What has moved is the engine underneath: bug fixes and performance work, which is why community benchmarks keep finding the standard library version outpacing the legacy libraries it replaced. Today, on any JDK from 8 through 26, the answer to "how do I Base64 this in Java" is one import and a factory call, and it has been for over a decade.
A Few Nerd Delights
Because a handbook should end on a smile, here are some Java-specific facts that are simply fun:
- The javadoc says
Since: 1.8, and it has been true for twelve years. Not one method added, not one removed, not one behavior changed: one of the longest-frozen API surfaces in the language, and you use it without thinking. encodeToStringbuilds its result String with the ISO-8859-1 charset, per the javadoc. It is a completely unnecessary detail in practice, because Base64 output is pure ASCII and looks the same in Latin-1, UTF-8, and most of the rest of the charset zoo, but the javadoc tells you anyway, which is the JDK being the JDK.- The MIME encoder adds no line separator after the final partial line. Other tools, including some very famous email libraries, end wrapped output with a trailing CRLF. If your diff against a reference implementation is exactly two characters at the end, you have found this quirk.
- Ask
getMimeEncoderfor 77-character lines and it gives you 76: the line length is rounded down to the nearest multiple of four, silently, because a wrap that splits a four-character group would produce garbage. The API refuses to build a broken line rather than asking your permission. Base64.getEncoder() == Base64.getEncoder()is true. The factory methods return the same shared instance on every call, so the "get a new one" API is a costume for a singleton, and the thread-safety promise is just a description of what the JVM is already doing.- On Android, the twin API
android.util.Base64exposes the same decisions as flags:FLAG_NO_PADDING,FLAG_URL_SAFE,FLAG_NO_WRAP. Two APIs, one decision table, which is a quiet testament to how settled the Base64 design is by now. - RFC 4648 section 5, the URL-safe alphabet section, spends a paragraph on "a database persistence framework for Java objects" encoding 128-bit UUIDs for HTTP parameters. The standard that governs this API is, in one small corner, a Java design document.
- Encode the word
base64and you getYmFzZTY0, no padding, because six is a multiple of three. A format describing itself is the technical equivalent of a mirror that speaks in Morse, and this is the mirror's own reflection. - Run
jdeps -jdkinternalson pre-Java-8 code and watch it flagsun.misc.BASE64Encoderas "JDK removed internal API". The tool's example in the official migration guide is a Base64 class, which is the JDK pointing at your imports and saying "we talked about this". - The 1.37 factor. Every wrapped MIME payload costs about 1.37 times its original size (4/3 for the alphabet, 78/76 for the CRLF rhythm), a fraction so stable that old email math still quotes it: the toll the 1990s mail infrastructure charged on every attachment is exactly the bill
getMimeEncoder()charges today.
Heading The Other Way
That is the encoder's side of the story, and it is the calmer of the two: the job never fails on the data, the traps are about your decisions (charset, padding, wrapping, dialect) rather than about other people's surprises, and the whole API fits in one import. The other direction is where Base64 stops being convenient and starts being adversarial, because decoding is where you meet other people's padding choices, their line breaks, their charsets, and their armor, with an IllegalArgumentException standing between you and the truth. Base64 decoding in Java, linked from this page, covers the decoder in the same depth: the three decoder personalities, the exact error messages, the padding rules, base64url and JWTs, MIME and PEM, and the Java-specific pitfalls collected in one place. Read the two as a pair and the whole subject is yours.
Last updated: 2026-08-30
Related article: Base64 Decoding in Java: A Complete Guide