Guide Cybersecurity 5 min read

Sixteen different base64 strings decode to the same byte

Base64 leaves four bits free when it encodes a single byte, and RFC 4648 binds encoders with MUST while granting decoders only a MAY. So sixteen spellings of one byte all decode identically in Node, Python and PHP — including under validate=True and strict — while the base64 command rejects fifteen of them.

Kenji Tanaka
Developer Tools & Cloud Analyst
Published 12 Sep 2026, 5:51 PM (SGT)
Share:
Coloured source code on a dark editor screen, photographed at an angle Coloured source code on a dark editor screen, photographed at an angle Photo by fancycrave1 on Pixabay
Advertisement

These sixteen strings are all valid base64, and every one of them decodes to the single byte 0x41 — the letter A:

QQ==  QR==  QS==  QT==  QU==  QV==  QW==  QX==
QY==  QZ==  Qa==  Qb==  Qc==  Qd==  Qe==  Qf==

Node accepts all sixteen. So does Python, including with validate=True. So does PHP, including with strict set. The base64 command on this machine rejects fifteen of them.

Why there are sixteen

Base64 packs three bytes into four characters of six bits each. One byte is eight bits, which does not divide into six, so the encoder emits two characters — twelve bits — and pads. Four of those twelve bits carry nothing.

RFC 4648 tells encoders what to do with them, and tells decoders something weaker:

"These pad bits MUST be set to zero by conforming encoders … If this property do not hold, there is no canonical representation of base-encoded data, and multiple base-encoded strings can be decoded to the same binary data."

"In some environments … decoders MAY chose to reject an encoding if the pad bits have not been set to zero."

⚠️ The asymmetry is the point: a MUST for encoders, but only a MAY for decoders. The number of possible spellings falls out of the arithmetic of the padding. A one-byte payload leaves four bits free (2⁴ = 16 spellings), a two-byte payload leaves two bits free (2² = 4), and a three-byte payload divides evenly, with only one.

The specification's security section names the consequence: these spare bits can be used to bypass string equality checks.

What the runtimes do

RuntimeQQ==QR==
NodeAA
Python, defaultb'A'b'A'
Python, validate=Trueb'A'b'A'
PHP, strict"A""A"
base64 -d (BSD)Arejects

The two "strict" flags are the interesting rows. Both are documented to reject characters outside the alphabet, and both do exactly that. Neither claims to check the pad bits, and neither does. The word "strict" is carrying an expectation the library never took on.

Where it bites

Anywhere a base64 string is compared to another base64 string instead of being decoded first.

Consider an HTTP Authorization: Basic header, which is base64 of user:password. A blocklist of compromised credentials will match the canonical encoding and miss the fifteen alternatives. All sixteen authenticate, because the server decodes them to the same byte string before comparing.

The same shape appears in cache keys, in deduplication by encoded value, in webhook signature comparison, and in any audit log that records "the token we saw" as text. Two records that differ as strings can be the same secret.

Advertisement

⚠️ This does not weaken a password or make it easier to guess. It defeats only the assumption that a given payload has one base64 spelling — the assumption string comparison silently relies on.

What this does not mean

RFC 4648 has not made a mistake. A decoder that hard-rejected non-zero pad bits would break every non-conforming encoder already on the wire, and the document discloses the limitation twice — once where it creates it and once in the security considerations. The MAY is a deliberate choice about interoperability, made with the consequence written down.

Nor are the libraries misbehaving. Python's validate and PHP's strict both do what their documentation says. The gap is between what those words promise and what a reader hears in them.

⚠️ The command-line tool's rejection is not evidence that the runtimes are wrong. It is the MAY being exercised in the other direction, on the same machine and the same input. This is what a permission in a specification looks like in practice: different implementations making different choices.

What to do with it

Never compare base64 strings to decide whether two payloads are the same. Decode first and compare bytes.

If you must store a canonical text form for a cache key, an index or a blocklist, re-encode the value on the way in:

canonical = encode(decode(s))
if (canonical !== s) { /* non-canonical spelling */ }

That round trip is the check none of the "strict" flags performs, and it is four lines.

When diffing logs or checking user-supplied values, remember that two different base64 strings can represent the same bytes. A textual mismatch proves nothing; only a match is conclusive.

Where this comes from, and what will date it

Both quotations are from RFC 4648 itself, read from its canonical text at 35,491 bytes — the first from §3.5, which creates the situation, and the second from the same section, which permits the escape. The security observation is from §12.

The sixteen spellings were generated by enumerating the alphabet and keeping every string whose decode is the single byte 0x41, and the runtime behaviour was executed here rather than cited: Node 26.4.0, Python 3.9.6 and PHP 8.4.24, plus the BSD base64 on the same machine. The counts 16, 4 and 1 for one-, two- and three-byte payloads follow from 2⁴, 2² and 2⁰.

⚠️ Two limits. We tested on Python 3.9, which is what this machine has. Python 3.11 adds a strict_mode to a different function, and we did not test whether it addresses the padding. And the Authorization example above is a description of a mechanism, not something we observed in any deployed system — no real credential was involved, and we are not claiming any particular service is vulnerable.

This finding is stable. RFC 4648 will not change, the arithmetic is fixed, and any runtime altering this behaviour would be making a breaking change it would have to announce.

Advertisement
Kenji Tanaka
Developer Tools & Cloud Analyst

Kenji Tanaka covers developer tools, cloud platforms, DevOps, CI/CD, and software supply-chain topics for RECATOOLS.

View author profile → · Editorial policy

About this byline Kenji Tanaka is a RECATOOLS editorial persona for developer tools, cloud, DevOps, and software supply-chain coverage. Articles are produced and reviewed under RECATOOLS editorial supervision.

Corrections policy

Advertisement