Skip to content
AtomicReps

Base64 is not a lock

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

  • Base64 takes no key. That is the whole argument.

    The common read: Base64 sits at the weak end of the same scale AES sits at the strong end of, so encoding a secret buys me a little protection and encrypting it buys me a lot.

    There is no scale. 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 withheld from whoever reads the encoded form, because nothing 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 past the mechanism and rules on the vocabulary, filing "synonym for encrypt" as a deprecated definition of "encode" and stating its own ground for the ruling, that encoding is not always meant to conceal meaning.

    The question in front of a reviewer is therefore never which transform they know, it is which property the field needs, and those two questions have different answers often enough that the whole category error has a name.

    HTTP Basic is where the confusion got written into a protocol rather than into one team's config: 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.

    It arrives as an Authorization: Basic header pasted whole into a support thread by an engineer who read the value as scrambled, filed against a customer's login problem, closed the same afternoon, and left behind a live service credential in a ticket system with a different retention policy from the secret manager it was supposed to live in.

    Read the signature before you read the output: a transform with no secret input has no secret output, and a base64 blob in a config file is a credential in a config file.

  • Four properties. Four tools. No overlap.

    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 thrown away. It runs in ONE direction: there is no matching call anywhere in the library and no key that would produce one, and that is a property you either want or you have already broken your own feature with.

    The map is small enough to hold whole: confidentiality is bought with encryption, integrity with a hash or, once an attacker can rewrite both the value and its digest, with a keyed MAC; authenticity and non-repudiation are bought with a signature, which is the only one of the four that answers WHO; and wire safety, the ability of arbitrary bytes to survive a text channel, is bought with an encoding.

    Compression is none of the four, which is a fact this lesson spends twice, once here and once at the end where it turns out to have a security cost of its own. The pivot that makes the map usable in review is a question 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.

    Answer those two and the primitive is chosen. That arrives as an escalation asking someone to decrypt the password reset tokens so a customer can be helped, filed against the platform team on the strength of a column that renders as long hex, and closed by mailing a fresh token because there was never anything in that column to turn back.

    The engineer who wrote 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 take the cost honestly: the naming discipline buys nothing on the day you adopt it and is the only thing standing between a reader and this ticket a year later.

  • Two different strings. One identical 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 the default in front of you accepts both spellings and hands back one byte.

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

    Tolerance is the second half of the same problem, and the decoders you have 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 thrown away 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 get folded together was decided by some referring document rather than by base64.

    That arrives as a duplicate charge on one customer's invoice, filed against the payment provider by the customer and then re-filed against the retry logic by the on-call engineer, on a service whose idempotency guard is a Set of encoded payloads and is doing precisely what its tests say it does.

    Decode first and compare bytes, and where the encoded form has to be the key, buy the strictness explicitly: lastChunkHandling: "strict" rejects the spelling the default silently folds, and it is an opt-in because the loose behaviour is the one the whole web already depends on.

  • The alphabet is a transport decision. Padding belongs to whoever referred you here.

    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 are hostile in that context, 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 and no compatibility in either direction.

    Which of the two a given decoder will read is the same referring-specification decision S6 spent on whitespace, and it landed differently in the two decoders sitting 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 it throws.

    Padding is decided one level up again: RFC 4648 requires the = characters unless a specification referring to it says otherwise, and JOSE is exactly 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 bolt padding back on until something stops throwing.

    The version of this bug that never reaches a decoder at all is the quietest: a standard-alphabet blob dropped into a query string arrives with every + turned into a space, because form-urlencoded parsing reads + that way and + is not in the percent-encode set the URL serializer applies, so nothing warns and only the payloads whose bytes happen to produce index 62 are damaged.

    It arrives as "the share link works for me", filed against the link shortener by whoever owns it, on a token that fails for roughly one preview in a handful and passes every test written against a fixed fixture.

    Pick the alphabet where the bytes meet the channel and pay for it once: toBase64({ alphabet: "base64url", omitPadding: true }) going out and the matching alphabet option coming back is the whole fix, and any line in your codebase that appends = in a loop is that fix deferred.

  • 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 encoding S8 named two screens back, so anyone holding the token reads every claim with two calls and no key, and the signature they are holding alongside those claims answers a different question entirely: who produced this, and has it changed since.

    RFC 7519's privacy considerations do not treat that as an edge case to warn about, they treat it as the design and prescribe the remedy in the same breath, 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 is quiet 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.

    It arrives as a privacy review finding rather than a bug, filed against the logging pipeline for capturing an Authorization header, in a service whose actual decision was made 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, and price the alternative honestly: an opaque identifier plus a server-side lookup costs you a round trip on every request, and it is the only version of this where the contents of the token are not a publication.

  • Compress, then encrypt, and the length does the talking.

    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 is not neutral about the other four either. 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 a measurement of that plaintext in a field no cipher covers, and an attacker who can add a guess to the input and watch the size is running a search rather than an analysis.

    The JWT best current practice states the rule flatly: 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 the vulnerable composition is one key in a JSON object away in a library you already trust.

    This is a named attack class with published names attached: 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.

    It arrives as a request to enable response compression on an endpoint for a mobile latency win, filed as a performance ticket against the gateway, approved on the numbers, and the CSRF token in that response body is now measurable by anyone who can make the browser send a request with a guessed prefix.

    Default to encrypt-only and compress nothing an attacker can influence, and take the cost honestly: you will ship a larger payload and lose an easy latency win, and the thing you bought back is that the size of your ciphertext stops being a channel.

  • The key stopped looking like a key. It 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, it was a property of the formats those secrets used to arrive in, and one of those formats changed three months ago. RFC 9964 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, and since AKP key parameters in a JWK are base64url encoded, the entire private key arrives as 43 characters.

    That is shorter than a request id, shorter than most session tokens, and short enough to survive every habit a reviewer has built for spotting key material in a diff or a paste. 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 moved the other way and got long, so the one string in that JWK your eye is drawn to is the half you are supposed to publish. Every habit built on the old shape now points at the wrong parameter.

    It arrives as nothing at all, which is what makes it the last screen of this lesson: no scanner alerts, no review comment, no ticket, and a JWK sits 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.

    Stop reading base64 by shape and read it by SOURCE: the only reliable statement about a base64url run is which parameter of which document it came out of, and this is the beat where a length rule, an eye, and a decade of PEM blocks all stop being evidence at the same time.