Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python Base64 Decoding Accepts Junk: Enforce an Explicit Input Contract

Reject malformed Base64 text with explicit strict validation, a padded standard-alphabet contract, and canonical round-trip checks on Python 3.11–3.14.

By ON Lab5 min readSources checked November 10, 2026 (KST)

A decoded byte string does not prove that the original text matched your data format. If an API field promises one canonical, padded, standard-alphabet Base64 value, validate that promise before handing the bytes to an image parser or storing them. For Python 3.11–3.14, pass validate=True explicitly and compare a fresh encoding with the supplied text when canonical spelling matters. Do not repair every decoding failure by appending padding.

First separate the transport from the encoding

Keep the exact received text long enough to diagnose the boundary. A value beginning with data:image/png;base64, contains a data URL wrapper. A field using - and _ may use the URL-safe alphabet. A query parameter containing + may have been changed into a space during form-style parsing. These are different input contracts; removing punctuation until decoding succeeds can conceal which one failed.

For the query-parameter case, see Base64 plus signs in URLSearchParams. This guide starts after transport parsing and accepts only a Python string containing a standard Base64 value. It does not parse a URL, email attachment, JWT, or multipart upload.

Observe strict decoding on the installed interpreter

This fixture runs entirely in memory and uses deliberately tiny, invented values. Run it in your actual application interpreter. Record the full Python version because decoder behavior can change in maintenance releases as well as major versions.

import base64
import binascii
import sys

print(sys.version.split()[0])
for sample in ("YQ==", "Y!Q==", "YQ==\n", "YQ==Yg==", "YR=="):
    results = []
    for strict in (False, True):
        try:
            value = base64.b64decode(sample, validate=strict)
            results.append(repr(value))
        except binascii.Error:
            results.append("binascii.Error")
    print(repr(sample), results)

assert base64.b64decode("YQ==", validate=True) == b"a"
try:
    base64.b64decode("Y!Q==", validate=True)
except binascii.Error:
    pass
else:
    raise AssertionError("junk unexpectedly accepted")

Read each pair as explicit lenient mode followed by explicit strict mode. On the installed Python 3.11.6, 3.13.2, and 3.14.3 runtimes checked for this guide, the punctuation and trailing newline examples decoded in lenient mode and failed in strict mode. The trailing second payload was ignored in lenient mode on these installations. The noncanonical YR== decoded to the same byte as YQ== even with validation enabled. Those are observations of these exact runtimes, not promises about every patched release.

Python 3.14 documentation describes validate=True as rejecting characters outside the chosen alphabet. The separate binascii strict-mode documentation also identifies malformed padding boundaries. Neither statement means that successfully decoded bytes are a valid application object.

Define one canonical padded standard value

The following function intentionally implements a narrower contract than a general-purpose Base64 decoder. It accepts a string, caps its encoded length before decoding, requires the standard alphabet and complete padded groups, rejects empty input by policy, then checks canonical spelling by re-encoding. The length cap of 4,096 characters is an example field limit; choose a limit that matches your own field rather than using it for arbitrary uploads.

import base64
import binascii
import re

STANDARD_PADDED = re.compile(
    r"(?:[A-Za-z0-9+/]{4})*"
    r"(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?"
)

def decode_field(text: str, max_chars: int = 4096) -> bytes:
    if not isinstance(text, str):
        raise TypeError("expected Base64 text")
    if not text or len(text) > max_chars:
        raise ValueError("empty or oversized Base64 field")
    if STANDARD_PADDED.fullmatch(text) is None:
        raise ValueError("expected padded standard Base64")
    try:
        decoded = base64.b64decode(text, validate=True)
    except binascii.Error as exc:
        raise ValueError("invalid Base64 field") from exc
    if base64.b64encode(decoded).decode("ascii") != text:
        raise ValueError("noncanonical Base64 field")
    return decoded

for text, expected in (
    ("YQ==", b"a"), ("YWI=", b"ab"), ("YWJj", b"abc"),
    ("+/8=", b"\xfb\xff"),
):
    assert decode_field(text) == expected

invalid = (
    "", "YQ", "YQ===", "Y!Q==", "YQ==\n", "YQ==Yg==",
    "YR==", "-_8=", "data:text/plain;base64,YQ==", "éQ==",
)
for text in invalid:
    try:
        decode_field(text)
    except ValueError:
        pass
    else:
        raise AssertionError(repr(text))

try:
    decode_field("YQ==", max_chars=3)
except ValueError:
    pass
else:
    raise AssertionError("length limit bypassed")
print("4 valid cases; 10 invalid cases; length limit passed")

The expression allows complete groups with no equals sign when padding is unnecessary, as in YWJj. It permits the final one or two equals signs only in their expected positions. It also excludes whitespace and the URL-safe alternatives. The re-encoding comparison catches the deliberately noncanonical test case without relying on a newly introduced decoder option.

RFC 4648 distinguishes alphabet, padding, and canonical pad bits. Here the rejection policy is explicit so callers do not confuse transport tolerance with format validation. Re-encoding verifies a spelling rule; it adds no authenticity or confidentiality.

Handle legitimate format differences deliberately

If your producer specifies URL-safe unpadded Base64, this function is the wrong contract. Implement and test that format separately, including its allowed alphabet and omitted-padding rule. Do not call a standard decoder on a whole data URL or silently try multiple alphabets until one works. If the protocol permits MIME line wrapping, use a MIME-aware path instead of stripping all whitespace at the generic API boundary.

Adding equals signs is appropriate only when the producer contract explicitly omits padding and the original data is otherwise valid. A truncated value may still decode after that operation, producing incomplete bytes without recovering the missing content. Ask for the producer output and expected decoded length when padding errors first appear. Use synthetic examples in shared diagnostics rather than exposing live credentials or customer payloads.

After decoding, validate the actual content separately. An image field still needs its intended image-format and size checks. A text field needs a documented text encoding. Base64 syntax validation cannot establish who created the bytes, whether they were modified, or whether the application should trust them.

Check version changes without weakening the contract

The CPython report linked below documents trailing data after padding and subsequent fixes, including backports. Its historical output is not evidence that every current Python 3.13 or 3.14 build still truncates that input. This guide tested the locally installed versions named above and makes lenient mode diagnostic only. Python 3.15 has a changed decoder interface; review that version's official documentation before adopting additional options. The canonical field function above uses explicit validation and its own format check rather than an implicit default.

Run both complete fixtures after changing your interpreter or producer. Verify the positive values round-trip, all negative values fail, and the encoded-size policy matches your field. If an input is rejected, identify its format and transport first. Change the accepted contract intentionally instead of turning a decoder exception into unconditional cleanup.

Sources & further reading