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 Java: A Complete Guide

It arrives in a support ticket, an API response, a Kubernetes secret, or buried in the middle of a URL: a long run of letters and digits with the occasional +, /, -, or _, and maybe one or two = signs dangling at the end. Someone says it is Base64 and that it contains something you need: a password, a JSON payload, a certificate, a photo. This guide is the Java recipe for getting it back. Quick orientation, because the home page walks through the format in depth: Base64 rewrites every three bytes of data as four characters drawn from a 64-letter alphabet, and tacks one or two = pads onto the end when the last chunk is short. Decoding is the shrinking direction of that trade: four characters go in, three bytes come out, so the result always needs about a quarter less space than the input.

Here is the headline, 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, seven factory methods, three alphabets, and the same behavior from Java 8 through today's Java 26. Everything in this article is built on that one class.

One honest boundary before we start: this is the decoder side of the story. You will learn to pick the right decoder for the alphabet you are meeting, read the JDK's error messages like a doctor reads a scan, turn bytes into text without mojibake, unwrap PEM armor, stream multi-gigabyte payloads, and spot the security traps the format quietly leaves in the road. The other direction, packing bytes into a string, gets its own guide, and it is linked at the end of this one.

What You Already Own

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 in 2026. 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 a decoder, you ask the class for one. The seven factory methods hand out three personalities in each direction, and the decoder side looks like this:

Factory method Alphabet Mood Reach for it when
getDecoder() A-Z a-z 0-9 + / Strict: rejects any outside character Data you produce or control
getUrlDecoder() A-Z a-z 0-9 - _ Strict, URL-safe alphabet JWTs, tokens, IDs, anything born in a URL
getMimeDecoder() A-Z a-z 0-9 + / Lenient: skips every non-alphabet character Email, genuinely wrapped input, PEM bodies
getEncoder(), getUrlEncoder(), getMimeEncoder() as above Encoding, the sister guide's territory Whenever you produce Base64 instead of reading it

Three properties of the returned instances are worth memorizing. First, they are thread-safe: the javadoc says instances are "safe for use by multiple concurrent threads", and the source code shows the factory methods returning the same shared instance on every call, so Base64.getDecoder() == Base64.getDecoder() is true. Build one decoder in a static field and share it across your whole service; you are not even copying anything. Second, they are stateless between calls, so there is nothing to reset and nothing to synchronize around. Third, passing null where a byte array or string is expected is not a gentle no-op: it is a NullPointerException, exactly as the class javadoc promises.

You will still meet older libraries in codebases, so a quick map of the landscape. Apache Commons Codec (currently 1.22.1) has carried 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 or you want its shape-checking helpers. Guava ships com.google.common.io.Base64, a similarly capable veteran, still popular in big data stacks. For anything running on a modern JVM, java.util.Base64 is the default choice: zero dependencies, and community benchmarks keep finding it the fastest of the bunch (more on that in the performance section).

Decoding Your First String

Ninety percent of decoding life fits in a handful of 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 FirstDecode {
  public static void main(String[] args) {
    byte[] bytes = Base64.getDecoder().decode("TWFu");
    String text = new String(bytes, StandardCharsets.UTF_8);
    System.out.println(text); // Man
  }
}

Four sentences about what just happened. First, the entry point is an instance, not the class: decode() lives on the Base64.Decoder object you got from the factory. Second, and this is the single most important design decision in the whole API, the result is a byte array, never a String. 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, so the JDK stops at the bytes on purpose. Third, the jump from bytes to text is a separate, deliberate step with an explicit charset, and that step is where "café" turns into mojibake if you are careless; the charset section below is dedicated to it. Fourth, the empty string is a first-class value: Base64.getDecoder().decode("") gives you a zero-length array, no exception, no fuss.

For test data in your head, remember that TWFu is the standard's own smoke test: if your decode code turns it into Man, the machine is honest. The round trip in the other direction is two lines of the same API and gets the full treatment in the encoding guide linked at the end.

The Decoder Lineup

Java does not give you one decoder; it gives you three, and the difference between them is a policy decision about which alphabet to accept and how much mess to tolerate. All three are instances of the same nested class Base64.Decoder. The class javadoc spells out the split in one sentence per mood. For the basic and URL-safe decoders: the decoder "rejects data that contains characters outside the base64 alphabet". For the MIME decoder: "all line separators or other characters not found in the base64 alphabet table are ignored in decoding operation". That second sentence is the entire MIME story in one line, and it has teeth, because "ignored" means everything that is not an alphabet character, not just line breaks.

The choice rule is short. Default to getDecoder(). If the value came from a URL, a token, or an API that promised "URL-safe", switch to getUrlDecoder(). Only when you genuinely expect MIME-shaped input (line breaks every 76 characters, straight from a mail system) do you reach for getMimeDecoder(). When in doubt, pick strict: a strict decoder's job is to make surprises fail, and that is exactly what you want at a trust boundary. A lenient decoder, on the other hand, is a magnifying glass for corruption: a string with stray characters in it will decode to something plausible and wrong, with no error at all.

Reading the Decoder's Complaints

The strict decoders fail loudly, and they fail precisely. Every bad input throws an IllegalArgumentException whose message tells you exactly what went wrong, so the first time a production string explodes, this table is what you read. The messages below are the exact wording of the current JDK:

Input (to getDecoder unless noted) What is wrong Exact message
"SGVs bG8s" a space sneaked in Illegal base64 character 20
"SGVs\nbG8s" a line break sneaked in Illegal base64 character a
"SGVs$bG8s" a dollar sign is not in the alphabet Illegal base64 character 24
"SGVsbG8-" a URL-safe dash in the standard decoder Illegal base64 character 2d
"aCts" to getUrlDecoder() a plus sign in the URL-safe decoder Illegal base64 character 2b
"S" one symbol cannot form a byte Input byte[] should at least have 2 bytes for base64 bytes
"SG=VsbG8s" padding in the middle of the data Input byte array has wrong 4-byte ending unit
"Zm8==" two pads where one belongs Input byte array has wrong 4-byte ending unit
"Z=" one character followed by a pad Last unit does not have enough valid bits
"SGVsbG8sIHdvcmxkIQ==xx" junk after the pads Input byte array has incorrect ending byte at 20

That hex number in the message is the byte value of the offending character, printed with Integer.toString(byte, 16): 20 is a space, a is a line feed, d is a carriage return, 24 is a dollar sign, 2d is the URL-safe dash, 2b is the plus, 2f the slash, 5f the underscore. Two quirks to keep in your back pocket. First, the message can go negative: feed the decoder a string containing é and it complains Illegal base64 character -17, because the character is first mapped to the Latin-1 byte 0xE9, which as a signed Java byte is minus 23, and minus 23 in hex is minus 17. Your error logger, briefly, is doing signed arithmetic. Second, the position: in the incorrect ending byte at N family, N is the zero-based index of the first byte the decoder could not make sense of, which is a gift when you are bisecting a corrupted payload.

One costume change to know: when decoding happens through the wrapped stream (the wrap(InputStream) variant, covered below), the same problems surface as an IOException with a 0x prefix instead: Illegal base64 character 0x20. Same problem, different exception, slightly different spelling. And the lenient MIME decoder, of course, complains about none of this: it just skips. That is the price of the lenient mood.

The Padding Rules

Every Base64 string in the wild makes a silent promise about padding, and Java's promise is unusually friendly. The decoder javadoc says it exactly: the padding character = "is accepted and interpreted as the end of the encoded byte data, but is not required". A final unit of two or three characters decodes as if it had been padded, and when pads are present they must be present in the exact right amount. The current JDK's behavior around the classic examples:

Input Result
"" empty byte array, no error
"Zm8" "fo", padding simply absent
"Zm8=" "fo", the canonical spelling
"Zm8==" IllegalArgumentException: wrong 4-byte ending unit
"Zm9v=" IllegalArgumentException: wrong 4-byte ending unit
"Zm=" "f", one byte
"Z=" IllegalArgumentException: last unit does not have enough valid bits
"AA==" exactly one byte, the NUL byte 0x00
"AAAA" three NUL bytes

Read that table twice. The empty string decodes to nothing, while AA== decodes to a single NUL byte: in Base64, "nothing" and "a zero" are different creatures, and both are perfectly valid input. And padding, when present, must be exact: Zm8= is right, Zm8== is wrong, Zm9v= is wrong, and a pad in the middle of the string is wrong. Practical consequence for your own protocols: pick one spelling (padded or not) and enforce it on both ends, because a value that can arrive in two spellings is a value that can break a naive equality check somewhere down the line.

base64url: The Alphabet Built For URLs

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 Java ever sees 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". You will meet it as base64url, and it is where JSON Web Tokens, OAuth state parameters, API session IDs, and eleven-character video IDs all live.

The most famous base64url payload on the web is the JWT, and peeking inside one is three lines of work. Token parts are conventionally unpadded, and the URL decoder is happy with that, because padding is accepted but not required, remember:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtPeek {
  public static void main(String[] args) {
    String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
      + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
      + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
    String[] parts = token.split("\\.");
    byte[] header = Base64.getUrlDecoder().decode(parts[0]);
    byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
    System.out.println(new String(header, StandardCharsets.UTF_8));
    // {"alg":"HS256","typ":"JWT"}
    System.out.println(new String(payload, StandardCharsets.UTF_8));
    // {"sub":"1234567890","name":"John Doe","iat":1516239022}
  }
}

Two honest disclaimers live here. First, decoding a JWT is peeking, not trusting: the third part is a signature, and the two parts you just read are not secret and not authenticated. Trusting a payload before verifying its signature is the classic JWT bug, and the fix is to hand verification to a JOSE library such as JJWT (0.13.0) or nimbus-jose-jwt (10.9.1) rather than rolling your own crypto. Second, the direction of danger is one-way: hand a standard-alphabet string to getUrlDecoder() and you get Illegal base64 character 2b or 2f, and the reverse earns you 2d or 5f. Alphabet mismatch is the single most common Base64 decode failure in the wild, and the error message points at it within a heartbeat. If a token in a query string was supposed to be standard Base64, its + and / were probably mangled by the transport before they ever reached you, and the decode error is telling you about a bug upstream, not in your decoder.

From Bytes To Words

Every decode call in this article stops at the bytes on purpose, because Base64 is a byte format, full stop. The question "what text was that?" is yours to answer, and the modern default answer is UTF-8. There is one charset detail on the decode side of the API that surprises people, though, so here it is. The decode(String) overload does not interpret your string as UTF-8. The javadoc says it exactly: an invocation "has exactly the same effect as invoking decode(src.getBytes(StandardCharsets.ISO_8859_1))". That is not a bug, it is a trick: the Base64 alphabet is pure ASCII, so mapping the string through Latin-1 hands the decoder the exact same bytes with zero conversion cost, and any non-ASCII character in the input simply becomes an invalid symbol that the strict decoder rejects (that is where the negative hex numbers in the error messages come from).

The charset of the payload is a completely separate decision, the one at the new String(bytes, charset) step. Here is the classic case: "café" in UTF-8 is the five bytes 63 61 66 C3 A9, which encode to Y2Fmw6k=:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetDecode {
  public static void main(String[] args) {
    byte[] packed = Base64.getDecoder().decode("Y2Fmw6k=");
    System.out.println(new String(packed, StandardCharsets.UTF_8));
    // café, the accent survives
    System.out.println(new String(packed, StandardCharsets.ISO_8859_1));
    // caf followed by mojibake, the UTF-8 bytes misread as Latin-1
  }
}

That second line is the failure mode to recognize instantly: a UTF-8 payload read through Latin-1, producing a string that is exactly one character too long and one byte off. The cure is always to agree on a charset with the producer and pass it explicitly. And pass it explicitly in the code, not just in your head: the no-argument new String(bytes) constructor uses the platform default charset, which on a Windows server can be Cp1252 and on an older Linux can be whatever the machine feels like. Since JDK 18 (JEP 400, "UTF-8 by Default") the default is UTF-8 on every platform, so on a modern JVM the no-argument form happens to be right, but your code should still say so, because the next person reading it should not have to know what the default is. And when the payload is not text at all, the same code just gets a different ending: bytes in, bytes out, until the very last step.

When the Payload Is a File

The most common file job is the inverse of some export routine: a .b64 text file arrives, and you need the original file back. With strict decoding, this is already production-shaped:

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DecodeFile {
  public static void main(String[] args) throws Exception {
    byte[] packed = Files.readAllBytes(Paths.get("payload.bin.b64"));
    byte[] raw = Base64.getDecoder().decode(packed);
    Files.write(Paths.get("payload.bin"), raw);
  }
}

Nothing in this path cares whether the payload is a text file, a ZIP archive, or a video: byte[] is just bytes. The size math works in your favor, too: the decoded output is three quarters the length of the encoded input, so decoding never makes memory worse, and a multi-hundred-megabyte encoded file is the smaller of the two. A good habit is to let the bytes announce themselves before you trust any label. The first eight bytes of a PNG are always the magic number 89 50 4E 47 0D 0A 1A 0A, which means every Base64-encoded PNG you will ever meet starts with the same prefix, iVBORw0K: if a payload "claims" to be an image and does not start that way, something is already wrong.

If you already own the destination buffer, the two-array overload writes straight into it and returns exactly how many bytes landed, with no intermediate allocation:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class DecodeInto {
  public static void main(String[] args) {
    byte[] src = "SGVsbG8sIHdvcmxkIQ==".getBytes(StandardCharsets.ISO_8859_1);
    byte[] dst = new byte[16];
    int written = Base64.getDecoder().decode(src, dst);
    System.out.println(written); // 13
    System.out.println(new String(dst, 0, written, StandardCharsets.UTF_8));
    // Hello, world!
  }
}

One sharp corner on that overload, documented in the javadoc: if the destination is too small, no bytes at all are written and you get IllegalArgumentException: Output byte array is too small for decoding all input bytes. Size the buffer from the simple math, roughly 3 * n / 4 minus the padding, and the exception never shows its face. There is also a ByteBuffer overload that returns a fresh buffer with its limit set to the decoded length, handy when your pipeline lives in NIO.

From the Wire: Headers, JSON and Data URIs

Base64 meets Java most often at the network edge. Three shapes deserve a worked example each.

Shape one: the HTTP Basic auth header. The oldest authentication header on the web still rides on Base64. Per RFC 7617, a Basic request sends Authorization: Basic followed by the Base64 encoding of username:password, and the RFC is explicit that this is encoding, not protection: anyone with a packet capture can read both halves in one keystroke. The RFC's own example, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, decodes to Aladdin:open sesame. Parsing the header on the server side is a few lines of work:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuth {
  public static String[] credentials(String header) {
    if (header == null || !header.startsWith("Basic ")) {
      return null;
    }
    byte[] packed = header.substring(6).getBytes(StandardCharsets.ISO_8859_1);
    byte[] raw = Base64.getDecoder().decode(packed);
    String userPass = new String(raw, StandardCharsets.UTF_8);
    int colon = userPass.indexOf(':');
    if (colon < 0) {
      return null;
    }
    return new String[] {userPass.substring(0, colon), userPass.substring(colon + 1)};
  }
}

Two details keep this safe. The split on the first colon matters, because a password may legally contain colons of its own. And the comparison of the decoded password against your stored value should be constant-time: hash both values with SHA-256 and compare the digests with MessageDigest.isEqual, never a plain equals that an attacker can time into a user list. Serve this only over HTTPS; on a plain connection the Base64 layer is window dressing.

Shape two: binary inside JSON. A large share of modern APIs embed binary as Base64 text inside JSON: file upload endpoints, content APIs, secret stores, and webhooks all do it, because raw bytes would otherwise break the JSON string's escaping rules. The pattern is always the same: the field arrives as a plain string, and you decode it at the boundary, not inside your domain objects:

import java.util.Base64;
public class ApiField {
  public static void main(String[] args) {
    // Parsed JSON carried:  "content" : "iVBORw0KGgoAAA..."
    String field = "iVBORw0KGgo=";
    byte[] image = Base64.getUrlDecoder().decode(field);
    // Some APIs speak standard Base64 instead. Read the spec,
    // then pick getDecoder() or getUrlDecoder() accordingly.
    System.out.println(image.length); // 8
  }
}

The pitfall here is not decoding; 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 in the value settles the alphabet, and trailing = settles the padding.

Shape three: the data URI. Someone pastes an image into a form and the front end hands you the full data URI: data:image/png;base64,iVBORw0KGgo.... RFC 2397 defines the shape: data:, an optional media type, an optional ;base64 flag, a comma, and then the data. When the flag is present the payload is Base64; when it is absent the payload is percent-encoded plain text, rarer but legal. If the media type is omitted, the default is text/plain;charset=US-ASCII. Splitting one is straightforward:

import java.util.Base64;
public class DataUri {
  public static void main(String[] args) {
    String uri = "data:image/png;base64,iVBORw0KGgo=";
    int comma = uri.indexOf(',');
    String meta = uri.substring(5, comma);
    String payload = uri.substring(comma + 1);
    boolean isBase64 = meta.endsWith(";base64");
    String mime = isBase64 ? meta.substring(0, meta.length() - 7) : meta;
    byte[] raw = Base64.getDecoder().decode(payload);
    System.out.println(mime + " -> " + raw.length + " bytes");
    // image/png -> 8 bytes
  }
}

Two pitfalls live in this format. The missing ;base64 flag is the first: a legal data URI without the flag carries a percent-encoded payload, and running it through Base64.getDecoder() throws. The second is the claimed media type: it is a hint from the sender, not a fact, so check the magic bytes of what you decoded before you file it under "png". And remember the RFC's own advice that data URIs are for short values; a multi-megabyte image inside a URL is a smell, not a pattern.

Email, MIME and PEM Armor

Base64 was born for mail, and mail-shaped Base64 still arrives in Java programs all the time. The MIME standard (RFC 2045) made Base64 one of the binary transfer encodings and added two house rules: encoded lines must not exceed 76 characters, and decoders must ignore every character outside the alphabet, line breaks included. The strict decoders reject the very first line break; getMimeDecoder() was built for exactly this input:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class MimeDecode {
  public static void main(String[] args) {
    String wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
    byte[] bytes = Base64.getMimeDecoder().decode(wrapped);
    System.out.println(new String(bytes, StandardCharsets.UTF_8));
    // Hello, standard
  }
}

That works, and you should still know the catch, because the catch has teeth. The lenient decoder does not "ignore line breaks"; it ignores everything that is not in its alphabet. If a standard Base64 string gets corrupted with stray characters, the junk vanishes and the rest decodes to something plausible, so reach for getMimeDecoder() only when you actually expect MIME-shaped input.

The sibling of MIME in the wild is PEM armor, the -----BEGIN CERTIFICATE----- business that wraps certificates and keys. Here is the trap: the armor lines are full of ordinary alphabet characters. The letters in "BEGIN CERTIFICATE" are just Base64 letters, so feeding a whole PEM block in, armor included, decodes the armor as if it were data. Strip the armor yourself, then hand the bare body to a decoder:

import java.util.Base64;
public class PemDecode {
  public static void main(String[] args) {
    String pem = "-----BEGIN CERTIFICATE-----\n"
      + "TUlJQm96Q0NBVWlnQXdJQkFnSUpBSXBhVDJUaVFvZU1BMEdDU3FHU0liM0RRRUE9\n"
      + "-----END CERTIFICATE-----\n";
    String body = pem.replaceAll("(?m)^-----.*$", "").replaceAll("\\s", "");
    byte[] der = Base64.getDecoder().decode(body);
    System.out.println(der.length); // the DER body, armor excluded
  }
}

PEM conventionally wraps at 64 characters per line (MIME at 76), and once the whitespace is gone the strict decoder and the MIME decoder agree on the result. Use the strict one: a surprise at least has the decency to throw. For the dirty but standard case, the classic recipe is to strip the known whitespace and decode with the strict instance, and let any leftover junk earn an IllegalArgumentException instead of a corrupted certificate.

Config, Environment and Database Values

Base64 is a text container, which is why it shows up in places you would not expect. In databases, a binary blob (a file, an icon, a serialized structure) can live in a TEXT column as Base64, surviving every tool that assumes text; expect the stored value to be about a third larger than the original and size the column accordingly. In config files and environment variables, Base64 is the trick for smuggling values that would otherwise break the format: a DSN with semicolons, a password with quotes, a multi-line certificate. Decoding at boot is the whole job:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigDecode {
  public static void main(String[] args) {
    String value = System.getenv("DB_DSN_B64");
    if (value == null) {
      return;
    }
    byte[] raw = Base64.getDecoder().decode(value);
    String dsn = new String(raw, StandardCharsets.UTF_8);
    // dsn might be: pg:host=db;password=qu"ote
  }
}

The same caution applies twice here. First, this is format safety, not secrecy: the moment a developer reads the config file, they can decode the value in one call, so never store a secret as Base64 and call it encrypted; the security section below goes into depth. Second, validate at boot: a corrupted or half-pasted env value is an IllegalArgumentException from the strict call, and a two-line check turns a cryptic runtime error into an actionable startup message. One Java-specific note for the database crowd: keep the decoded binary as a byte[] (a byte[] parameter in your JDBC code), and never round-trip binary through a String, because the string constructors are where binary payloads go to die.

Streaming The Big Stuff

For payloads that are big but still fit in a buffer you manage, the array APIs are fine. For payloads that should not fit in memory at all, the stream adapter is the move: wrap(InputStream) returns an input stream that decodes as you read, so a multi-gigabyte encoded file never has to sit in a 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 StreamDecode {
  public static void main(String[] args) throws Exception {
    InputStream packed = Base64.getDecoder().wrap(Files.newInputStream(Paths.get("bigfile.b64")));
    OutputStream raw = Files.newOutputStream(Paths.get("bigfile.bin"));
    byte[] buf = new byte[8192];
    int n;
    while ((n = packed.read(buf)) != -1) {
      raw.write(buf, 0, n);
    }
    raw.close();
    packed.close();
  }
}

Two details worth knowing. The read methods of the wrapped stream throw IOException when they meet bytes that cannot be decoded, so a corrupted file fails with a stream exception instead of an IllegalArgumentException. And closing the wrapped stream closes the underlying stream, so the example closes packed last, after the copy loop, and in production you would put both in a try-with-resources block. (The 8192 buffer is a multiple of four characters, which is a nice property to have, for a reason you are about to see.)

Now a legacy warning, because this is the one real bug in the whole story and it has a bug number. On Java 8, 11, and 12, reading a wrapped decoder with certain buffer sizes appends two stray zero bytes to the end of the decoded data: JDK 8222187, whose classic reproduction pairs a seven-byte read buffer with a plain eight-byte input, and it is fixed in JDK 16. If you must stream on a legacy JDK 8, read into a buffer whose size is a multiple of four and recheck the decoded length, or better, upgrade the JDK, which would fix about a hundred other things anyway.

Packing Tape, Not a Lock

Now the section that separates the careful from the burned. Base64 is not encryption, and the standard itself says so twice over. RFC 4648 section 12: Base encoding "visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality", and it goes on to note that this "has been known to cause security incidents" when someone pastes a protocol exchange into a ticket and accidentally reveals the password. The RFC's advice for implementers deserves a frame too: "a decoder should not break on invalid input including, e.g., embedded NUL characters".

The subtler trap is malleability. Remember that each symbol carries six bits, and that a short final unit leaves spare bits that must be zero in a well-formed encoding. A sloppy or hostile encoder can put junk in those spare bits, and the result still looks completely valid: MQ== and MT== both decode to the single byte for the digit 1. Java takes the forgiving side of this: Base64.getDecoder().decode("MT==") does not verify the non-significant bits and happily hands you the same byte. Why care? Because two different strings that decode to the same data break the "unique spelling" assumption that hash checks, deduplication, and signature comparisons quietly rely on, and an attacker who can tamper with an encoded value in transit can swap one spelling for the other. The 2022 paper "Base64 Malleability in Practice" by Chalkias and Chatzigiannis (ACM ASIA CCS 2022) walks through exactly these inconsistencies across real-world implementations. The RFC's own words on the spare bits: they "may be abused to leak information or used to bypass string equality comparisons or to trigger implementation problems". The practical rule is not "never decode"; it is "know your boundary": for data between your own systems the JDK's generosity is fine, but for data crossing a trust boundary, enforce the canonical form (correct length, zero spare bits, one spelling of padding) before you trust anything you decoded.

Performance Notes

Here is the good news in one sentence: on a modern JVM, the built-in decoder is fast enough that Base64 is almost never your bottleneck, and it is the reference against which the rest of the ecosystem benchmarks itself. A case in point: in 2025 the gRPC-java project publicly benchmarked its Guava-based Base64 handling against java.util.Base64 (issue 11857), and the JDK implementation came out roughly 2.5 to 3.8 times faster on encoding and 1.3 to 2.1 times faster on decoding on JDK 17 and 21, with the biggest gaps on x86. That is a strong hint about where the JDK's implementation effort has gone, and it is the same conclusion you will keep finding in Base64 benchmarks: the standard library version is the fast one now, not the legacy one.

Two practical notes. First, for huge files the memory profile, not the speed, is what you are managing, which is why the streaming section exists: wrap(InputStream) keeps the working set to your read buffer. Second, if you do end up on a hot path that decodes millions of small values, share one decoder instance (the factory already returns the same shared one, as noted up top), skip the decode(String) overload when you already have bytes (it copies the string through Latin-1 first), and let the decode(byte[], byte[]) overload write into a pre-sized destination array to skip the allocation dance.

Pitfalls With A Java Accent

The traps collected in one place, all of them Java-specific:

  • Wrong decoder for the alphabet. A base64url string into getDecoder() (or the reverse) is the classic Illegal base64 character crash, usually with a 2d, 5f, 2b, or 2f in the message. Match the decoder to the protocol, every time.
  • Trailing whitespace from the wild. Values copied from a terminal, an env var, or a config file often arrive with a newline, and the strict decoder turns that into Illegal base64 character a. strip() the input, or use the MIME decoder only when the data is genuinely wrapped.
  • The armor trap. getMimeDecoder() does not understand PEM headers, and the letters in BEGIN and CERTIFICATE decode as data. Strip the armor lines yourself, always.
  • MIME leniency as a shortcut. Decoding with the MIME decoder just to "be safe" silently skips any stray non-alphabet character, so a corrupted payload can come out plausible and wrong. Use it for real MIME input only.
  • Charset left to luck. The no-argument new String(bytes) uses the platform default. It is UTF-8 on JDK 18+, but your code should pass StandardCharsets.UTF_8 explicitly, or enjoy mojibake after the next server migration.
  • Stringifying binary. new String(decodedPng) and back is data destruction: every byte sequence that is not valid in your charset becomes the substitution character, and the round trip is one-way. Bytes in, bytes out, until the very last step.
  • Trust in the spare bits. MT== decodes just like MQ==, so a payload with junk hidden in the non-significant bits passes every check the JDK runs. If the protocol matters, enforce the canonical form.
  • JDK 8, 11, and 12 streams. The wrapped decoder on those versions can append two stray zero bytes for certain buffer sizes (JDK 8222187, fixed in 16). On 16 and later this is not a problem; on the older ones it is.
  • null is not empty. Passing null to decode() is a NullPointerException, not an empty array. If a variable may be null, coalesce it before the call.
  • Android is a different zoo. On Android, java.util.Base64 only exists from API level 26; below that the framework class is android.util.Base64 with its own flag constants. Code that hard-codes one or the other without a check breaks on exactly the devices you never tested.
  • Forgetting it is not security. Base64 hides a password from a glance and from nobody else. If the data is secret, encrypt it first and only then pack it if the channel demands text.

The Long Road To java.util.Base64

The format story is older than Java. In the 1980s the internet's mail infrastructure could only carry 7-bit ASCII, and people who wanted to move binaries invented local dialects: uuencode for UNIX (its alphabet runs through consecutive ASCII codes, so encoding was one addition of 32 with no lookup table) and BinHex for Apple machines (which curated its alphabet to drop visually confusable characters like 7, O, g, and o). In 1987 the Privacy Enhanced Mail protocol (RFC 989) standardized the 64-character scheme with 64-character lines for carrying certificates, and RFC 1421 in 1993 kept the alphabet and the padding rules. In 1996 MIME (RFC 2045) adopted the scheme, named it "base64" after its 64-character alphabet, set the 76-character line length that still wraps your email attachments, and wrote the lenient decoder rule that getMimeDecoder() implements to this day. In 2003 RFC 3548 tried to tidy the whole family and declared that decoders should reject out-of-alphabet characters, and in 2006 RFC 4648 became the standard everyone quotes, with the alphabet tables, the base64url variant in section 5, and the security section that keeps the last of this article's sections honest.

Java's own chapter is a little more dramatic. For years the only Base64 inside the JDK was the internal pair sun.misc.BASE64Encoder and sun.misc.BASE64Decoder, the kind of API that compiles today and vanishes without a deprecation warning, and if you needed Base64 in an XML world there was also javax.xml.bind.DatatypeConverter from JAXB. Everyone else used Apache Commons Codec or Guava. Then March 18, 2014: Java 8 shipped java.util.Base64, implementing RFC 4648 and RFC 2045 in one class with the factory-method pattern you have been using all along. Two and a half years later, Java 9 (September 21, 2017) removed the sun.misc pair for good, and the official migration guide is blunt about it: "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". Run jdeps on old code that still references them and the tool flags the dependency as "JDK removed internal API". Java 11 followed up by removing the JAXB module and its DatatypeConverter along with it (JEP 320). Since 1.8 the public API has not changed a single method, and the javadoc still says so in four letters. What has moved is the engine underneath: bug fixes (the JDK 8222187 stream bug, fixed in JDK 16) and performance work, which is why community benchmarks keep landing on the same conclusion. Twelve years, one API, and it is still the fastest Base64 you will not have to pay for.

Fun Facts, Java Edition

Because a complete guide should end on a smile, here are some Java-specific facts that are simply fun:

  • The Oracle javadoc for decode(byte[] src, byte[] dst) promises that "some bytes may have been written to the output byte array before IllegalargumentException is thrown". Not IllegalArgumentException, IllegalargumentException, with a lowercase a. The typo is in the actual JDK source, and it has been there since 2014. Documentation this committed to a typo is rarer than it should be.
  • Decode a string containing é and the error message is Illegal base64 character -17: a negative hex number, because the character becomes the Latin-1 byte 0xE9, which as a signed Java byte is minus 23, and the JDK prints it in base 16. Your error logger, briefly, is doing signed arithmetic.
  • Base64.getDecoder() == Base64.getDecoder() is true. The source code returns a shared static 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.
  • Feed the URL decoder a string of four underscores, "____", and it returns three bytes of pure 0xFF. The underscore is alphabet value 63, four of them make 24 bits, and 24 bits of ones are the byte triplet FF FF FF. Nothing illegal about it, which is the funniest part.
  • AA== decodes to a single NUL byte while the empty string decodes to nothing. In Base64, "nothing" and "a zero" are different creatures, and both are perfectly valid input.
  • The little string TWFu that decodes to Man is not ours: it is the example the RFC itself uses to explain the alphabet, which means every Base64 tutorial on earth has been paying the same tiny tribute since 2006.
  • Every Base64-encoded PNG you have ever decoded starts with iVBORw0K. That is the PNG magic number in disguise, and it is one of the most recognizable eight-character prefixes on the internet.
  • RFC 4648 is not above dropping a Java reference: its section on the URL-safe alphabet suggests encoding "a relatively large unique id (generally 128-bit UUIDs)" for "a database persistence framework for Java objects". The standard that governs this API is, in one small corner, a Java design document.
  • YouTube video IDs are base64url without padding, the familiar eleven-character string you can paste anywhere in a URL. The format that was designed for email attachments now runs a video platform, and getUrlDecoder() is the part of your JDK that makes it work.
  • Decode the string YmFzZTY0 and you get the word base64 back, no padding needed, because six is a multiple of three. A format describing itself is the technical equivalent of a mirror that speaks in Morse.

The Other Direction

That is the decoder side of the story, and it is where most of the pain lives, because decoding is where you meet other people's data: their padding choices, their line breaks, their charsets, their tokens, their armor. The other direction, turning bytes into a Base64 string with java.util.Base64's encoders, is a calmer animal: it never throws on invalid input (there is no invalid input to encode), it has a size bill to pay instead of an error message to read, and its own set of traps (the charset step, the MIME dials, the padding decision for tokens) gets a guide of its own. Base64 encoding in Java, linked from this page, covers the encoder in the same depth, and the two read comfortably as a pair.

Last updated: 2026-08-30

Related article: Base64 Encoding in Java: A Complete Guide