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

Every so often a string of letters, digits, and the occasional +, /, or = lands in your terminal, and you need the real thing back. A JWT pasted into a ticket, a .b64 file attached to a support email, a Kubernetes secret that reads like alphabet soup, a PNG hiding inside an HTML data: tag. This is the field guide to getting the bytes back out in Bash and the shell, using the tools you almost certainly already have installed.

The format in one breath: Base64 rewrites every three bytes of raw data as four characters drawn from a 64-letter alphabet (A-Z, a-z, 0-9, plus + and /), and adds one or two = signs at the end when the byte count is not a multiple of three. Decoding is the shrinking direction of that trade: four characters go in, three bytes come out. The main page of this site walks through the format step by step, so this article spends its time where it belongs, on the shell side of the job.

Here is the plot twist: there is no single base64 command. The name is shared by a GNU C program, a Rust rewrite, a BusyBox applet, a BSD holdover, and an OpenSSL utility whose flags overlap in a genuinely dangerous way. They all agree on the alphabet, but they do not always agree on what broken input looks like, and that difference is where scripts go to die. So step one is to find out who is answering when you type base64.

Know the Decoder You Are Talking To

One command tells you most of the story:

base64 --version

Depending on the machine, you are dealing with one of these:

What you see What you have Decode flag
base64 (GNU coreutils) 9.x The classic C implementation, still the default on most Linux distributions -d or --decode
base64 (uutils coreutils) 0.8.x The Rust rewrite of coreutils, the default userland on current Ubuntu releases -d (and, unusually, -D works too)
BSD-style usage text, no version flag The BSD base64 on macOS and the BSDs, descended from the old bintrans tool -D (on this family, lowercase -d means debug, not decode)
BusyBox v1.x The all-in-one binary of Alpine Linux and embedded systems -d

Notice what is missing from that table: OpenSSL. openssl base64 is a different animal entirely, and its -d flag means decrypt, not decode. That single flag is responsible for more silently empty output files than any other habit in this article, so we will meet it properly in the fallback section.

If your distribution ships more than one family side by side (current Ubuntu does), a couple more commands show you the full picture:

command -v base64
base64 --version 2>&1 | head -1

Four One-Liners That Cover Most Days

Decode a string from standard input. This is the move you will make a thousand times, and the printf keeps the shell from decorating your payload:

printf '%s' "SGVsbG8sIFdvcmxkIQ==" | base64 -d

Decode a file. Every serious implementation accepts a FILE argument, and it is the cleanest way to keep the data away from the shell's quoting machinery:

base64 -d payload.b64 > payload.bin

Decode from a here-string. The here-string appends a trailing newline, but every decoder treats newlines as ignorable whitespace, so this is completely safe for small blobs:

base64 -d <<< "SGVsbG8sIFdvcmxkIQ=="

Decode a wrapped, multi-line blob with a heredoc. Quoting the delimiter with single quotes keeps the shell from interpreting anything inside:

base64 -d <<'EOF'
SGVs
bG8s
IFdv
cmxk
IQ==
EOF

All four print Hello, World!. On macOS and the BSDs, swap -d for -D in every example above; the rest of the syntax is identical.

Messy Input Is the Norm

The Base64 you meet in the wild is rarely one clean line. It arrives wrapped at 76 characters (MIME convention) or 64 characters (PEM convention), exported from Windows with CRLF line endings, or copied out of a chat window with stray spaces in the middle. The good news: the decoder does not care where the line breaks are, as long as they are actual newlines.

The universal cure for any wrapping style is to wash the line breaks away before decoding:

tr -d '\r\n' < blob.b64 | base64 -d

Carriage returns are the special case. A newline is allowed input, but a \r is not a newline to the strict decoders. A blob that crossed a Windows system will trip GNU hard, which prints a partial result and then fails; the fix is to strip the carriage returns first:

printf 'SGVs\r\nbG8s\r\n' | tr -d '\r' | base64 -d

If you see fragments like Hel followed by an error, you are standing on a CRLF blob. The same wash, tr -d '\r\n', before decoding is the portable habit for any input you did not produce yourself.

For genuinely corrupted input, GNU and uutils offer the -i (--ignore-garbage) flag, which skips non-alphabet characters and decodes what it can:

printf 'SGVs!bG8' | base64 -di

That prints Hello. Before you make -i a default habit, know why the standard warns against it: RFC 4648, section 3.3, says implementations must reject data containing characters outside the alphabet, because ignored characters can be exploited as a covert channel that smuggles data which never appears in the decoded output. Reach for -i when a paste from a document brought along punctuation, not when you are verifying data you trust.

Here is how the three main decoders actually behave at the edges, on 2026 tooling (uutils 0.8.x, GNU coreutils 9.7, BusyBox 1.37):

Input uutils 0.8.x GNU 9.7 BusyBox 1.37
SGVsbG8sIFdvcmxkIQ==, clean Hello, World!, exit 0 Hello, World!, exit 0 Hello, World!, exit 0
SGVsbG8s, eight characters, no padding Hello,, exit 0 Hello,, exit 0 Hello,, exit 0
Zg, two characters, no padding f, exit 0 f, exit 0 error, "truncated input"
SGV, three characters, no padding error, no output He printed, then error error, "truncated input"
CRLF-wrapped lines decodes fine, exit 0 partial output, then error decodes fine, exit 0
SGVs!bG8, stray punctuation error (with -i: Hello) partial output (with -i: Hello) partial output (no -i flag at all)
SGV=, non-canonical spare bits error, no output He printed, then error He, exit 0
TQ==junk, garbage after the padding exit 0, keeps decoding junk exit 0, keeps decoding junk exit 0, keeps decoding junk

Three takeaways from that table. First, there is no universal rule for unpadded tails: BusyBox wants the length to be a multiple of four, while GNU and uutils accept the legal remainders but only when the leftover bits are all zero (which is why Zg passes and SGV does not). Second, GNU and BusyBox write the bytes they already decoded before they fail, so a script that redirects to a file and checks the exit code afterwards will happily keep a half-decoded file. Always check the exit status, and treat any file left by a failed decode as suspect. Third, that last row: every one of the three decoders keeps decoding junk after the == and exits 0, because nothing tells them the stream was supposed to end. If trailing garbage matters to you, validate the shape of the input before you trust the output.

base64url: The Alphabet of Tokens and URLs

RFC 4648 section 5 defines a second dialect: the same 6-bit math, but with - and _ replacing + and /, and the padding dropped, because a URL rarely needs to advertise the exact byte length. The RFC is pointed about it: this encoding "should not be regarded as the same as the base64 encoding". If you have ever looked at a JWT, you have already met the dialect, because its segments are base64url with the padding stripped.

The shell recipe is a two-step swap: translate the URL-safe characters back to their standard cousins, then decode:

printf '%s' "_k-C" | tr '_-' '/+' | base64 -d | xxd -p

That returns fe4f82, three raw bytes that just happened to wear a URL outfit. The swap is positional, so the direction matters: encoding goes tr '+/' '-_', decoding goes tr '_-' '/+'. Mixing them up does not error, it just quietly produces different bytes, which is the worst kind of bug.

Now the trap that catches people who treat base64url like plain Base64. Padding is optional in the dialect, and a segment whose length is three modulo four is exactly the shape the strict decoders scrutinize. The robust move is to restore the missing = characters first, which is a small function:

b64url_decode () {
  local s=$1
  local n
  n=$(printf '%s' "$s" | wc -c | tr -d '[:space:]')
  while [ $(( n % 4 )) -ne 0 ]; do
    s="${s}="
    n=$(( n + 1 ))
  done
  printf '%s' "$s" | tr '_-' '/+' | base64 -d
}
b64url_decode "eyJzdWIiOiJob21lciJ9"

The last line prints {"sub":"homer"}, an unadorned JSON object that a web server signed or sealed a moment before. Segments whose length is one modulo four are malformed to begin with, and no amount of padding rescues them, so the function's rejection of that case is a feature.

There is also a native path in GNU coreutils: basenc, the bigger sibling of base64, understands the dialect directly:

printf '%s' "Zg==" | basenc --base64url -d

That prints f, the one byte hiding in two characters. One warning before you build pipelines on it: the GNU basenc of this era even decodes unpadded base64url input (a bare Zg) without complaint, while the uutils basenc still wants the padding restored first, as the example above shows. The little function above works everywhere, which is why it is the portable choice.

Opening a JWT

A JSON Web Token is three base64url segments joined by dots, per RFC 7515. The first two are plain JSON (the header and the payload claims), so they decode straight to readable text. The third is the signature, a raw binary digest, so leave it alone: decoding it gives you the signature bytes, not a message.

token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiJ9.abc123"
b64url_decode "$(printf '%s' "$token" | cut -d. -f2)"

That prints {"sub":"42"}, the subject claim, with no server involved. Read the claims with a clear head about what decoding is and is not: it reveals, it does not verify. The signature says nothing until the server that holds the key re-computes it, which is a job for openssl dgst (the encoding article shows the full minting and checking dance), not for this one. A common field error is to treat a decoded payload as proof that a token is valid; an attacker can mint unsigned or weakly signed tokens by hand, and a decoder will cheerfully read all of them.

Files, Bytes and the Variable Wall

Decoding hands you raw bytes. They may spell an English sentence, or they may be the middle of a PNG, a shared library, or a zip archive. The file is the only place in a shell script where bytes are completely safe, so the default workflow is decode-to-file and then compare:

base64 -d photo.b64 > photo.png

When the original sits next to you, a byte-for-byte comparison is the only proof that matters:

cmp photo.png photo.png.orig && echo "byte-for-byte identical"

When the original is far away, compare checksums instead:

sha256sum expected.bin
base64 -d blob.b64 | sha256sum

Two matching hashes and your decode is provably exact, which beats any amount of eyeballing an image viewer or hex dump.

Shell variables are a different story, and they are a wall for two reasons. Command substitution $(...) strips every trailing newline from the output, and it cannot hold a NUL byte at all: bash prints a warning and quietly drops them. A three-byte payload of 41 00 42 shows both problems in one demo:

printf 'QQBC' | base64 -d > out.bin
xxd out.bin

The file holds all three bytes (41 00 42). The variable route does not:

v=$(printf 'QQBC' | base64 -d)
printf '%s' "$v" | wc -c

You get 2, with a warning on standard error about the ignored null byte. The lesson is short: if the data might be binary, decode it into a file, inspect it with xxd, and never let it through a variable.

Where Base64 Hides in Real Work

Once decoding is comfortable, you start noticing the format everywhere. Here are the corners of real shell work where it shows up, each with the exact move.

Kubernetes secrets. Every field under .data in a secret is Base64, and the official docs stress that this is encoding, not encryption. Reading one back is a classic one-liner:

kubectl get secret db-creds -o jsonpath='{.data.password}' | base64 -d; echo

Git binary patches. When a diff touches binary files, git diff --binary emits a GIT binary patch block. Do not reach for base64 -d here: the lines in that block are git's own base85-style encoding (they start with z), not Base64, and a plain decoder will choke on them. The right tool is the format's owner:

git diff --binary | grep -a -A2 'GIT binary patch'

Feed the diff to git apply or git am, and let them do the unpacking.

Data URIs. An image embedded in HTML or CSS looks like data:image/png;base64,iVBOR.... Strip everything up to and including the comma, wash the line breaks, decode, and you hold the file:

cut -d, -f2- icon.uri | tr -d '\r\n' | base64 -d > icon.png

PEM armor. Certificates and private keys wrap their Base64 in framing lines that are not Base64 at all. Select the armoured block, drop the two framing lines, and decode to the raw DER binary:

awk '/BEGIN CERTIFICATE/{f=1;next} /END CERTIFICATE/{f=0} f' cert.pem | tr -d '\r\n' | base64 -d > cert.der

Swap CERTIFICATE for PRIVATE KEY or whatever label your file carries; the shape is the same.

MIME email. Any part of an email with Content-Transfer-Encoding: base64 is wrapped at 76 characters with CRLF line endings, because that is what RFC 2045 prescribes. The portable combo is the wash plus the decode:

tr -d '\r\n' < attachment.b64 | base64 -d > attachment.bin

Clipboard pastes. Text copied from a browser, a chat window, or a document arrives with stray spaces and a trailing newline. Spaces are not alphabet characters, so the strict decoders reject the paste; the standard rescue is to drop them first:

tr -d ' \r\n' < pasted.b64 | base64 -d

Bytes First: Charsets and Unicode

The most repeated mistake in Base64 work is thinking in characters when the format only knows bytes. base64 -d hands you raw bytes, and whether they become readable text is a decision made by whatever reads them next. That decision is a charset, and it happens after the decode, never inside it.

UTF-8 is the default assumption and usually the right one. The word café in UTF-8 is five bytes, and the decode is a pleasure:

printf 'Y2Fmw6k=' | base64 -d | xxd -p

That is 636166c3a9: caf plus the two UTF-8 bytes c3 a9 for the é. Older systems, though, will hand you Latin-1 (ISO-8859-1) bytes, where the same letter is the single byte e9. Decoding such a blob and printing it straight to a UTF-8 terminal gives you a mangled character; the fix is to re-interpret the bytes with iconv before anything else sees them:

base64 -d latin1.b64 | iconv -f ISO-8859-1 -t UTF-8 > utf8.txt

A few byte-level facts that save real debugging time:

  • A UTF-8 BOM is the three bytes ef bb bf, which encode to 77u/. Notice the /, a URL-hostile character, which is exactly the kind of thing base64url exists to fix. If a decoded file "has invisible garbage in front", check the first three bytes with xxd.
  • An emoji such as 😀 is four UTF-8 bytes and becomes eight Base64 characters, 8J+YgA==. The + in there is the standard alphabet working exactly as designed; in base64url clothing it becomes 8J-YgA.
  • Invalid UTF-8 sequences decode perfectly well as bytes and then display as garbage or replacement characters. That is not a Base64 failure; the decode did its job. xxd or hexdump -C shows you what the bytes actually are.
  • Your terminal's locale decides how it renders those bytes. "The terminal shows garbage" is a statement about display, not about data. The bytes did not change on the trip.

Big Blobs: Streams, Splits and Speed

Base64 is a stream format, and the decoder works as a true streaming pipe: a 10 GB file never sits in memory, it just moves through. That makes the decode side almost boring at scale, which is exactly what you want.

When a big blob was split into chunks for transfer (an email limit, a ticket attachment size, an IM message), reassembling is just a cat in the right order, followed by the usual wash:

cat part_* | tr -d '\r\n' | base64 -d > big.bin

Keep one mental model for sizes: the encoded form is always about a third bigger than the original, four characters per three bytes. So when someone tells you the .b64 file should be the same size as the thing it hides, they are wrong, and you can now tell them by how much: a 300 MB payload arrives as roughly 400 MB of text.

Speed is not a concern in practice. These are table-driven loops over plain memory, and on a modern machine the decoders push through gigabytes in well under a second; a 200 MB blob decodes in roughly a tenth of a second with GNU, and even the slowest of the common implementations (BusyBox) finishes in about a second and a half. Memory use stays flat no matter how big the input gets, because nothing is buffered.

When the Machine Has No base64

Most systems have at least one of the tools above, and most have several. If your platform lacks coreutils entirely (a stripped container, an unusual appliance), the install paths look like this:

Platform Get it Notes
Debian / Ubuntu preinstalled; apt install coreutils if stripped current releases default to the uutils family, with the GNU twin reachable as gnubase64
RHEL / Fedora dnf install coreutils preinstalled on effectively every image
Alpine apk add busybox (usually already present) busybox base64 -d; no -i flag
macOS built in; brew install coreutils for the GNU version BSD flags: -D to decode, -b for line width; brew gives you gnubase64

And if no package manager is an option at all, the universal fallbacks below all read from standard input and write bytes to standard output, so they slot into the same pipelines:

openssl base64 -d -A -a < in.b64 > out.bin
perl -MMIME::Base64 -0777 -ne 'print decode_base64($_)' < in.b64 > out.bin
python3 -c 'import base64, sys; sys.stdout.buffer.write(base64.b64decode(sys.stdin.buffer.read()))' < in.b64 > out.bin

On a BusyBox system with the base64 applet compiled out, the old guard still works: uuencode -m produces MIME Base64, and its sibling reads it back:

busybox uudecode -o out.bin in.uu

Pitfalls That Eat Whole Afternoons

Everything below is behavior you will meet in the field, collected in one place:

Pitfall What happens The fix
openssl base64 -d on its own Silently does nothing and exits 0, because -d means Decrypt there openssl base64 -d -A -a, and treat a zero-byte result as an error
Decoding on macOS with -d The BSD base64 rejects the flag (or treats it as debug) -D, or install coreutils for gnubase64
CRLF line endings in the input GNU prints a partial result then fails; uutils and BusyBox accept it tr -d '\r\n' first, always for foreign input
Unpadded tails BusyBox rejects any remainder; GNU and uutils accept only canonical tails restore the missing = padding before decoding
Garbage after the padding, e.g. TQ==junk All common decoders keep decoding and exit 0 validate the shape of the input before trusting the output
Non-canonical spare bits, e.g. SGV= uutils and GNU reject (GNU after partial output); BusyBox decodes anyway the input is corrupt; regenerate the encoding upstream
Reading decoded output into a variable $(...) strips trailing newlines and cannot hold NUL bytes at all decode into a file, inspect with xxd
-i on untrusted input corruption becomes a silent success; ignored characters can carry hidden data decode strictly, read the error, fix the source
Mixing up the two alphabets decoding base64url as standard (or vice versa) yields wrong bytes or an error know the format before decoding; the swap is tr '_-' '/+'
Treating a decoded secret as a secret one command undoes it; the RFC records real incidents of leaked credentials actual encryption, not encoding

The OpenSSL row deserves a paragraph of its own, because the failure is so silent. On OpenSSL 3.x the standalone app's -d is its general "decrypt" option, and Base64 processing is a separate mode selected with -a. So openssl base64 -d reads your input, does nothing, prints nothing, and exits 0. The working decode is openssl base64 -d -A -a, where -A tells it the input is one continuous line. If you must use OpenSSL for decoding, treat a zero-byte output as an error, every single time.

A Tight Routine

The habits that keep Base64 from ever getting the better of you:

  • Fail loudly. Run scripts with set -euo pipefail and check exit codes. All of the common decoders exit 1 on bad input; OpenSSL is the loud one that went quiet, so for it, also check that the output is non-empty.
  • Prove the round trip. cmp or sha256sum between expected and restored bytes is the only proof that matters. Never eyeball binary.
  • Binary to files, never to variables. Trailing newlines and NUL bytes are both casualties of command substitution.
  • Wash foreign input. tr -d ' \r\n' before decoding anything that crossed a platform boundary.
  • Stay strict by default. Keep -i off until you have seen what exactly was wrong; a strict failure tells you the location, a lenient one tells you nothing.
  • Name the alphabet. Standard Base64 and base64url are different encodings per RFC 4648. Decode a JWT as base64url, an email attachment as standard, and never swap characters without knowing why.
  • Never print what you just decoded. The whole point of the format in the secrets world is invisibility; the whole point of a log is visibility. Those two goals do not mix.

A small portable shim for the "which flag does this machine want" question:

case "$(base64 --version 2>&1 | head -1)" in
  *uutils*|*GNU*) b64d () { base64 -d; } ;;
  *)              b64d () { base64 -D; } ;;
esac
b64d < in.b64 > out.bin

The case statement catches the two coreutils families by their version banner and falls back to the BSD flag on anything else, which is exactly the three-way split of the real world.

How the Shell Learned to Decode

The format is old; the command you are typing is not. A short timeline of the shell side of the story:

  • 1980, Berkeley. Mary Ann Horton writes uuencode and uudecode at the University of California, Berkeley, to carry binary files (usually compressed ones) through email. The name means "Unix-to-Unix encoding", a safe encoding for moving files between Unix systems that might not share a charset. For decades this, not Base64, is what shell users reach for.
  • The dial-up era. The earliest base encodings ride on the same problem: uuencode on UNIX, BinHex on the TRS-80 and later the Macintosh, each assuming only the characters its own terminal could print.
  • 1993. MIME standardizes Base64 for email (RFC 1521, later RFC 2045), with the 76-character line wrapping that still defines the base64 default today.
  • 2003 and October 2006. RFC 3548 tidies up the family, and RFC 4648 replaces it with the alphabets and the strict-decoding rule this article keeps quoting, base64url included.
  • November 22, 2006. coreutils 6.6 adds the base64 command itself, citing RFC 3548 in its changelog. Before that date, shell users on Linux reached for openssl base64, uuencode -m, Perl, or Python, which is why so many old scripts assume OpenSSL is the only option in town.
  • OS X 10.8. macOS ships its own base64, the BSD flavor with the -D flag and no default line wrapping, which is where the -d versus -D split comes from.
  • March 2024. coreutils 9.5 relaxes the decoder: padding is no longer required when decoding, and encodings with non-zero spare bits are now diagnosed as corruption instead of quietly accepted.
  • 2025. The Rust rewrite of coreutils (uutils) becomes the default on current Ubuntu releases. Same command name, same flags, a new engine with its own edge opinions, like accepting -D as an alias.

Curiosities Worth Keeping

  • The command is younger than the format. Base64 has been in email since 1993, but the base64 command only appeared in 2006. Nineteen years of shell scripts did this job with other tools, and you can still find their fingerprints everywhere.
  • A fossil in the help text. The uutils base64 still describes its alphabet as "RFC 3548" in its help, the retired predecessor of RFC 4648, while its GNU sibling already cites the current standard. A tiny fossil, visible only if you read the help.
  • The most expensive silent no-op in the toolbox. openssl base64 -d does nothing and exits 0. An entire debugging hour, gone, and the exit code test passed.
  • BusyBox keeps the spare bits. It will happily decode SGV=, whose leftover bits are non-zero and therefore non-canonical, while its GNU cousin throws a fit over the same input. Same RFC, different nerves.
  • The format's name is true on every machine. printf 'base64' | base64 gives YmFzZTY0 on GNU, uutils, BusyBox, and OpenSSL alike. It has been true since 2006 and always will be.
  • Base84 is not Base64. Git's "binary patch" blocks look like Base64 to the untrained eye, but the z-prefixed lines are a base85-style dialect of one. The impostor that costs people a grep-and-decode detour.
  • Eleven characters, sixty-four bits. A YouTube video ID is an 11-character base64url string, a 64-bit number wearing URL clothing, which is why it can appear in a URL without a single percent sign.
  • Decoders disagree on one character. A single string like SGV= splits the field into three camps, as the behavior table above shows. If a decode fails on "obviously valid" input, you are probably standing on the spare-bits line, and the encoding was never canonical to begin with.

So the next time a string of letters, digits, plus and slash lands in your terminal, you know the whole story: which decoder is watching, which alphabet is speaking, where the newlines are hiding, and exactly how to get the bytes back, intact and proven byte-for-byte, without losing an hour to a carriage return. And when the job points the other way, packing your own binary into a text envelope for the trip, the related Base64 encoding article linked below covers that ritual in the same depth.

Last updated: 2026-08-29

Related article: Base64 Encoding in Bash: A Complete Guide