Decode a Base64 Image in Python, Node.js and the Browser

2026-09-17 · 955 words

Base64 is standardised, but decoders are not standardised in how forgiving they are. The same string that Python accepts can be silently mangled by Node, and the same string that Node happily decodes will make atob throw. Knowing which runtime does what turns a confusing failure into an expected one.

Python: strict, explicit, and the best choice for validation

Python's base64 module is the most honest of the three. By default it ignores newlines and other characters it does not recognise in the middle of the string; with validate=True it rejects anything outside the alphabet:

import base64, binascii

payload = uri.split(',', 1)[1] if ',' in uri else uri
payload = payload.replace('-', '+').replace('_', '/')       # base64url → standard
payload += '=' * (-len(payload) % 4)                        # restore dropped padding

try:
    raw = base64.b64decode(payload, validate=True)
except binascii.Error as e:
    raise SystemExit(f'not valid base64: {e}')

open('out.png', 'wb').write(raw)
print(len(raw), raw[:8].hex(' '))

Three things in that snippet are worth pointing out. The padding repair happens before validation, so a string that lost its = in transit is accepted. The alphabet conversion happens before padding repair, because the padding calculation uses the length after substitution — the two are the same length, so the order is safe, but doing it the other way round in a language with different assumptions is a common source of off-by-one bugs. And binascii.Error is the exception to catch; base64.b64decode does not raise ValueError.

If you want the strictest possible check, decode and then verify the result:

from PIL import Image
import io
img = Image.open(io.BytesIO(raw))
img.verify()          # raises on a truncated or structurally broken file
print(img.format, img.size)

verify() is the closest thing to answering "is this actually a complete image", which is a different question from "did the Base64 decode". A truncated JPEG decodes perfectly and fails here.

Node.js: lenient to the point of being dangerous

Buffer.from(s, 'base64') does not throw on invalid input. It discards characters it does not recognise and returns whatever it could decode, which means a truncated or polluted string produces bytes with no error at all:

const raw = Buffer.from(payload, 'base64');
console.log(raw.length, raw.subarray(0, 8).toString('hex'));

That is convenient for forgiving input and terrible for validation, because a string that lost the last 20% of its characters still returns a buffer. If you are decoding data you did not produce, validate the shape before decoding:

const clean = payload.replace(/[\r\n\t ]+/g, '');
if (clean.length % 4 === 1) throw new Error('truncated: length % 4 === 1');
if (/[^A-Za-z0-9+/=]/.test(clean)) throw new Error('illegal character in payload');
const raw = Buffer.from(clean, 'base64');

Node also handles base64url natively, which avoids the manual substitution entirely:

const raw = Buffer.from(token, 'base64url');   // '-' and '_' understood, padding optional

For files on disk, the whole thing is a one-liner:

import { writeFileSync } from 'node:fs';
writeFileSync('out.png', Buffer.from(payload, 'base64'));

The browser: atob, bytes, and one crucial detail

atob decodes to a "binary string" — one character per byte — which is not the same thing as bytes. Passing those characters anywhere that expects text corrupts everything above 0x7F:

const buf = atob(payload);
console.log(buf.length, buf.charCodeAt(0).toString(16));

const bytes = Uint8Array.from(buf, (c) => c.charCodeAt(0));   // real bytes
const blob = new Blob([bytes], { type: 'image/png' });
img.src = URL.createObjectURL(blob);

The Uint8Array.from step is not optional. new Blob([atob(payload)]) looks equivalent and produces a corrupt file for any image containing a byte above 127 — which is most of them, since compressed data is close to uniformly random. The symptom is an image that "almost" works: correct dimensions, green or grey bands, or a decoder rejecting it.

atob is also the strictest of the three about whitespace. Per the current specification it throws InvalidCharacterError on a newline, so a MIME-wrapped string must be stripped first:

const bytes = Uint8Array.from(atob(payload.replace(/[\r\n\t ]+/g, '')), (c) => c.charCodeAt(0));

Command line, for when there is no script

Two tools do this and they are not identical in strictness:

payload=$(printf '%s' "$B64" | sed 's/^.*,//')
printf '%s' "$payload" | base64 -d > out.bin        # GNU coreutils, stops on bad input
printf '%s' "$payload" | openssl base64 -d -A > out.bin   # -A ignores newlines

base64 -d on GNU systems reports invalid input and exits non-zero on a character outside the alphabet, which makes it a decent validator. macOS uses BSD base64 with -D instead of -d. Follow either with a signature check, which is the only way to know what you got:

file out.bin          # → PNG image data, 1 x 1, 8-bit/color RGBA, non-interlaced
xxd out.bin | head -1

A fifth way in the browser: let fetch do it

There is an approach that skips atob entirely and avoids every size limit discussed above, because the browser decodes the data URI internally:

const blob = await (await fetch(uri)).blob();     // uri is the full data: string
img.src = URL.createObjectURL(blob);
const bytes = new Uint8Array(await blob.arrayBuffer());   // if you need the bytes

fetch understands data: URLs as a scheme, so this works with no network request. It returns a Blob directly, which pairs naturally with a blob URL and with arrayBuffer() for byte inspection, and it does not build an intermediate binary string — so the double-memory problem of atob plus Uint8Array does not arise.

Two caveats. A strict Content-Security-Policy can block this: the fetch is governed by connect-src, so a policy that does not allow data: there will fail the request, and the error message talks about the connection rather than about Base64. And fetch is asynchronous, which is usually an improvement but does change the shape of surrounding code.

For a converter that decodes on every keystroke it is the simplest of the five approaches; for a script that needs to validate and repair the input first, doing the normalisation yourself and then calling one strict decoder remains clearer.

A behaviour comparison worth remembering

Same input, three runtimes, three outcomes:

| Situation | Python | Node | Browser atob | |---|---|---|---| | Contains \n | ignored by default | ignored | throws | | Contains spaces | ignored by default | ignored | throws | | Contains %3D | error with validate=True | % skipped | throws | | Missing padding | error | decodes | throws | | Length % 4 == 1 | error | decodes (wrong bytes) | throws | | Contains - or _ | error (urlsafe variant needed) | 'base64url' handles it | throws |

The pattern is that Node never tells you anything and Python tells you if you ask. Neither is wrong for its intended use — Node optimises for tolerant ingestion of real-world data, Python for correctness by default. The practical advice follows directly: decode with the strictest runtime you have, and do the normalisation yourself. If you repair the input explicitly, record what you repaired, and verify the output bytes afterwards, the three runtimes agree — and you find out about a truncated string instead of rendering half an image.

That last step, verifying the decoded bytes, is the one people skip. Two checks cover most of it: the first bytes should match a known format signature (see image file signatures), and the length should match what the producer reported. If the length is short, the string was truncated in transit — the arithmetic behind that check is in line breaks and padding. If you would rather not write it, Base64 to Image performs the whole sequence — normalise, repair, decode, identify, verify — and reports each step it took, including the ones it decided not to take, which is often the more useful information.

Related reading

Try the Base64 to Image converter →