Research
Why quotes and semicolons break Wi-Fi QR codes
By OpenQR · updated 10 November 2026
A comment appeared under a QR code generator launch recently: the generator part is easy, escaping isn’t, and a Wi-Fi code breaks the second an SSID or password contains quotes or semicolons, because nobody tests it. We build a generator, so we took the claim seriously and tested it the only way that counts: fill the form with a hostile password, decode the produced image, compare it byte for byte against the specification. Of five generators tested in an afternoon, one of the best-known produces a code that joins the wrong network.
7 min read · Updated 10 November 2026
How a Wi-Fi QR code carries your password
A Wi-Fi QR code contains a short string in a format that came out of the ZXing barcode project. The network name and password sit in one line, with semicolons separating the fields:
WIFI:T:WPA;S:Cafe Guest;P:latte4life;;T is the authentication type (WPA, WEP or nopass), S is the SSID, P is the password, and H:true marks a hidden network. The trailing double semicolon closes the string. The stock camera apps on iOS and Android both read this format, which is why pointing a phone at such a code offers to join the network. The grammar has one consequence that everything else in this article follows from: the semicolon separates fields, so a semicolon inside a value needs to mean something else. The specification’s answer is a backslash escape. Five characters must be escaped inside SSIDs and passwords: the backslash itself, the semicolon, the colon, the comma and the double quote.
WIFI:T:WPA;S:she said \"hi\";P:p@ss\;w0rd\:\"x\";;That is a code for the SSID she said "hi" and the password p@ss;w0rd:"x". Every reader that implements the grammar turns the escaped string back into exactly those values.
What an unescaped code does on a phone
Nothing crashes, nothing shows an error, and the code scans on the first try. The phone splits the string at every semicolon it finds, so a raw password p@ss;w0rd:"x" becomes the password p@ss followed by two nonsense fields, w0rd and "x". The join attempt runs with a truncated password and fails, or in the nopass case the phone tries a network whose name ends early. The QR code itself is perfectly valid. Every scanner reads it. That is what makes the bug shippable: the developer’s test password is usually password123, the code scans, the ticket closes, and the first person who prints the code for a network with a semicolon in the key gets a poster that silently refuses to work.
The characters are legal in real passwords
A WPA passphrase is 8 to 63 printable characters, and quotes, semicolons, colons, commas and backslashes are all allowed in it. Router defaults and mesh systems hand out passphrases with these characters every day. The failure is not exotic; it is one honest password away.
We tested five generators
On 10 November 2026 we ran the same test against five generators: fill the Wi-Fi form with the SSID she said "hi" and the password p@ss;w0rd:"x", take the image each site actually produces, decode it with the jsQR library, and compare the decoded string against the specification. For the two sites that require an account before producing any image, we recorded that fact instead of guessing.
| Generator | What we decoded from its own image | Verdict |
|---|---|---|
| OpenQR (ours) | WIFI:T:WPA;S:she said \"hi\";P:p@ss\;w0rd\:\"x\";; | Correct |
| QRGeny | WIFI:T:WPA;S:she said \"hi\";P:p@ss\;w0rd\:\"x\";H:false;; | Correct |
| QRCode Monkey | WIFI:S:she said \"hi\";T:nopass;P:p@ss;w0rd:\"x\";; | Broken: semicolon not escaped |
| qr-code-generator.com | No image without an account | Not testable anonymously |
| Me-QR | No image without an account | Not testable anonymously |
The QRCode Monkey result is worth reading closely, because it is a half-fix rather than no fix. The quotes are escaped, so someone on their team did think about escaping. The semicolons in the password are not escaped, so the password field still terminates at p@ss. A partial escaper is the most dangerous shape: it passes the obvious test (try a quote, it works) and fails the field separator that actually breaks the parse. Our probe ran against the live desktop site with its default settings, and the site may of course change; the decoded string above is the receipt for what it produced on the day.
The fix, in full
The escape function is one line. In our codebase every surface shares it: the browser generator, the dashboard, the REST API and the MCP server all build Wi-Fi payloads through one builder, so there is exactly one place to be right.
// The five reserved characters in the ZXing Wi-Fi grammar, backslash-escaped.
const esc = (v: string) => v.replace(/([\\;,:"])/g, "\\$1");
function buildWifi(f: { ssid: string; password: string; encryption: string; hidden?: boolean }) {
const parts = [`T:${f.encryption === "nopass" ? "nopass" : f.encryption}`, `S:${esc(f.ssid)}`];
if (f.encryption !== "nopass") parts.push(`P:${esc(f.password)}`);
if (f.hidden) parts.push("H:true");
return `WIFI:${parts.join(";")};;`;
}The reverse direction matters as much. When someone opens the edit sheet for a saved code, the parser has to unescape the same five characters, or the round trip quietly corrupts the password on the second save:
const unesc = (v: string) => v.replace(/\\([\\;,:"])/g, "$1");One character class, applied in both directions, is the whole discipline. If your escape set and your unescape set ever differ, you have built a machine that mutates passwords.
Two traps that survive correct escaping
Escaping the five characters is the spec part. Two further traps live outside the grammar, and we hit both while building the verification for this article.
The encoder’s default byte conversion
QR codes carry bytes, and the widely used qrcode-generator library converts your string to bytes with a rule that masks each character’s code point to a single byte. ASCII survives by construction. An é in a café SSID survives by luck, because its code point fits in one byte. An emoji does not: its code point is masked to its lowest byte, which turns a doughnut emoji into the letter f. The code decodes, the string comes back wrong, and no error is raised anywhere. The fix is to point the encoder at a real UTF-8 conversion before anything else happens:
qrcode.stringToBytes = (s: string) => Array.from(new TextEncoder().encode(s));The trailing backslash
A password that ends in a backslash, like x\y\, is the nastiest case in the set. Escaped correctly it becomes x\y\\, and if your escaper runs the replacement in the wrong order or your reader treats \; and a literal \ before a field separator identically, the closing semicolon gets swallowed and the parse runs off the end of the string. Put the trailing-backslash password in your test suite permanently; it is the case that distinguishes an escaper from a regex that happens to pass.
How to test this in your own product
The only assertion that means anything is decoding the image you actually produce and comparing the result to the string you intended. Unit tests on the builder prove the builder; they say nothing about the encoder, the style layer, the export path or the download button. The harness we use renders the code, rasterises it, decodes it with jsQR and compares byte for byte:
const qr = qrcode(0, 'M');
qr.addData(payload, 'Byte'); qr.make();
// rasterise to RGBA at 8px per module with a 4-module quiet zone, then:
const decoded = jsQR(rgba, side, side).data;
assert(decoded === payload); // byte for byte, not 'looks similar'Run it with a password that contains every reserved character at once, a password that ends in a backslash, an SSID with an accent, and an emoji. Then scan the exported file with a physical iPhone and an Android phone, because library decoders and camera apps are different programs with different histories. jsQR parsing your code is good evidence; a phone joining the network is the result.
Limits of this test
This is a snapshot: five generators is a small sample, two of them could not be tested without accounts, QRCode Monkey may fix its escaper the day after this is published, and we tested desktop web with default settings rather than every app and platform. The method survives all of that. Decode what you ship, compare it to what you meant, and keep the hostile passwords in the suite. The comment that started this was right that almost nobody tests it.