Skip to content
Testing

9 JSON Binary Data Encoding Tests Before API Release

·12 min read
9 JSON Binary Data Encoding Tests Before API Release - Testing guide from JSON Prettifier

Quick answer: Test binary-in-JSON as a byte-preservation contract, not merely as a string that decodes. Fix the alphabet and padding policy, encode text through UTF-8, enforce limits after decoding, verify metadata and integrity separately, reject malformed representations, and exercise awkward streaming boundaries. The nine tests below use Node's standard library.

JSON has no byte type, yet APIs routinely need to carry signatures, thumbnails, certificates, and small documents. Base64 makes those bytes representable as text; it does not decide which alphabet, spelling, size limit, or trust policy your endpoint accepts. Those decisions belong in the contract and in executable tests.

The examples below were executed with Node.js 24.19.0. They use only node:assert, node:crypto, and Buffer. Node's decoder is deliberately permissive, so the strict examples validate the text before calling it. For background, Base64 is a family of binary-to-text encodings; RFC 4648 is the primary specification for the alphabets and canonical rules.

1. Does the contract define one JSON-safe binary representation?

Direct answer: Define a JSON object whose data field is a string and whose contract names the byte encoding explicitly. Do not serialize a Buffer, typed array, or platform-specific byte object and hope another runtime infers it. Test that encoding and decoding reproduce every input byte, including zero and 255.

JSON.stringify(Buffer.from([0, 255])) does not produce a portable byte literal. In Node it invokes Buffer.toJSON() and emits an object with type and data; other platforms use different shapes. A stable API envelope makes the representation deliberate:

{
  "encoding": "base64",
  "mediaType": "application/octet-stream",
  "byteLength": 6,
  "data": "AP8QIDBA"
}

Test arbitrary bytes rather than a friendly ASCII word:

import assert from "node:assert/strict";

const original = Buffer.from([0x00, 0xff, 0x10, 0x20, 0x30, 0x40]);
const envelope = {
  encoding: "base64",
  mediaType: "application/octet-stream",
  byteLength: original.length,
  data: original.toString("base64"),
};

const parsed = JSON.parse(JSON.stringify(envelope));
assert.deepEqual(Buffer.from(parsed.data, "base64"), original);
assert.equal(parsed.byteLength, original.length);

The JSON Viewer is useful for inspecting this envelope and confirming that data remains a JSON string. It does not decode the bytes, verify a hash, or validate a media type; those are application tests.

2. Does the decoder distinguish Base64 from Base64url?

Direct answer: Test the alphabet named by the protocol instead of treating Base64 and Base64url as interchangeable labels. Standard Base64 uses + and /; Base64url substitutes - and _. Choose fixtures that actually produce those characters, because ordinary letters and digits can conceal a mismatched decoder.

The byte sequence below exposes both substitutions. A “hello” fixture would not, because its encoding uses only the shared portion of the alphabets.

const bytes = Buffer.from([0xfb, 0xff, 0xef]);
assert.equal(bytes.toString("base64"), "+//v");
assert.equal(bytes.toString("base64url"), "-__v");

RFC 4648 section 5 defines the URL- and filename-safe alphabet. It is a distinct encoding, not a hint that every decoder must accept both. Node's current Buffer documentation says its base64 decoder accepts the URL-safe alphabet and its base64url decoder accepts the standard one. That convenience can hide a contract violation that a stricter Java, Go, gateway, or database decoder rejects.

Use separate positive and negative fixtures. If a field promises standard Base64, decide whether -__v is an error even though Node can decode it. If a field promises Base64url, decide whether +//v is an error. The right policy comes from the enclosing protocol, not from JSON itself.

3. Is the padding and canonical-form policy explicit?

Direct answer: Specify whether padding is required, optional, or forbidden, then test all three final-byte cases. Canonical padded Base64 has a length divisible by four and uses zero, one, or two trailing equals signs. Unpadded Base64url is common, but accepting multiple spellings can undermine string comparisons, caches, and signatures.

One, two, and three input bytes cover the padding boundary:

assert.equal(Buffer.from([0x66]).toString("base64"), "Zg==");
assert.equal(Buffer.from([0x66, 0x6f]).toString("base64"), "Zm8=");
assert.equal(Buffer.from([0x66, 0x6f, 0x6f]).toString("base64"), "Zm9v");

assert.equal(Buffer.from([0x66]).toString("base64url"), "Zg");
assert.equal(Buffer.from([0x66, 0x6f]).toString("base64url"), "Zm8");

RFC 4648 requires padding unless the referring specification says otherwise, and it requires unused pad bits to be zero for a canonical encoding. This matters because two textual values can otherwise decode to the same bytes. Compare or sign the exact representation required by your protocol; do not silently normalize before verifying a signature unless that protocol explicitly defines the normalization.

A May 2, 2026 Google Workspace CLI issue documents a practical boundary: Gmail attachment data was returned as unpadded Base64url and failed when users passed it to standard decoders. The issue is qualitative evidence of current interoperability work, not proof that one padding policy suits every API.

4. Are text inputs converted through UTF-8 bytes correctly?

Direct answer: When the original value is text, encode its UTF-8 bytes rather than its JavaScript UTF-16 code units. Include accented letters, non-Latin scripts, and emoji in round-trip fixtures. In browsers, btoa() consumes a byte-oriented string; use TextEncoder or the modern Uint8Array Base64 methods instead.

Base64 operates on bytes. The string ₹ café 🚀 therefore needs a text encoding before Base64 and the same decoding after it:

const text = "₹ café 🚀";
const encoded = Buffer.from(text, "utf8").toString("base64");
const decoded = Buffer.from(encoded, "base64").toString("utf8");
assert.equal(decoded, text);
assert.equal(Buffer.from(text, "utf8").length, 14);

MDN's btoa() documentation explains that each input character must fit in one byte and recommends byte-array conversion for arbitrary binary data. Current JavaScript also defines Uint8Array.fromBase64(), setFromBase64(), and toBase64(); the TC39 Base64 proposal reached Stage 4, while deployment support still needs checking for the browsers and runtimes you ship.

Do not label arbitrary file bytes “UTF-8.” A PNG, compressed archive, or signature should remain bytes before and after Base64. UTF-8 decoding is appropriate only when the underlying content is defined as text.

5. Are encoded and decoded size limits enforced?

Direct answer: Enforce a decoded-byte limit as well as the complete HTTP-body limit, and test both sides of every boundary. Padded Base64 uses exactly 4 × ceil(bytes ÷ 3) characters. JSON property names, quotes, metadata, and transport headers add more, so a raw-file limit cannot safely double as a request-size limit.

Make the relationship executable instead of relying on “about 33%”:

for (let size = 0; size <= 10; size += 1) {
  const bytes = Buffer.alloc(size, 0xa5);
  assert.equal(bytes.toString("base64").length, 4 * Math.ceil(size / 3));
}

const MAX_DECODED = 1_000_000;
function acceptEnvelope(value) {
  const decoded = Buffer.from(value.data, "base64");
  if (decoded.length > MAX_DECODED) throw new Error("decoded payload too large");
  return decoded;
}

Reject oversized input before expensive downstream work, but measure again after decoding. A client-supplied byteLength is useful for detecting mistakes; it is not an enforcement boundary because the client controls it. The broader JSON file-size guide explains why gateways, parsers, and applications can impose different limits.

For large files, question the JSON design itself. A multipart request, a binary endpoint, or a pre-signed object-storage upload can avoid Base64 expansion and reduce simultaneous copies in memory. Base64-in-JSON remains useful for small values when one JSON document is a meaningful contract requirement.

6. Are media type, filename, and byte length validated separately?

Direct answer: Treat metadata as untrusted claims, not facts inferred from successful Base64 decoding. Validate the allowed media type, sanitize or replace the filename, and compare declared length with decoded length. When file type matters, inspect the decoded bytes with a maintained parser instead of trusting an extension or client header.

Decoding only proves that a string can be interpreted as bytes. It does not prove that those bytes are an image, that report.pdf is safe as a storage path, or that the declared length is honest.

const ALLOWED_TYPES = new Set(["image/png", "image/jpeg"]);

function validateMetadata(envelope, bytes) {
  assert.ok(ALLOWED_TYPES.has(envelope.mediaType));
  assert.equal(envelope.byteLength, bytes.length);
  assert.match(envelope.fileName, /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/);
  assert.equal(envelope.fileName.includes(".."), false);
}

That filename expression is an example policy, not a universal sanitizer. Many systems are safer generating their own storage key and retaining the submitted name only as display metadata. Likewise, magic-byte checks are format-specific and can require deep parsing; a few leading bytes are not malware detection.

Keep JSON syntax validation separate from content validation. A perfectly valid envelope can still contain disallowed bytes, misleading metadata, or an excessive decoded payload.

Apply the same separation to transport metadata: the JSON Content-Type guide explains how media types describe the HTTP representation, while this envelope's mediaType describes the decoded file. Neither claim proves the bytes are safe.

7. Does an integrity check cover the decoded bytes?

Direct answer: When integrity is required, calculate the digest or authenticated signature over the byte sequence defined by the protocol and test a one-bit mutation. State the digest's own encoding separately. A plain hash detects accidental change but does not authenticate the sender; signatures and MACs require key management and protocol-specific verification.

The digest below protects the decoded bytes, independent of whitespace or JSON member order:

import { createHash } from "node:crypto";

const sha256 = bytes => createHash("sha256").update(bytes).digest("base64url");
const bytes = Buffer.from([0, 1, 2, 3, 255]);
const expected = sha256(bytes);
assert.equal(sha256(Buffer.from(bytes.toString("base64"), "base64")), expected);

const changed = Buffer.from(bytes);
changed[2] ^= 1;
assert.notEqual(sha256(changed), expected);

Be precise about the signed material. Some protocols sign the original encoded characters; others sign decoded content; still others canonicalize a larger JSON structure. Re-encoding before verification can change padding or alphabet and invalidate the intended security property. The JSON canonicalization checks cover byte-stable JSON when the signature applies to a document rather than one binary field.

Do not describe Base64 as encryption. It has no secret and is immediately reversible. Confidentiality comes from TLS in transit and appropriate encryption at rest or at the application layer, depending on the threat model.

8. Does strict decoding reject malformed or ambiguous input?

Direct answer: Put contract validation in front of permissive library decoders. Reject the wrong alphabet, internal whitespace, misplaced padding, impossible lengths, and non-canonical pad bits unless the enclosing specification explicitly permits them. Then decode and re-encode to the required canonical form so equivalent but forbidden spellings cannot slip through.

RFC 4648 says decoders must reject non-alphabet characters unless another specification deliberately allows them. Node's Buffer.from(value, "base64"), however, ignores spaces, tabs, and newlines and accepts both standard and URL-safe alphabets. That behavior is documented and useful, but it is too broad for a contract that promises one canonical form.

function decodeCanonicalBase64(value) {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
    throw new Error("non-canonical base64");
  }
  const bytes = Buffer.from(value, "base64");
  if (bytes.toString("base64") !== value) throw new Error("non-zero pad bits");
  return bytes;
}

assert.deepEqual(decodeCanonicalBase64("AP8="), Buffer.from([0, 255]));
assert.throws(() => decodeCanonicalBase64("AP8"));
assert.throws(() => decodeCanonicalBase64("A P8="));
assert.throws(() => decodeCanonicalBase64("AP8==="));
assert.throws(() => decodeCanonicalBase64("AP-="));

Keep malformed fixtures in the suite even when the current runtime happens to reject them. Decoder upgrades and language migrations can widen or narrow accepted input. A July 25, 2026 Coraza issue reported Base64url characters truncating a decoded value in one security rule path, illustrating why cross-layer tests should verify the complete output rather than merely expecting “no exception.”

9. Do streaming boundaries preserve the byte sequence?

Direct answer: Split input at one-byte, two-byte, and irregular boundaries and confirm that streamed output equals one-shot Base64. Each four encoded characters represent three input bytes, so independently encoding arbitrary chunks and concatenating their padded strings is wrong. Use a streaming codec that carries residual bytes, or align every non-final chunk to three bytes.

A tiny counterexample catches the mistake:

const input = Buffer.from("stream boundary", "utf8");
const wrong = [input.subarray(0, 1), input.subarray(1)]
  .map(chunk => chunk.toString("base64"))
  .join("");

assert.notEqual(wrong, input.toString("base64"));
assert.notDeepEqual(Buffer.from(wrong, "base64"), input);

const aligned = [input.subarray(0, 3), input.subarray(3, 9), input.subarray(9)]
  .map(chunk => chunk.toString("base64"))
  .join("");
assert.equal(aligned, input.toString("base64"));

Real stream encoders retain the final one or two bytes and combine them with bytes from the next chunk. Test chunk sizes [1, 2, 3, 4, 5, 8191], empty input, and a final incomplete group. Run the same exercise in the decoder if the API streams encoded content.

Also distinguish transport chunks from application records. Splitting one Base64 string across network packets is harmless when a stateful decoder consumes the stream; presenting each fragment as an independently padded JSON value changes the protocol. If files are large enough to require elaborate chunk records, a binary upload design may be simpler and cheaper.

What should the release checklist require?

Direct answer: Require all nine tests in continuous integration, plus cross-runtime fixtures for every supported client. The release gate should compare decoded bytes, not screenshots or plausible text. Document the exact alphabet, padding, maximum decoded length, metadata policy, integrity scope, malformed-input behavior, and streaming model beside the API schema.

Use this compact matrix as the acceptance record:

TestPositive fixtureRequired negative fixture
Representation00 ff 10 20 30 40 round tripplatform-specific byte object
Alphabetbytes producing + / or - _wrong alphabet
Padding1-, 2-, and 3-byte inputsforbidden missing or extra =
UTF-8accents, non-Latin text, emojidirect Unicode use with byte-string APIs
Limitsexact maximum decoded bytesmaximum plus one, dishonest length
Metadataallowed type and safe nametraversal name, unsupported type
Integritymatching digest or valid signatureone-bit mutation
Strictnesscanonical spellingwhitespace, bad pad bits, mixed alphabet
Streamingirregular chunk sizesconcatenated padded fragments

Finally, run the fixtures through the actual gateway, framework, and client SDK, not only a helper function. JSON parsing, body limits, automatic coercion, and decoder tolerance can differ at each layer. A release is ready when the complete path preserves the expected bytes and rejects every representation your contract excludes.

Frequently Asked Questions

Can JSON contain raw binary data?
No. JSON defines strings, numbers, booleans, null, arrays, and objects, but no byte type. An API must place a textual representation such as Base64 in a JSON string, send a separate binary or multipart body, or return a URL from which the client can retrieve the bytes.
Should a JSON API use Base64 or Base64url?
Use the variant named by the contract. Standard Base64 uses plus and slash and normally includes padding. Base64url uses hyphen and underscore and often omits padding. Base64url helps when the same value enters a URL or filename, but it is not automatically correct for every JSON field.
How much larger is Base64 than the original binary data?
Padded Base64 encodes each three input bytes as four characters, so its exact character count is four times the ceiling of bytes divided by three. The ratio approaches one third extra for large inputs, while the surrounding property name, quotes, braces, and HTTP headers add more.
Is Base64 encoding secure or encrypted?
No. Base64 changes bytes into printable characters and anyone can reverse it without a key. Use the protocol's encryption for confidentiality and an authenticated signature or MAC when authenticity matters. A plain checksum can detect accidental changes but cannot prove who created the payload.
Why does Node accept a Base64 value that another decoder rejects?
Node's Buffer decoder is intentionally permissive: its Base64 mode also accepts the URL-safe alphabet and ignores whitespace. A stricter service may reject those forms. Validate the contract's alphabet, padding, and canonical spelling before decoding, then test the same malformed fixtures against every production runtime.

Inspect the JSON Envelope Before You Decode

Paste a safe sample into the JSON Viewer to confirm the field names, metadata, and encoded string. Decode and verify the bytes in application code.

Open JSON Viewer