Base64 Encoding in Bash: A Complete Guide
You have bytes and you need a string. A text file that must live inside a JSON body. An image that has to sit in a config line. A token that will travel through a URL, an environment variable, or an HTTP header. A private key that belongs in a certificate store. This is the everyday job of Base64 encoding in the shell, and the shell's answer is a single, small, remarkably portable command.
The trade in one breath: Base64 rewrites every three bytes of raw data as four characters from a 64-letter alphabet (A-Z, a-z, 0-9, plus + and /), padding the tail with one or two = signs when the byte count is not a multiple of three. The main page of this site explains the format in full; here we spend our time on producing the text, choosing the right dialect for the destination, and paying the size bill with open eyes. One number to keep in your pocket: the encoded form is normally about a third bigger than the original, four characters per three bytes, and it keeps coming back.
The cast is small. The base64 command from coreutils (GNU or the newer Rust uutils family), basenc from the same family for the URL-safe dialect, openssl base64 for machines without coreutils, the BusyBox applet for embedded systems, and the BSD flavor on macOS. Five tools, one job, a few flags worth knowing.
Pick Your Encoder
Every one of these reads bytes from standard input or a file and writes text to standard output, so they all slot into the same pipelines. The differences are the default line wrapping and the available dialects:
| Tool | Where it lives | Default line wrapping | Reach for it when |
|---|---|---|---|
base64 (coreutils) |
Linux, and macOS via Homebrew | 76 characters | the default choice; add -w 0 for one line |
basenc (GNU coreutils) |
Linux with coreutils | 76 characters | you need --base64url, base32, base16, or friends |
openssl base64 |
everywhere OpenSSL is installed | 64 characters | coreutils is absent; -A for one line |
busybox base64 |
Alpine, embedded Linux | 76 characters | minimal systems; the same flags in a smaller body |
base64 (BSD/macOS) |
macOS, the BSDs | none (one long line) | native macOS work; -b sets the width |
Read that wrapping column twice, because it is the quiet difference between the families. Coreutils and BusyBox wrap at 76 by default, OpenSSL wraps at 64, and the BSD tool does not wrap at all. None of them is wrong; they just inherited different conventions (MIME says 76, PEM says 64, and the BSD tool is simply older than the wrapping habit). When your consumer cares, set the width explicitly and never rely on the default.
Text First: The Invisible Newline
Encoding text in a shell starts with one trap: echo adds a newline. Those five letters "hello" become six bytes the moment they pass through echo, and the sixth byte rides along into the output, invisible and permanent:
echo "hello" | base64
That prints aGVsbG8K, and the final character encodes the newline. The fix is the one you should use for text where the byte count matters: printf with a format, no decoration:
printf '%s' "hello" | base64
Now the output is aGVsbG8=, exactly five bytes worth, and the last character is a padding sign rather than a live byte. The same rule applies to here-strings, which append a trailing newline just like echo: base64 <<< "hello" gives you the aGVsbG8K version again. When in doubt, ask yourself what the last byte is before you encode it.
For anything you want on a single line, add -w 0 (or -w 0's cousins below), which also removes the final newline the command would otherwise emit:
printf '%s' "hello world and more" | base64 -w 0
That is one clean, unbroken line with no trailing newline, ready to drop into a URL, a JSON value, or a config file without any further ceremony.
Files and the Wrap Width
Files are the common case, and every implementation takes a FILE argument, which keeps the bytes away from the shell's quoting machinery entirely:
base64 -w 0 report.pdf > report.b64
Without -w 0, the output arrives wrapped at 76 characters, which is exactly what a MIME consumer wants:
base64 report.pdf > report.mime.b64
The width is a dial you control per consumer. Seventy-six is the MIME convention from RFC 2045, sixty-four is the PEM convention used by certificates and keys, and zero means one unbroken line for URLs and APIs:
base64 -w 64 key.bin | head -2
If the consumer lives on Windows and expects CRLF line endings, convert after the wrap, not before:
base64 -w 76 attachment.bin | sed 's/$/\r/' > attachment.crlf.b64
For the OpenSSL path, the equivalent of one-line mode is the -A flag, which also suppresses the trailing newline:
openssl base64 -A < report.pdf
Before you ship a wrapped file, a size sanity check costs nothing and catches a surprising number of mistakes (a file encoded twice, a file encoded with the wrong input):
wc -c report.pdf
base64 report.pdf | wc -c
The second number should be about four-thirds of the first, plus one byte per wrapped line for the newlines. If it is wildly different, stop and look at what you actually fed the encoder.
URL-Safe Base64: Swapping the Two Nasty Characters
Two letters in the standard alphabet, + and /, are the problem children: a + in a URL query string means a space, a / can look like a path separator, and both force percent-encoding the moment the string enters a URL, a cookie, or a filename. RFC 4648 section 5 solves this with a dialect that replaces exactly those two characters with - and _, and drops the padding, since a URL rarely needs to advertise the exact byte length.
The shell recipe is a swap plus a trim, two passes through tr and one to remove the padding:
printf '\376\117\202' | base64 -w 0 | tr '+/' '-_' | tr -d '='
Those three bytes normally encode to /k+C, which would be ugly in a URL; the pipeline turns it into _k-C, four characters that can travel anywhere. The swap is positional, so the direction is easy to mix up: encoding goes tr '+/' '-_' (plus becomes dash, slash becomes underscore), and the reverse, which belongs to decoding, goes tr '_-' '/+'. A mixed-up direction does not error, it just produces different bytes, which is the worst kind of bug to ship.
The dialect matters whenever the string leaves the shell's control: JWT segments, tokens in query strings, values in cookies or filenames, and any identifier that another system will read as part of a URL. GNU's basenc produces the dialect natively, with padding still in place:
printf '%s' "hello" | basenc --base64url
Strip the padding with tr -d '=' if the consumer wants the stripped form, as most do.
Minting a JWT in the Shell
JSON Web Tokens are the most visible consumer of URL-safe Base64 in the API world. A compact JWT is three base64url segments joined by dots: the header, the payload, and the signature, per RFC 7515. The first two are plain JSON; the signature is a binary digest of the first two segments joined by a dot, which is exactly the kind of thing openssl is good at.
key="supersecretkey"
h=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | base64 -w 0 | tr '+/' '-_' | tr -d '=')
p=$(printf '%s' '{"sub":"42","name":"homer"}' | base64 -w 0 | tr '+/' '-_' | tr -d '=')
s=$(printf '%s' "$h.$p" | openssl dgst -sha256 -hmac "$key" -binary | base64 -w 0 | tr '+/' '-_' | tr -d '=')
printf '%s.%s.%s\n' "$h" "$p" "$s"
That prints a compact HS256 JWT that any standard library on any platform will accept. Notice the division of labor: the Base64 part is the alphabet, the openssl dgst -sha256 -hmac part is the cryptography, and the dot-joining is the format. Keep the three jobs separate in your head and the pipeline stays obvious.
Three cautions for the field. First, the MAC is computed over the ASCII text of the first two segments plus the dot, so the segments must already be in their final base64url form when you sign them; re-wrapping or re-padding after signing breaks the token. Second, the key stays out of the token: the signature proves who signed, the key keeps the secret secret. Third, minting in a shell script is a testing and automation tool, not a replacement for the server that will actually issue and verify these tokens, and a token minted with alg: none proves nothing at all.
Data URIs: Files Riding Inside Strings
RFC 2397 defines the data: URL scheme, and its Base64 form lets a file live inside a URL: data:, then an optional media type, then ;base64 when the payload is Base64-encoded, then a comma, then the data. Omit the media type and the default is text/plain;charset=US-ASCII, which is a footgun worth knowing about, because most people mean an image or a JSON document, not ASCII text.
printf 'data:text/plain;base64,%s\n' "$(printf '%s' "hi there" | base64 -w 0)"
That prints data:text/plain;base64,aGkgdGhlcmU=, a complete, self-contained URL that a browser will happily display. For an image, the same shape with a real media type:
printf 'data:image/png;base64,%s\n' "$(base64 -w 0 icon.png)" > icon.uri
Paste the result into an HTML img tag's src or a CSS background and the image ships with the document, no second HTTP request. The pitfalls are all about size: the RFC itself says the scheme is only useful for short values, browsers impose their own URL length limits, every inlined byte costs the 33 percent overhead on top of the image's own size, and a page full of data URIs is a page with no caching story for those images. For small icons and one-off embedded graphics it is a joy; for a photo library it is a tax.
Secrets, Config and Environment Variables
Base64 shows up in configuration and secrets work for one specific reason: it turns arbitrary bytes, including spaces, quotes, and newlines, into a string that survives an export, a config line, or a JSON field without any quoting acrobatics. Kubernetes is the most visible example: every field under a secret's .data is Base64, so creating a secret in the shell is just encoding:
kubectl create secret generic app --from-literal=password='s3cret'
The API server stores the password as czNjcmV0 under .data, and any node with access to the secret can read it back with one decode. The same move works for your own config files:
export API_TOKEN_B64=$(printf '%s' "$API_TOKEN" | base64 -w 0)
or, for a file the application reads at startup:
printf 'token=%s\n' "$(printf '%s' "$API_TOKEN" | base64 -w 0)" >> app.conf
Now comes the warning that belongs on a wall: Base64 is encoding, not encryption. RFC 4648's security section is blunt about it, noting that the encoding "visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality", and that this exact misunderstanding has caused real security incidents when someone pasted a "protected" protocol exchange into a bug report and accidentally revealed the credentials. If the value must be secret, encrypt it (and then Base64 the ciphertext for storage); if Base64 is all you have, treat the encoded value as plain text the moment it leaves the screen.
Unicode, Charsets and the Bytes Beneath
The encoder reads bytes, not characters, and the shell hands it whatever bytes the locale and the command produced. For UTF-8 text that is usually exactly what you want: the é in héllo is already two bytes, c3 a9, and the encoding just carries them along:
printf 'h\xc3\xa9llo' | base64
That prints aMOpbGxv, and a UTF-8 consumer on the other side gets héllo back, byte for byte. The trouble starts when the source is not UTF-8. A Latin-1 file with the same word holds a single byte e9 for the é, and encoding those bytes straight through produces text that only a Latin-1 consumer can read back. Convert first, encode second:
iconv -f ISO-8859-1 -t UTF-8 note.txt | base64 -w 0
Two more byte-level facts. A UTF-8 BOM, three bytes at the front of a file, encodes to 77u/ and will sit at the front of your decoded output forever unless you strip it first:
sed '1s/^\xef\xbb\xbf//' file.txt | base64 -w 0
And the locale never changes the encoding itself, because the encoder is a byte machine; it only changes what you typed. When the output looks wrong, check the bytes you fed, not the encoding you ran.
Email, APIs and Uploads
Email is where Base64 learned its manners, and the manners are still the convention. SMTP historically carried only 7-bit ASCII, so attachments travel as Base64 wrapped at 76 characters with CRLF line endings, per RFC 2045. Producing that exact shape for a MIME part is the wrap plus the line-ending conversion:
base64 -w 76 attachment.bin | sed 's/$/\r/' > attachment.mime
The old guard is still on duty in embedded systems: BusyBox's uuencode with the -m flag produces MIME Base64 wrapped in the familiar begin-base64 framing, and its sibling uudecode reads it back:
busybox uuencode -m photo.jpg < photo.jpg > photo.uu
APIs and uploads use the same idea in JSON clothing: the binary becomes a Base64 string inside a JSON field, and curl carries it. Building the body in a shell variable keeps the quoting honest:
body="{\"file\":\"$(base64 -w 0 upload.bin)\"}"
curl -fsS -X POST https://api.example.com/upload -d "$body"
Two interoperability traps live here. First, check which alphabet the API wants: some expect standard Base64, some expect the URL-safe dialect, and a string with + characters sent to a URL-safe endpoint (or vice versa) will fail validation or, worse, decode to the wrong bytes. Second, watch for double-encoding, the classic bug where a script encodes a value that the server encodes again, and the round trip needs two decodes to unwind.
When the Payload Gets Big
The encoder, like the decoder, is a streaming machine: it reads in chunks and writes in chunks, so a 10 GB tarball does not need 13 GB of RAM, and the command happily runs for minutes on big inputs with flat memory use. The size math is the only planning tool you need: the output is four characters per three input bytes, plus one byte per wrapped line, so a 300 MB file becomes roughly 400 MB of text. For a quick reality check on any file:
base64 -w 0 big.bin | wc -c
When the text itself must be moved through a channel with a size limit (an email attachment cap, a ticket system, an IM message), split the encoded form, never the raw binary, so every chunk is still ordinary text you can paste, compress, or forward:
base64 -w 0 big.bin | split -b 4000 - part_
That produces a run of 4000-character parts; the receiver cats them back together in order and decodes once. And when the payload is compressible, compress before you encode, because Base64 adds redundancy on top of whatever the data already contains: a tarball of a project directory typically shrinks several times under gzip before the 33 percent Base64 surcharge is applied:
tar czf - project/ | base64 -w 0 > project.b64
Speed will not be your constraint. These encoders push through gigabytes in well under a second on a modern machine; a 200 MB file takes roughly a tenth of a second with the coreutils and OpenSSL implementations, and even BusyBox, the slowest of the common ones, finishes in under a fifth of a second. The bottleneck in real pipelines is almost always the network, not the encoding.
The Small Characters That Bite
The pitfalls of the encoding side are smaller than the decoding side's, which is only fair:
| Pitfall | What happens | The fix |
|---|---|---|
echo feeding the encoder |
a trailing newline rides into the output, and the last character encodes it | printf '%s' for text where the byte count matters |
| Relying on the default wrapping | 76, 64, or zero depending on the tool; a one-line consumer chokes on wrapped input | set -w 0 (or the width the consumer wants) explicitly |
| A trailing newline in the output | line-wrapped modes end with a newline that pollutes URLs and JSON when captured | -w 0 for one line, or capture through $(...) which strips it |
+ or / in a URL |
plus reads as a space in a query string; both force percent-encoding | use the URL-safe dialect for anything that enters a URL |
Mixed-up tr direction |
the swap produces valid but wrong bytes, no error anywhere | encoding is tr '+/' '-_'; decoding is tr '_-' '/+' |
| Encoding an already-encoded value | double-encoding that needs two decodes to unwind | check whether the source is already Base64 before encoding |
| A UTF-8 BOM in the input | three extra bytes at the front of every decoded output | strip the BOM first: sed '1s/^\xef\xbb\xbf//' |
| Storing a real secret as Base64 | one command undoes it; the RFC records real incidents of leaked credentials | encrypt for secrecy, Base64 for transport shape only |
| Assuming the consumer's alphabet | standard versus URL-safe mismatch fails validation or decodes wrong | read the API docs; encode in the dialect the consumer asks for |
Habits That Keep You Safe
- Name the byte count.
printf '%s'for text, theFILEargument for files, and awc -csanity check before you ship anything where size matters. - Set the wrap explicitly.
-w 0for URLs and JSON,-w 76for MIME,-w 64for PEM. Never leave the width to the tool's default. - Choose the alphabet for the destination. Standard for email and files, URL-safe for tokens and URLs, and check the consumer's docs before you encode.
- Compress before you encode. For any compressible payload,
gziportar czffirst; the 33 percent surcharge applies to whatever you hand the encoder. - Split the text, not the binary. When a size limit is in the way,
splitthe encoded form so every chunk stays paste-safe, and reassemble in order before the single decode. - Keep the three JWT jobs apart. Alphabet, cryptography, format: encode the segments, sign the ASCII text of the joined segments, then emit. Reorder them and the token breaks.
- Never let Base64 stand in for encryption. If the value is secret, encrypt it and then encode the ciphertext. If it is not secret, say so and stop worrying.
A Short History of Encoding in the Shell
- 1980, Berkeley. Mary Ann Horton writes
uuencodeanduudecodeat the University of California, Berkeley, to carry binary files through email between Unix systems. The name, "Unix-to-Unix encoding", is the format's birth certificate, and for the next decade or so this is what shell users encode with. - The dial-up era. uuencode on UNIX and BinHex on the TRS-80 and later the Macintosh solve the same problem with different alphabets, each trusting only the characters its own terminal can print.
- 1993. MIME standardizes Base64 for email in RFC 1521, later RFC 2045, with the 76-character line wrapping that the coreutils default still carries today.
- Before 2006 on Linux. There is no
base64command. Shell scripts reach foropenssl base64,uuencode -m, Perl, or Python, and the OpenSSL habit is so deep that half the old one-liners in the wild still start with it. - November 22, 2006. coreutils 6.6 ships the
base64command, citing RFC 3548 in its changelog, and the one-command era begins. Three months earlier, in October 2006, RFC 4648 had formalized the alphabet family, including the URL-safe dialect this article keeps reaching for. - OS X 10.8. macOS ships its own
base64, the BSD flavor with no default wrapping, which is why "just run base64" needs a platform check in portable scripts. - 2024. coreutils 9.5 changes how decoders treat unpadded and non-canonical input, which in practice means encoders get a free pass: output that older GNU versions would have rejected now decodes cleanly. The encoder side of the format is the stable one; the decoders are the ones that moved.
- 2025. The Rust rewrite of coreutils (uutils) becomes the default on current Ubuntu releases. Same command, same flags, a new engine, and the same 76-character default it inherited from the C version.
Little Wonders
- The format's name is true on every machine.
printf 'base64' | base64givesYmFzZTY0on GNU, uutils, BusyBox, OpenSSL, and macOS alike. It has been true since 2006 and always will be. - A file of nothing encodes to a wall of A's. Feed it three NUL bytes and the output is
AAAA, because zero is the first value in the alphabet and the first character is the A. A.b64file that starts with a long run of A's is usually padding, not a mystery. - The 33 percent tax has no discounts. Four characters per three bytes, no compression, no second chance. The only escape is to compress the data first, which is why
tar czfis the real hero of big-payload pipelines. - Two letters caused all the URL trouble.
+and/are the only alphabet members that ever needed a substitute, and an entire dialect of the format exists to retire them. Sixty-two and sixty-three, the alphabet's last two slots. - Eleven characters, sixty-four bits. A YouTube video ID is an 11-character base64url string, a 64-bit number in URL clothing, which is why it travels through URLs without a single percent sign.
- Git's most famous impostor. The binary blocks in
git diff --binarylook like Base64, but thez-prefixed lines are a base85-style dialect of one. One glance and you know it is not your alphabet; one grep-and-decode detour and you lose twenty minutes. - Every tool wraps differently, on purpose. 76 for MIME, 64 for PEM, zero on the BSD tool: three defaults, three inherited conventions, one format. The width was always yours to choose; the tools just remembered different defaults.
- The encoder never fails on your data. Unlike its decoding cousin, the encoder has no invalid input, no corruption, no strict mode. It takes bytes and gives letters, every time. The bugs in this article are all in the bytes you hand it and the destination you send them to.
And when the trip points the other way, when a long string of letters, digits, and the occasional dash or underscore lands in your terminal and you need the bytes back, the related Base64 decoding article linked below covers that ritual in the same depth, from unpadded JWT segments to every newline trap the decoders hide.
Last updated: 2026-08-29
Related article: Base64 Decoding in Bash: A Complete Guide