Encoding is not encryption, and the difference is one word: key
Base64 is a reversible mapping from arbitrary bytes to 64 printable ASCII characters. That’s it. There is no key, no secret, no hardness assumption. Anyone who sees base64 data can decode it, in milliseconds, with tools built into every language and every browser console. atob() is right there.
Encryption means confidentiality against someone who has the data but not the key. Encoding means representation: the same information in a different alphabet, transformable in both directions by anyone. Base64 relates to encryption the way translating a letter into French relates to locking it in a safe. Both change what you see; only one keeps anyone out.
We keep meeting base64 where encryption was intended. Credentials in config files. API keys in mobile app resources. HTTP Basic auth, which per RFC 7617 is literally base64(username:password) in a header, which is why Basic auth without TLS is plaintext authentication. Anywhere a security decision rests on base64 being unreadable, that decision is wrong. Encode encrypted bytes for transport, absolutely; that’s the correct pairing. Just never let the encoding stand in for the encryption.
Why base64 exists: email could only carry 7 bits
Base64 is a solution to a 1980s transport problem. Early internet mail (SMTP, RFC 821 from 1982) guaranteed only 7-bit ASCII text; the eighth bit of every byte could be stripped or mangled anywhere along the relay path, and control characters could be interpreted rather than delivered. Sending an image or a binary through that pipe corrupted it. The first popular workaround was uuencode, written by Mary Ann Horton at Berkeley in 1980 and shipped with 4.0BSD, which mapped binary onto printable characters for UUCP mail.
But uuencode used characters like spaces that some mail gateways rewrote, so the fix wasn’t reliable across systems. MIME (RFC 1341, June 1992) settled the matter by defining the base64 content-transfer-encoding we still use, deliberately choosing 64 characters that survive every known gateway: A to Z, a to z, 0 to 9, plus + and /. The alphabet itself was adapted from the earlier Privacy-Enhanced Mail work (RFC 1113, 1989). So the encoding inside every JWT and QR code today is, at heart, a workaround for mail relays that died decades ago. Amusingly durable.
Your email attachments still travel this way. Open a raw .eml file and there’s your PDF, wrapped at 76 characters per line, in base64.
The 33% tax, exactly
Base64 spends one output character (8 bits on the wire) to carry 6 bits of payload. Three input bytes are 24 bits, which is exactly four 6-bit characters, so every 3 bytes become 4 characters. The overhead is exactly one third, always, plus up to two padding characters at the end. MIME email adds its 76-character line wrapping on top, another 2.6% or so for the CRLFs.
| Input | Base64 output | Growth |
|---|---|---|
| 3 bytes | 4 chars | +33% |
| 1 KB | 1,368 chars | +33.6% (incl. padding) |
| 100 KB image | ~133 KB | +33% |
Could you do better and stay printable? A little. Base85 (used inside PostScript and by git’s binary diffs) packs 4 bytes into 5 characters for 25% overhead, but its alphabet includes quotes and backslashes, which is exactly what you don’t want inside JSON strings or URLs. Base64’s alphabet was picked for survivability, not density, and that trade has aged well.
One more thing on size: gzip and brotli compress base64 poorly compared to the raw bytes, because the encoding smears byte patterns across character boundaries. If something needs compressing, compress first, then encode the result.
base64url, or why anyone can read your JWT
Standard base64 breaks in URLs: + decodes as a space in query strings and / is a path separator. RFC 4648 (2006) therefore defines a second alphabet, base64url, swapping those two characters for - and _ and typically dropping the = padding. It’s the variant inside every JWT: a token is three base64url parts separated by dots, header.payload.signature.
Which leads to the fact that surprises people weekly: JWT payloads are readable by everyone. The signature prevents modification, not inspection. Paste any JWT into our base64 decoder and the claims are right there (in your own browser tab, which is the only place you should ever paste a live token), and you can spot one on sight because eyJ is simply base64 for {", the opening of the JSON header. Every JWT you have ever seen starts with those three characters. Don’t put anything in a payload you wouldn’t put in a log line; we go deeper on token handling in where to store JWTs.
data: URIs: when inlining helps and when it hurts
The data: URI scheme (RFC 2397, August 1998) lets you embed a file directly where a URL would go: data:image/png;base64,iVBORw0... in an src attribute or a CSS url(). One fewer HTTP request, and the asset can’t 404 independently of the page.
The costs are easy to underestimate. The data pays the 33% base64 tax. It can’t be cached on its own, so an icon inlined into your stylesheet re-downloads with every stylesheet change, and one inlined into HTML re-downloads on every page view. It also blocks nothing-to-render-yet parsing wins you’d get from a parallel image fetch. And history has opinions here: IE8, the first IE with data URI support, capped them at 32 KB.
Inline only assets that are tiny (a few KB), page-specific and stable, like a small SVG logo in a critical-CSS block. With HTTP/2 multiplexing, the cost of an extra request is small enough that separate, cacheable files win almost everything else. The same instinct applies to build output generally; shaving bytes there is what a JS minifier is for, and inlining a 200 KB hero image as base64 undoes all of it at once.
The = padding, and the decoders that care
Since 3 bytes map to 4 characters, input lengths that aren’t multiples of 3 leave a remainder. Base64 handles it with padding: one leftover byte encodes to 2 characters plus ==, two leftover bytes to 3 characters plus =. Output length is always a multiple of 4. The padding carries no data; it exists so block-based decoders know exactly where the end is without an out-of-band length.
Strictness varies wildly, which is where the bugs live. Python’s base64.b64decode() raises binascii.Error: Incorrect padding on unpadded input, while JavaScript’s atob() tolerates missing padding but throws on stray whitespace or a base64url character. JWTs omit padding per spec, so feeding a JWT segment to a strict standard-base64 decoder fails twice over (padding and alphabet). The fix is boring and works: know which variant you’re holding, and normalize (translate -_ to +/, re-add = to a multiple of 4) before a strict decode. If you just want to see what a blob contains without writing code, our base64 decoder and encoder run in the browser tab, so pasting a payload from a production log does not send it anywhere.
Spotting base64 by eye
After a while you clock it instantly, and the tells are mechanical. The character set is only A to Z, a to z, 0 to 9 and +/ (or -_ for the url flavour), no spaces, no punctuation beyond those. Length is a multiple of 4, possibly with one or two trailing =. And a few prefixes give away the content type before you decode: eyJ is JSON (hello, JWT), iVBOR is a PNG, /9j/ is a JPEG, UEsDB is a ZIP (and therefore also every .docx and .xlsx).
Decoding it is where the fun starts, because the output is bytes, not necessarily text. Decode something that was UTF-8 and interpret it as Latin-1, or vice versa, and you get the classic garbled-character salad; that failure mode has its own name and our mojibake explainer covers it. And when the decoded bytes look like line noise with no recognizable header, consider that you might finally be looking at actual encrypted data. In which case base64 did its one job: it got the bytes to you intact.
What people get wrong about base64
Is base64 a form of encryption?
No. Base64 is an encoding: a reversible mapping from bytes to 64 printable characters, with no key and no secret. Anyone can decode it instantly, with a one-liner in every programming language or any online decoder. Encryption requires a key, and without that key the ciphertext is useless to an attacker. Base64 has no key by design, because its job is transport (making binary data survive text-only channels), not confidentiality. If you need secrecy, you need actual encryption like AES-GCM, and you can still base64 the encrypted result for transport.
Can base64 be decoded by anyone?
Yes, trivially. Decoding is a fixed table lookup defined in RFC 4648: every character maps back to 6 bits, no key or secret involved. In JavaScript it’s atob(), in Python base64.b64decode(), on a shell base64 -d. This is why base64 in a config file, URL or cookie should be treated as plaintext in any security review. It hides content from casual eyes the way an envelope does, and no more.
Why does base64 output end with equals signs?
The = characters are padding. Base64 processes input in blocks of 3 bytes, emitting 4 characters per block. When the input length isn’t divisible by 3, the last block is short: one leftover byte produces 2 characters plus ==, two leftover bytes produce 3 characters plus =. The padding brings the output to a multiple of 4 so decoders can process fixed-size blocks. Some variants drop it, most prominently the base64url used in JWTs, because = has a reserved meaning in URLs.
Why is base64 bigger than the original data?
Because it spends 8 bits of output to carry 6 bits of information. Each output character comes from a 64-symbol alphabet, so it encodes exactly 6 bits, but it’s stored as a full byte. That’s 4 output bytes for every 3 input bytes, a fixed overhead of one third (plus up to 2 padding characters, and about 2.6% more in MIME email, which wraps lines at 76 characters). There’s no way around it while staying in printable ASCII; base85 gets the overhead down to 25% but uses characters that break in URLs and quoted strings.
Is it safe to put sensitive data in a JWT payload?
No, unless the JWT is additionally encrypted (JWE, which is rare in practice). A standard signed JWT is three base64url-encoded parts, and the payload is readable by anyone who holds the token: paste it into any decoder and the claims are right there. The signature stops tampering, not reading. So user IDs and expiry timestamps are fine, but passwords, personal data or internal flags in a JWT payload are effectively public to the token holder and to anything that logs the token.
When should I use a data URI instead of an image file?
For small, single-use images, roughly under a few KB, where saving an HTTP request outweighs the 33% size overhead: tiny icons, tracking pixels, inline SVG backgrounds in CSS. For anything larger or reused across pages, a separate file wins, because data URIs can’t be cached independently: the image re-downloads inside every HTML or CSS file that embeds it, and it inflates those files’ size on every load. With HTTP/2 multiplexing, the request-saving argument is weaker than it was in 2010, so the sensible default today is separate files.
What should I use instead of base64 when the data really has to stay secret?
An authenticated cipher with a key you keep somewhere else. In practice that is AES-256-GCM or ChaCha20-Poly1305 through libsodium, Go crypto/cipher, Node crypto or Python cryptography, with the key in a secrets manager rather than the repository. If the data only has to be unreadable at rest to the storage provider, envelope encryption via KMS is the ordinary answer. Base64 still shows up afterwards, because ciphertext is binary and has to be transported as text, which is exactly the job it was built for.