DEV Community

Affan Hashmi
Affan Hashmi

Posted on

Before drawing a QR code, test the payload: Unicode, URLs, and double encoding

A QR generator can draw a perfectly valid-looking image while carrying the wrong string. Two easy mistakes are turning a literal plus sign into a space and percent-encoding an already serialized URL again.

This tutorial focuses on the boundary before image generation: prepare a payload, show exactly what will be encoded, and test the transformations. The JavaScript below does not draw a QR symbol or prove that a scanner will interpret it correctly. That separation makes the tests small and the claims verifiable.

I also run qrcodeerstellenkostenlos.de, so this tutorial focuses on the payload boundary; the code below is a standalone teaching example, not the site's production implementation.

Keep three representations separate

Think of three different objects: the user's text, the serialized payload string, and the bytes supplied to an encoder. They need not have the same length or spelling.

For ordinary text, preserve the original Unicode string. A&B + 100% is text, not a query string, so its punctuation needs no URL escaping. For a URL, construct and serialize the URL first. Then hand that complete payload to a QR encoder using its documented input contract. Do not apply encodeURIComponent to the whole URL: it would also escape structural characters such as the colon and slashes.

This example uses UTF-8 for byte measurement. TextEncoder converts strings to UTF-8 bytes; string .length instead counts UTF-16 code units. Neither count is a promise of QR capacity. The example's 512-byte ceiling is an application policy, not a universal QR limit or a guarantee of scanability.

Pick an explicit contract

The text path accepts nonempty, well-formed strings, preserves spaces and line feeds, and permits tabs. It rejects other C0 controls and DEL. In particular, carriage returns are rejected instead of silently normalized. If pasted Windows line endings are expected, design that normalization as a visible, tested step before this function.

A lone UTF-16 surrogate is rejected with isWellFormed(), rather than allowing a conversion to silently change the intended content. The example requires a runtime supporting that method. It does not normalize combining marks: é and e followed by a combining acute accent retain their distinct encodings.

The URL path is intentionally narrower than the URL standard: explicit HTTPS only, no embedded username/password, no raw whitespace or backslashes, and no malformed percent escapes. These are product decisions. HTTPS syntax is not evidence that a destination is trustworthy or reachable. The function performs no network request, malware check, redirect check, or domain ownership check.

Implement the payload boundary

Save this as qr-payload.mjs. It uses built-in JavaScript APIs and needs no packages. The final result contains the payload and its measured byte length, making both available to a preview interface.

const encoder = new TextEncoder();
const MAX_BYTES = 512; // Application policy, not QR symbol capacity.

function checkedString(value) {
  if (typeof value !== "string" || !value.isWellFormed()) {
    throw new TypeError("Expected a string without lone surrogates");
  }
  return value;
}

function bounded(payload) {
  const utf8Bytes = encoder.encode(payload).length;
  if (utf8Bytes === 0 || utf8Bytes > MAX_BYTES) {
    throw new RangeError(`Expected 1 to ${MAX_BYTES} UTF-8 bytes`);
  }
  return { payload, utf8Bytes };
}

export function textPayload(raw) {
  checkedString(raw);
  // Allow tabs and LF; reject other C0 controls and DEL.
  if (/[\u0000-\u0008\u000B-\u001F\u007F]/u.test(raw)) {
    throw new TypeError("Unsupported control character");
  }
  return bounded(raw); // Preserve spaces, line feeds and Unicode exactly.
}

export function httpsPayload(raw, pairs = []) {
  checkedString(raw);
  if (!/^https:\/\//i.test(raw) || /[\s\\\u0000-\u001F\u007F]/u.test(raw) ||
      /%(?![0-9a-f]{2})/i.test(raw)) {
    throw new TypeError("Use an explicit HTTPS URL without whitespace or backslashes");
  }
  const url = new URL(raw); // No base: relative URLs must fail.
  if (url.protocol !== "https:" || !url.hostname || url.username || url.password) {
    throw new TypeError("Expected HTTPS without embedded credentials");
  }
  if (!Array.isArray(pairs)) throw new TypeError("Expected query pairs");
  for (const pair of pairs) {
    if (!Array.isArray(pair) || pair.length !== 2) {
      throw new TypeError("Each query pair needs a name and value");
    }
    const [key, value] = pair.map(checkedString);
    url.searchParams.append(key, value); // Pass unencoded strings.
  }
  return bounded(url.href); // Count after URL serialization.
}
Enter fullscreen mode Exit fullscreen mode

The URL constructor parses the URL without a base, so relative references fail. It also serializes values: host spelling, default ports, and some path details can change. Always show the resulting payload to the user before generating an image. An internationalized hostname may appear in its ASCII representation; displaying that result is useful context, not a phishing detector.

Query pairs are appended as unencoded names and values. URLSearchParams handles escaping during serialization. For example, a value containing a literal + gets %2B, while a space becomes +. Supplying an already escaped value changes the meaning: the literal string %20 becomes %2520, representing those three characters rather than a space.

Appending pairs may also reserialize an existing query, so do not use this helper to modify signed URLs whose signatures depend on exact bytes. For those URLs, keep the original signed serialization and follow the signing system's contract.

Test punctuation, Unicode, and rejection paths

Save this as qr-payload.test.mjs beside the module, then run node qr-payload.test.mjs:

import assert from "node:assert/strict";
import { textPayload, httpsPayload } from "./qr-payload.mjs";

const message = "Grüße 👋\nA&B + 100%";
assert.equal(textPayload(message).payload, message);
assert.equal(textPayload(message).utf8Bytes, 23);
assert.equal(textPayload("é").utf8Bytes, 2);
assert.equal(textPayload("e\u0301").utf8Bytes, 3);
assert.equal(textPayload("é".repeat(256)).utf8Bytes, 512);
assert.throws(() => textPayload("é".repeat(257)));
assert.throws(() => textPayload("\uD800"));

const { payload } = httpsPayload("https://example.com/read", [
  ["q", "A&B + 100%"], ["topic", "café"], ["topic", "QR"]
]);
assert.equal(payload,
  "https://example.com/read?q=A%26B+%2B+100%25&topic=caf%C3%A9&topic=QR");
const parsed = new URL(payload);
assert.equal(parsed.searchParams.get("q"), "A&B + 100%");
assert.deepEqual(parsed.searchParams.getAll("topic"), ["café", "QR"]);
assert.equal(httpsPayload("https://example.com", [["q", "%20"]]).payload,
  "https://example.com/?q=%2520");
for (const bad of ["/relative", "javascript:alert(1)", "http://example.com",
  "https://u:p@example.com", "https://example.com/a b", "https://example.com/%GG"]) {
  assert.throws(() => httpsPayload(bad));
}
console.log("Payload checks passed");
Enter fullscreen mode Exit fullscreen mode

The query round-trip assertions matter more than simply checking whether % appears in the result. They verify that the receiving URL parser recovers the intended values, including repeated keys. The byte-boundary tests use multibyte characters so a mistaken character-count limit cannot pass unnoticed.

Both this excerpt and a fuller local suite passed with Node v25.8.2. The fuller suite also covered empty and non-string text, both kinds of lone surrogate, preserved surrounding spaces, rejected controls, malformed query pairs, missing hosts, oversized URLs, an existing %20 path, default-port normalization, and an internationalized hostname. These are payload tests, not device or scanner tests.

Put the result into a preview, then test the encoder separately

Show the payload in a text area or via textContent, not innerHTML. Do not auto-open a submitted URL as part of validation. Keep generation errors distinct from validation errors; a payload under this demo's ceiling can still exceed a particular encoder's configured capacity.

If your encoder accepts text, confirm its Unicode encoding behavior in its documentation. If it accepts bytes, confirm how it signals the character encoding and how your target readers interpret that data. Do not assume UTF-8 byte measurement alone configures those choices. Avoid claiming universal compatibility without testing the generated symbols and intended readers.

For an end-to-end check, generate a symbol, decode it with an independently chosen decoder, and compare the recovered payload with the expected value. For printed or camera-scanned use, test the actual rendering size, contrast, quiet zone, and target devices too. No such scan tests were performed for this tutorial.

The practical boundary is simple: preserve text intentionally, serialize URL components once, and inspect the exact output before turning it into an image.

References and author context

Top comments (0)