Skip to content
AtomicReps
Wire Protocols & Applied Cryptography

Base64 is not encryption, and it is not one-to-one

Lesson 1 of 24

Base64 is not encryption, and it is not one-to-one

A lesson from Wire Protocols & Applied Cryptography. Play it above, or read it through below.

Level I · Encoding, Transport and Name Resolution
  • Two different base64 strings can decode to one byte.

    The common read: Two base64 strings that differ character by character are two different values, so comparing the encoded form is comparing the bytes with extra steps.

    Base64 is not one-to-one, and the spec has said so since it was written. The last group of a base64 string carries bits that belong to no byte, and RFC 4648 puts the obligation on the encoder rather than on the decoder: conforming encoders MUST set those bits to zero, and decoders only MAY choose to reject them, which is why a decoder in its default mode accepts both spellings and returns one byte.

    The browser's rule is not an implementation quirk either; it is written into the platform's own algorithm, which states explicitly that YQ and YR both return a.

    Whitespace tolerance is the second part of the same problem, and Node and the browser agree on it: RFC 4648 says implementations MUST reject encoded data containing characters outside the base alphabet unless the specification referring to this document explicitly states otherwise, and referring specifications do exactly that, so ASCII whitespace inside the string is removed before any decoding happens, by Node's Buffer because its documentation says so and by the browser because step ONE of the algorithm atob runs is to remove it.

    A string is therefore not a value; it is one of several legal spellings of a value, and which spellings decode to the same bytes was decided by some referring document rather than by base64. For example, a customer sees a duplicate charge on one invoice and files it against the payment provider, the on-call engineer re-files it against the retry logic, and the service's idempotency guard, a Set of encoded payloads, is doing what its tests say it does.

    Decode first and compare bytes, and where the encoded form has to be the key, turn on strictness explicitly: lastChunkHandling: "strict" rejects the spelling the default silently accepts, and it is an opt-in, and the loose default accepts the same spellings atob does.

  • Base64 takes no key, so it protects nothing.

    The common read: Base64 is weak protection and AES is strong protection, so encoding a secret gives me a little protection and encrypting it gives me a lot.

    Base64 gives no protection at all, not a weak amount of it. Base64 is a published map from three bytes onto four characters of a sixty-four character ASCII subset, and the document that defines it says in its own security section that base encoding visually hides otherwise easily recognized information, such as passwords, but does not provide any computational confidentiality.

    Nothing is hidden from whoever reads the encoded form, because no secret was ever supplied: the call takes one argument, the map is printed in the spec, and the inverse is a second call of the same shape. The IETF's security glossary goes further and addresses the vocabulary, listing "synonym for encrypt" as a deprecated definition of "encode" and stating its reason, that encoding is not always meant to conceal meaning.

    A reviewer should therefore ask which property the field needs, not which transform they recognise, because a recognised transform such as base64 often delivers none of the properties the field needs.

    HTTP Basic shows the same fact inside a protocol: the scheme puts base64 of the user name and password on the wire, and its RFC says the Basic authentication scheme is not a secure method of user authentication, nor does it in any way protect the entity, which is transmitted in cleartext across the physical network used as the carrier.

    For example, an engineer who reads the value as scrambled pastes a whole Authorization: Basic header into a support thread about a customer's login problem; the ticket is closed the same afternoon, and a live service credential stays in a ticket system with a different retention policy from the secret manager where it belongs.

    Check the function signature before the output: a transform with no secret input has no secret output, and a base64 string in a config file is a plaintext credential in that file.

  • Four properties need four different tools.

    The common read: Hashing is encryption I cannot undo, so SHA-256 is how I protect a field whose plaintext nobody needs to read again.

    A hash is not a cipher with the key removed. It runs in ONE direction: there is no matching call anywhere in the library and no key that would produce one, so if your feature needs the original back, a hash breaks that feature.

    The full list is short: confidentiality comes from encryption, integrity from a hash or, once an attacker can rewrite both the value and its digest, from a keyed MAC; authenticity comes from a MAC or a signature: a valid MAC shows that someone holding the shared key wrote the value, and a valid signature lets anyone with the public key check which private key signed it, which also gives non-repudiation, the one property a MAC cannot give; and wire safety, the ability of arbitrary bytes to pass through a text channel unchanged, comes from an encoding.

    Compression is none of the four. This lesson raises compression twice, once here and once later, where compression turns out to have a security cost of its own. In a review, you apply this list by asking two questions about the data rather than about the library: must the original be recoverable, and if it must, does it have to stay secret while it is stored.

    The two answers choose the primitive. For example, a support escalation asks the platform team to decrypt a customer's password reset token, because the column displays as long hex, and the ticket is closed by mailing a fresh token, since that column holds digests that cannot be reversed. The engineer who designed the column was right, and the ticket was still opened, which is the cost of naming a digest an encrypted value in a schema comment.

    Name the property in the field name and in the review comment, not the transform, and accept the cost: the naming discipline gives no benefit on the day you adopt it, and it is the only thing that prevents this ticket a year later.

  • The alphabet is a transport decision. Padding is set by the referring specification.

    The common read: Base64 is base64, so a token that round-trips through my own decoder round-trips through every decoder in the stack.

    There are two alphabets, and the choice between them is made by the channel rather than by the data. Two characters of the standard alphabet have special meaning in URLs and filenames, so RFC 4648 § 5 respells index 62 from + to - and index 63 from / to _, and the result is the same bytes with a different transport contract.

    A decoder that reads only one alphabet rejects the other. Which of the two a given decoder reads is the same kind of referring-specification decision that governs whitespace, and it differs between two decoders in one stack: Node's 'base64' accepts the URL-safe alphabet as well as the standard one because its documentation says it does, and the browser's atob accepts only +, / and ASCII alphanumerics, so a - or a _ is a character outside its alphabet and atob throws.

    Padding is also decided by the referring specification: RFC 4648 requires the = characters unless a specification referring to it says otherwise, and JOSE is such a specification, defining its own encoding as base64url with all trailing = characters omitted, as permitted by Section 3.2. So a JWT segment is short by design, not corrupt, and the correct response to a decode failure is to name the alphabet at the boundary rather than to append padding until the decoder stops throwing.

    A related bug produces no decoder error at all: a standard-alphabet blob placed in a query string is read back with every + turned into a space, because form-urlencoded parsing reads + as a space, and a blob concatenated into the URL or set through url.search keeps its + unescaped (only URLSearchParams writes it as %2B), so nothing warns and only the payloads whose bytes happen to produce index 62 are damaged.

    For example, a user reports that a share link fails, a colleague replies that it works for them, and the ticket is filed against the link shortener; the link fails only for tokens whose encoding contains a +, so it passes every test written against a fixed fixture.

    Choose the alphabet at the point where the bytes enter the channel: toBase64({ alphabet: "base64url", omitPadding: true }) going out and the matching alphabet option coming back is the complete fix, and any line in your codebase that appends = in a loop is a workaround for not making that choice.

  • A signature says who wrote it. It hides nothing.

    The common read: The token is signed, so the claims inside it are protected in the same act.

    Signing and concealing are two different operations and a JWS performs exactly one of them. The claims in a signed JWT are base64url of JSON, which is the URL-safe encoding named earlier in this lesson, so anyone holding the token reads every claim with two calls and no key, and the signature beside those claims answers a different question: who produced this, and has it changed since.

    RFC 7519's privacy considerations treat readable claims as the intended design rather than an edge case, and prescribe the remedy in the same passage, saying that measures must be taken where the claims contain sensitive information and that one way to achieve this is to use an encrypted JWT and authenticate the recipient.

    That remedy is a DIFFERENT construction rather than a stronger setting on the one you have: a JWE encrypts, a JWS signs, and no algorithm choice inside the signing family moves a token from one to the other.

    The failure produces no error because the token works: it validates, it authorises, it expires on schedule, and it carries an email address, an internal user id and a role name to every browser tab and every log line and every screenshot that ever touches it.

    The problem is usually found in a privacy review rather than reported as a bug, and the finding is filed against the logging pipeline for capturing an Authorization header, although the cause was a decision two years earlier by whoever added a convenient claim to save a lookup. Decide what a token is allowed to carry before you decide how it is signed.

    If a claim must stay private, you have two options, and both have a cost. An opaque identifier plus a server-side lookup costs a round trip on every request. A JWE requires the server that reads the token to hold a decryption key and to decrypt every token it receives. With either option, a person who holds the token cannot read the claims.

  • Compress, then encrypt, and the length reveals the content.

    The common read: Compression is a size decision and encryption is a security decision, so the order I run them in is a performance question.

    Compression is not one of the four properties and it still affects them. A compressor's output length depends on the CONTENT of its input and not only on the length, so a ciphertext computed over compressed plaintext carries information about that plaintext in its length, which no cipher hides, and an attacker who can add a guess to the input and watch the size can test guesses one at a time.

    The JWT best current practice states the rule directly: compression of data SHOULD NOT be done before encryption, because such compressed data often reveals information about the plaintext. JOSE ships the dangerous ordering as a header parameter rather than as a mistake you have to hand-write, since JWE's zip applies its compression to the plaintext before encryption, which means one key in a JSON object turns on the vulnerable composition in a library you already use.

    Compress-then-encrypt is a named attack class: CRIME allows an active attacker to decrypt ciphertext, specifically cookies, when TLS is used with TLS-level compression, and TIME and BREACH both make similar use of HTTP-level compression to decrypt secret data passed in the HTTP response. TLS 1.3's own summary of what it changed from TLS 1.2 lists the removal of compression, which is a protocol version deleting a feature rather than documenting a caveat about it.

    For example, a performance ticket against the gateway asks to enable response compression on an endpoint to reduce mobile latency, the benchmark numbers get it approved, and the CSRF token in that response body is now measurable by anyone who can make the browser send requests whose guessed prefix the endpoint echoes into the same body.

    Default to encrypt-only and compress nothing an attacker can influence, and accept the cost: you will ship a larger payload and lose an easy latency improvement, and in return the size of your ciphertext no longer reveals its content.

  • An ML-DSA private key in a JWK is 43 characters.

    The common read: Key material is long, so a 43-character base64url run is an identifier and the long run beside it is the thing I have to protect.

    Length was never a property of secrets. A P-256 or Ed25519 private key in a JWK has been 43 characters since JOSE defined those key types, and RFC 9964, published in May 2026, gives ML-DSA the same length. It registers a key type for these algorithms, AKP, and rules that the priv parameter MUST be the seed and MUST have a length of 32 bytes.

    AKP key parameters in a JWK are base64url encoded, so the entire private key arrives as 43 characters. A 43-character string is the length of an ordinary identifier: a UUID request id is 36 characters, and a session token made from 32 random bytes is exactly 43. At that length, a reviewer scanning a diff or a paste has no reason to think it is key material.

    The obvious objection is that a seed is an input rather than a key, and the RFC answers it in its own security considerations: the seed and the private key expanded from the seed require the same level of protection. Meanwhile the public half got longer, so the string a reviewer notices first in that JWK is the half you are supposed to publish.

    A length habit formed on RSA keys, where the private parameter was as long as the public one, now flags only the public half. Nothing reports this failure: no scanner alerts, no review comment flags it, and no ticket is filed while a JWK stays in a support thread or a debugging gist with the signing key inside it, and the first evidence anybody gets is a token that verifies and should not.

    Judge a base64 string by its SOURCE, not its length: the only reliable statement about a base64url run is which parameter of which document it came out of, and a length rule, a quick visual check, and experience with PEM blocks are no longer evidence.