A support ticket, pasted in good faith: the failing request, the response body, and one header line reading Authorization: Basic dGVzdDoxMjPCow==. To whoever pasted it, that trailing string is noise — machine scribble, safely unreadable, the kind of thing you leave in because redacting it feels paranoid. It is a username and a password. Getting them back takes one command that ships with every Linux box, and no key of any kind.
The specification that defines the encoding has said so since 2006. RFC 4648, §12, Security Considerations: "Base encoding visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality. This has been known to cause security incidents when, e.g., a user reports details of a network protocol exchange (perhaps to illustrate some other problem) and accidentally reveals the password because she is unaware that the base encoding does not protect the password." Not a hypothetical. A description of the support ticket above, written twenty years in advance.
Two halves follow. First that misconception, settled against the RFCs and demonstrated in a disposable container on 25 July 2026 — every line reproduced here is exactly as captured, and elisions are marked with …. Then a quieter confusion that probably costs you more hours a year: percent-encoding versus form-encoding, and why + means a space in a submitted form but a literal plus sign in a URL path.
base64 -d recovered our test string byte-for-byte — no key, no privilege, no tool to installOne command, no key, no privilege
We ran this in a throwaway debian:bookworm-slim container with the shell's trace mode on, so every command echoes with a + prefix before it runs. Nothing was installed — base64 is part of GNU coreutils and was already there.
…
+ cat /etc/debian_version
12.15
+ base64 --version
+ head -1
base64 (GNU coreutils) 9.1
+ date -u
Sat Jul 25 10:22:53 UTC 2026
+ printf transfer 500000 to account 88123456
+ cat secret.txt
transfer 500000 to account 88123456+ base64 secret.txt
dHJhbnNmZXIgNTAwMDAwIHRvIGFjY291bnQgODgxMjM0NTY=
+ base64 secret.txt
+ base64 -d secret.b64
transfer 500000 to account 88123456+ + cmp - secret.txt
base64 -d secret.b64
+ echo ROUND_TRIP_IDENTICAL
ROUND_TRIP_IDENTICAL
+ + base64 -d
printf YXR0YWNrIGF0IGRhd24=
attack at dawnThe interleaving in the middle is the trace stream and the program output landing on the same file descriptor; we left it as captured. cmp exited silently, meaning the decoded bytes were identical to the original. There was nothing to guess, brute-force or crack, because base64 is a representation, published in full in a public document with a table.
RFC 4648 goes further than "it isn't encryption". Base64 makes an attacker's job marginally easier: "Base encoding adds no entropy to the plaintext, but it does increase the amount of plaintext available and provide a signature for cryptanalysis in the form of a characteristic probability distribution." It is not neutral. It is very slightly worse than nothing.
A Standards Track RFC calls it cleartext
HTTP Basic authentication is the clearest case in the standards corpus of an encoding being explicitly defined and then immediately described as insecure. RFC 7617's abstract states the construction: the scheme "transmits credentials as user-id/password pairs, encoded using Base64." Then §4 describes what it just specified — "The most serious flaw of Basic authentication is that it results in the cleartext transmission of the user's password over the physical network." Encoded, and cleartext, in the same document. The RFC is not being sloppy; it is being precise about what encoding does not change.
§2.1 supplies a worked example — user test, password 123 followed by a pound sign — giving the credential as dGVzdDoxMjPCow==. We fed the IETF's own string to a decoder:
…
+ echo RFC 7617 s2.1 gives this worked example of Basic credentials for user 'test', password '123' + pound sign:
+ printf dGVzdDoxMjPCow==
RFC 7617 s2.1 gives this worked example of Basic credentials for user 'test', password '123' + pound sign:
+ base64 -d
+ od -c
0000000 t e s t : 1 2 3 302 243
0000012
+ printf dGVzdDoxMjPCow==
+ base64 -d
test:123£+ echo
+ echo --- and the header as a browser actually sends it ---
+ printf Authorization: Basic dGVzdDoxMjPCow==
+ echo
+ echo --- reversing it needs no key, no tool, no privilege ---
--- and the header as a browser actually sends it ---
Authorization: Basic dGVzdDoxMjPCow==
--- reversing it needs no key, no tool, no privilege ---
+ echo Authorization: Basic dGVzdDoxMjPCow==
+ + sed s/^.*Basic //
base64 -d
test:123£+ echo
…The od -c line shows what's happening: 302 243 is the two-byte UTF-8 sequence for the pound sign, in octal. Basic auth carries raw octets and base64 carries them faithfully, non-ASCII included. One sed and one base64 -d take a full header line back to the password. We wanted a real cipher beside it and could not have one — openssl is absent from debian:bookworm-slim, which the transcript records rather than papers over. The point survives the omission. Recovering plaintext from a cipher needs a key; recovering it from base64 needs a lookup table anyone can download.
What base64 is actually for, and what it costs
Base64 exists to move arbitrary bytes through channels that only tolerate printable text. RFC 2045, MIME Part One, §6.8: "The encoding and decoding algorithms are simple, but the encoded data are consistently only about 33 percent larger than the unencoded data." The same section gives the mechanism — "A 65-character subset of US-ASCII is used, enabling 6 bits to be represented per printable character." Six bits per character against eight bits per byte: three bytes become four characters, and 4 ÷ 3 is where the third comes from. "About" is the RFC's own hedge, and it does real work. We measured it on a repo asset, bind-mounted read-only:
…
+ ls -l /data/recatools-og.png
-rw-r--r-- 1 root root 76766 Jul 1 14:49 /data/recatools-og.png
…
+ echo RAW_BYTES=76766 B64_BYTES=102356 DELTA=25590
RAW_BYTES=76766 B64_BYTES=102356 DELTA=25590
…
+ awk {printf "ratio=%.6f overhead=%.4f%% predicted_b64=%d\n", $2/$1, ($2-$1)*100/$1, 4*int(($1+2)/3)}
ratio=1.333351 overhead=33.3351% predicted_b64=102356
…
+ base64 -d /tmp/og.b64
+ cmp - /data/recatools-og.png
+ echo IMAGE_ROUND_TRIP_IDENTICAL
IMAGE_ROUND_TRIP_IDENTICAL
…
--- MIME 76-col wrapping (RFC 2045) adds line breaks ---
…
+ base64 /data/recatools-og.png
+ wc -l
1347
+ base64 /data/recatools-og.png
+ wc -c
103703
…76,766 bytes in, 102,356 out — +33.3351%, and the image round-trips byte-identically. Then the measurement people forget: MIME wraps base64 at 76 columns, and line breaks are bytes too. The same PNG becomes 103,703 bytes across 1,347 lines, +35.09%. RFC 4648 §3.1 explains where the 76 comes from and disowns it in the same breath — "MIME does not define 'base 64' per se, but rather a 'base 64 Content-Transfer-Encoding' for use within MIME. As such, MIME enforces a limit on line length of base 64-encoded data to 76 characters." For everyone else: "Implementations MUST NOT add line feeds to base-encoded data unless the specification referring to this document explicitly directs base encoders to add line feeds after a specific number of characters."
Small inputs behave worse, which matters if you inline icons as data URIs. Output length is always a multiple of four — here are the five result lines from the same session, lifted out of the interleaved trace that surrounds them:
input='a' bytes=1 b64=YQ==
input='ab' bytes=2 b64=YWI=
input='abc' bytes=3 b64=YWJj
input='abcd' bytes=4 b64=YWJjZA==
input='abcde' bytes=5 b64=YWJjZGU=
One byte becomes four characters: +300%. Two bytes also become four, +100%. Three become four, and only there do you get the clean +33.3%. Four bytes need eight, +100% again; five need eight, +60%. The ratio sawtooths downward and settles near a third only once the input is large.
base64url changes the alphabet, not the security
Standard base64's alphabet ends in + and /, both of which mean something in a URL. RFC 4648 §5 defines a second alphabet that swaps them, and is exact about what changes: "This encoding is technically identical to the previous one, except for the 62:nd and 63:rd alphabet character, as indicated in Table 2." The only difference is those two characters. Here it is on bytes chosen to produce nothing but + and /:
…
+ printf \373\377\276\373\377\277
+ od -An -tx1 bin.raw
fb ff be fb ff bf
+ base64 -w0 bin.raw
+/+++/+/+ echo
+ + tr +/base64 -_
-w0 bin.raw
-_---_-_
…
+ echo urlsafe=-_---_-_ len=8
+ printf %s -_---_-_
+ tr \-_ +/
+ od -An -tx1
+ base64 -d
urlsafe=-_---_-_ len=8
fb ff be fb ff bf
…Same six bytes out. A different alphabet is a transport convenience, not a security property: the JWT header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 from the same session decodes to {"alg":"HS256","typ":"JWT"} with no key, which is exactly why a JWT payload is no place for anything private.
Padding is where almost everyone, us included, gets the citation wrong. Dropping the trailing = is not part of §5. It is §3.2: "Implementations MUST include appropriate pad characters at the end of encoded data unless the specification referring to this document explicitly states otherwise." JWS states otherwise, and RFC 7515 §2 attributes it properly — base64url there is the character set "defined in Section 5 of RFC 4648 [RFC4648], with all trailing '=' characters omitted (as permitted by Section 3.2)". Alphabet from §5, padding from §3.2. Our Base64 encoder and decoder now says so too, and restores the padding before decoding an unpadded segment.
Reserved, unreserved, and the myth of encoding everything
Percent-encoding is a different mechanism for a different job. RFC 3986 §2.1 states it in one sentence: "A percent-encoding mechanism is used to represent a data octet in a component when that octet's corresponding character is outside the allowed set or is being used as a delimiter of, or within, the component." The syntax is fixed at "pct-encoded = '%' HEXDIG HEXDIG", and the worked example is the one everybody has seen: "'%20' is the percent-encoding for the binary octet '00100000' (ABNF: %x20), which in US-ASCII corresponds to the space character (SP)."
The 20 in %20 is hexadecimal, and it is the smallest complete illustration of why base conversion belongs here. Hex 20 is decimal 32, and 32 is the ASCII code for a space. Percent-encoding is not a cipher either; it is a base-16 spelling of one byte, wrapped in a delimiter. Our number base converter shows that byte as 100000 in binary, 40 in octal, 32 in decimal and 20 in hex at once.
Two sets govern the rest. §2.2 lists the reserved characters — "gen-delims = ':' / '/' / '?' / '#' / '[' / ']' / '@'" and "sub-delims = '!' / '$' / '&' / ''' / '(' / ')' / '*' / '+' / ',' / ';' / '='" — and §2.3 the complement: "unreserved = ALPHA / DIGIT / '-' / '.' / '_' / '~'".
The RFC does not say that everything outside the unreserved set must be encoded. §2.2 is conditional: "If data for a URI component would conflict with a reserved character's purpose as a delimiter, then the conflicting data must be percent-encoded before the URI is formed." A reserved character doing its delimiting job stays as it is, and over-encoding is not free caution — the same section warns that "URIs that differ in the replacement of a reserved character with its corresponding percent-encoded octet are not equivalent." Encode a delimiter and you have changed what the URI means. For the unreserved set, §2.3 names the exact ranges, tilde at %7E among them, that "should not be created by URI producers and, when found in a URI, should be decoded to their corresponding unreserved characters by URI normalizers."
Our URL encoder and decoder runs two native encoders and nothing legacy: encodeURI() for a whole URL, preserving structure, and encodeURIComponent() for a single value, encoding the delimiters. Component mode turns a space into %20, & into %26, # into %23, @ into %40 and the rest of the gen-delims into triplets. It leaves ! and ~ alone, correctly.
The plus sign that means space in one place and a plus in the other
Nothing in RFC 3986 gives + any special relationship to a space. That rule belongs to a different serialisation: application/x-www-form-urlencoded, the format browsers use to submit a form. The WHATWG URL Standard, where that format is specified, isolates the behaviour in one boolean — "Let spaceAsPlus be true if percentEncodeSet is application/x-www-form-urlencoded percent-encode set; otherwise false", then "If spaceAsPlus is true and byte is 0x20 (SP), then append U+002B (+) to output and continue." The parser does the reverse as its first step: "Replace any 0x2B (+) in name and value with 0x20 (SP)."
The two encode sets differ by a short, specific list. The component set, per the same standard, "gives identical results to JavaScript's encodeURIComponent()"; the form set is that set "and U+0021 (!), U+0027 (') to U+0029 RIGHT PARENTHESIS, inclusive, and U+007E (~)". Run all three encoders over the same characters and the delta is visible:
| Character | encodeURI (Full URL) | encodeURIComponent (Component) | URLSearchParams (form data) |
|---|---|---|---|
| space | %20 | %20 | + |
| - | - | - | - |
| _ | _ | _ | _ |
| . | . | . | . |
| * | * | * | * |
| ~ | ~ | ~ | %7E |
| ! | ! | ! | %21 |
| ' | ' | ' | %27 |
| ( | ( | ( | %28 |
| ) | ) | ) | %29 |
These are not stylistic differences. They explain why a value can survive one round trip and be silently corrupted on another:
--- the divergence, isolated on a single space ---
encodeURIComponent(' ') = %20
new URLSearchParams({q:' '}).toString() = q=+
--- round-trip asymmetry: a form-encoded value read back with decodeURIComponent ---
form-encoded : q=hello+world
decodeURIComponent : hello+world
URLSearchParams.get : hello worldNeither answer is wrong. decodeURIComponent implements RFC 3986, where + is a plus sign; URLSearchParams implements the form-data parser, where it is a space. A tool that quietly picked one would corrupt the other case, so the URL tool now offers Treat + as a space (form data) as a decode option, off by default.
We know this failure from the inside, because we shipped it. Auditing our own Quick Reference table row by row against the two encoders the tool actually runs gave an uncomfortable result: it matched Full URL mode on 1 of 12 rows, Component mode on 10 of 12 — and the WHATWG form-urlencoded serializer on 11 of 12. Somebody had transcribed a form-encoding table onto a percent-encoding tool. The two rows wrong for both of the tool's own modes gave it away: ! → %21 and ~ → %7E, exactly the delta the WHATWG text describes.
Where our own pages stand
All three tools that anchor this guide were repaired earlier today, off the back of the same audit.
The base64 tool encodes through TextEncoder rather than handing a JavaScript string to btoa(), so 新加坡 and emoji encode correctly where bare btoa() raises a DOMException. Its URL-safe input option is now wired to the decoder instead of sitting inert, and a string that is unmistakably base64url — using - or _, never + or / — ticks it for you. One more, stated plainly because a page about encoding versus cryptography has no business getting it wrong: it used to say a JWT's "signature cannot be verified without the issuer's private key." Verification uses the public key; the private key signs. Fixed.
The URL tool's Quick Reference is now labelled Component mode and describes it accurately. The base converter no longer swallows the minus sign: a negative renders with its sign in every base, and the signed toggle switches all three non-decimal fields to the two's complement pattern a CPU would store — -5 becoming 11111011 in eight bits.
It printed the payload, then rejected it
One last result undercuts a comforting assumption. RFC 4648 §3.3 is strict about malformed input: "Implementations MUST reject the encoded data if it contains characters outside the base alphabet when interpreting base-encoded data, unless the specification referring to this document explicitly states otherwise." We fed a strict decoder an unpadded base64url segment — legal in a JWT, illegal as plain base64 — and watched the order of events. The payload inside it is a demonstration token we invented for the run; the identifier it carries is synthetic and belongs to nobody. Note what appears before the error:
--- strict GNU decoder on the UNPADDED base64url segment ---
+ printf+ tr \-_ +/
+ %s eyJzdWIiOiJTMTIzNDU2N0QiLCJyb2xlIjoiYWRtaW4ifQ
base64 -d
{"sub":"S1234567D","role":"admin"}base64: invalid input
exit=1It printed the entire payload, then failed with exit status 1. The rejection was real, but the disclosure had already happened. A validator that runs after the bytes reach your terminal is not a confidentiality control, and neither is an encoding a public table can undo. RFC 4648 §12 makes the converse point about lenient decoders — "If non-alphabet characters are ignored, instead of causing rejection of the entire encoding (as recommended), a covert channel that can be used to 'leak' information is made possible." Strict or lenient, the decoder is not protecting anything.
FAQ
Should I percent-encode every character that isn't alphanumeric?
No. RFC 3986 §2.2 requires encoding only where data would conflict with a reserved character's role as a delimiter, and warns that a URI differing in the encoding of a reserved character is "not equivalent" to the original. §2.3 goes further for the unreserved set, saying producers should not create percent-encoded forms of A–Z a–z 0–9 - . _ ~ at all.
My query parameter arrived with plus signs instead of spaces. What broke?
Something on the path decoded with a URI decoder rather than a form-data parser, or the reverse. The form-urlencoded parser replaces 0x2B with a space as its first step; decodeURIComponent never does. Decide which serialisation the value is in before decoding — in our URL tool, that decision is the Treat + as a space checkbox.
- RFC 4648: Base16, Base32 and Base64 Data Encodings (§3.1, §3.2, §3.3, §5, §12) — IETF (accessed 25 Jul 2026)
- RFC 3986: URI Generic Syntax (§2.1, §2.2, §2.3) — IETF (accessed 25 Jul 2026)
- RFC 2045: MIME Part One (§6.8) — IETF (accessed 25 Jul 2026)
- RFC 7617: The 'Basic' HTTP Authentication Scheme (abstract, §2, §2.1, §4) — IETF (accessed 25 Jul 2026)
- RFC 7515: JSON Web Signature (§2) — IETF (accessed 25 Jul 2026)
- URL Standard (§1.3, §5.1, §5.2) — WHATWG Living Standard (accessed 25 Jul 2026)