The payload behind a "join this network" QR code is a single line of text:
WIFI:T:WPA;S:cafe-wifi;P:p@ssw0rd;;
Worth knowing up front: this is not an official standard. It is the convention ZXing introduced, which the early Android scanners shipped with, and which everything else then matched. There is no RFC to point at when something disagrees.
Which matters, because the moment a password contains punctuation, the format starts biting.
Five characters have to be escaped
Inside S: and P:, a backslash escapes the five characters that would otherwise end a field: \ ; , : ".
So I built payloads for a set of passwords and parsed them two ways — once by naively splitting on ; and : (how you write it the first time), once following the escape rule:
password 'p@ssw0rd'
payload WIFI:T:WPA;S:cafe-wifi;P:p@ssw0rd;;
naive 'p@ssw0rd' OK
spec 'p@ssw0rd' OK
password 'coffee;time'
payload WIFI:T:WPA;S:cafe-wifi;P:coffee\;time;;
naive 'coffee\' <- truncated
spec 'coffee;time' OK
password 'a:b:c'
payload WIFI:T:WPA;S:cafe-wifi;P:a\:b\:c;;
naive 'a\:b\:c' <- backslashes kept
spec 'a:b:c' OK
password 'back\slash'
payload WIFI:T:WPA;S:cafe-wifi;P:back\\slash;;
naive 'back\\slash' <- doubled
spec 'back\slash' OK
password 'say"hi"'
payload WIFI:T:WPA;S:cafe-wifi;P:say\"hi\";;
naive 'say\"hi\"' <- backslashes kept
spec 'say"hi"' OK
The ; case is the dangerous one. It does not throw, it does not warn — it hands back coffee\ and the user gets an authentication failure with no clue why. The other three come back visibly wrong, which at least gets noticed.
One nuance from the same run: I escaped a comma in the SSID (cafe\,shop) and the naive parser still read the password correctly, because a comma never splits a ;-delimited string. The comma is in the escape set for spec compliance, not because ;-splitting needs it. Worth knowing if you are auditing someone else's encoder and wondering why their commas are bare.
Does it survive an actual QR code?
Escaping is only half the question. The other half is whether a real encoder and a real decoder agree once the payload has backslashes in it. So I baked each payload into an actual QR and read it back with OpenCV 5.0.0:
import cv2
def encode(text, ecc=cv2.QRCODE_ENCODER_CORRECT_LEVEL_M):
p = cv2.QRCodeEncoder.Params()
p.correction_level = ecc
return cv2.QRCodeEncoder.create(p).encode(text)
def decode(img):
# 1 pixel per module is too small for the detector: scale up and add a quiet zone
big = cv2.resize(img, None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
big = cv2.copyMakeBorder(big, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255)
return cv2.QRCodeDetector().detectAndDecode(big)[0]
'p@ssw0rd' -> 33x33 modules (version 4) round-trip matches
'coffee;time' -> 33x33 modules (version 4) round-trip matches
'a:b:c' -> 33x33 modules (version 4) round-trip matches
'back\slash' -> 33x33 modules (version 4) round-trip matches
Byte-identical both ways. The backslashes are ordinary payload bytes as far as QR is concerned — nothing at the barcode layer touches them. Every failure I have seen with these codes lives in the string handling above it.
Two things that cost me time here, in case you run the same experiment: the encoder emits one pixel per module, so feeding its output straight to the detector fails — you have to scale up. And it needs a quiet zone; without the border the detector finds nothing.
The SSID that gets read as hex
This one is not about punctuation at all. If the SSID consists entirely of hex digits, it has to be wrapped in double quotes, or a reader is entitled to decode it as hex bytes rather than as text:
SSID 'deadbeef' all hex: True -> needs quotes
WIFI:T:WPA;S:"deadbeef";P:pw123456;;
SSID '5060' all hex: True -> needs quotes
WIFI:T:WPA;S:"5060";P:pw123456;;
SSID 'cafe-5060' all hex: False -> fine as-is
5060 is the trap. A four-digit network name looks like the least exotic thing in the world, and a router that ships with the last four of its serial number as the SSID hits this by accident. cafe and deadbeef and abc123 are all in the same boat.
The check is two lines, and skipping it is the kind of bug that reproduces on exactly one customer's network.
What error correction costs
The other practical question: how much room does a long password actually take? I fixed a 9-character SSID and grew the password, encoding at each of the four error-correction levels. Numbers are the module count per side:
| password | payload | L | M | Q | H |
|---|---|---|---|---|---|
| 8 chars | 35 | 33 | 33 | 37 | 41 |
| 12 | 39 | 33 | 33 | 37 | 41 |
| 16 | 43 | 33 | 37 | 37 | 41 |
| 20 | 47 | 33 | 37 | 41 | 45 |
| 32 | 59 | 37 | 37 | 41 | 49 |
| 63 | 90 | 41 | 45 | 53 | 57 |
Three things I took from this.
Short passwords are free. 8 and 12 characters produce an identical 33x33 grid at L and M. There is no size argument for a weaker password until you are past about 12 characters.
H is not a small ask. At a 63-character password, H needs 57 modules against L's 41 — the same physical square has modules 1.4x narrower on each side, which is exactly the thing that makes a print-out stop scanning from across a room. H buys you damage tolerance you mostly do not need on a laminated card taped to a wall.
The jump points are what matter, not the length. Growth is a staircase, and the steps land in different places per level: at L the grid holds at 33 all the way to a 20-character password and only steps up at 32, while at M it has already stepped up by 16. If you are sitting just past a step, three fewer characters can keep you a version smaller.
The encoder and parser, in full
SPECIAL = '\\;,:"'
def esc(s):
return "".join("\\" + c if c in SPECIAL else c for c in s)
def is_hex(s):
return bool(s) and all(c in "0123456789abcdefABCDEF" for c in s)
def build(ssid, pw, auth="WPA", hidden=False):
field = esc(ssid)
if is_hex(ssid):
field = '"%s"' % field # quote AFTER escaping - see below
s = "WIFI:T:%s;S:%s;P:%s;" % (auth, field, esc(pw))
if hidden:
s += "H:true;"
return s + ";"
def parse(payload):
body = payload[len("WIFI:"):]
fields, cur, i = [], [], 0
while i < len(body):
c = body[i]
if c == "\\" and i + 1 < len(body): # escaped: take the next char verbatim
cur.append(body[i + 1]); i += 2; continue
if c == ";":
fields.append("".join(cur)); cur = []; i += 1; continue
cur.append(c); i += 1
if cur:
fields.append("".join(cur))
out = {}
for f in fields:
if f:
k, _, v = f.partition(":")
out[k] = v
s = out.get("S", "")
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
out["S"] = s[1:-1] # structural quotes, not part of the name
return out
The parser is a character loop rather than a split, and that is the whole point. There is no regex that splits on unescaped ; without either lookbehind gymnastics or a bug.
The ordering trap I wrote for myself
My first version of build wrapped the SSID in quotes and then escaped the whole thing. That produces:
WIFI:T:WPA;S:\"deadbeef\";P:pw123456;;
The quotes are now escaped, which means they are literal " characters in the network name rather than the wrapper that says "read this as text, not hex". You get a payload that is valid, parses cleanly, and describes a network called "deadbeef" — with the quotes — which does not exist.
Escape first, wrap second. And on the way back out, strip the wrapper, or you hand the caller the same wrong name.
Note T:nopass for an open network, and that the trailing ;; is two semicolons — one ends the last field, one ends the payload.
What I did not test: actual phone cameras. Everything above is OpenCV encoding and decoding on one machine. Whether iOS Camera and the Android scanner agree on the all-hex SSID rule, or on a bare comma, I cannot tell you — I have no way to assert what a closed scanner does without testing every one of them. If you have a phone in hand and a minute, that is the gap I would most like filled in the comments.
The version for people who just want a code for the guest network, with the placement and safety bits: https://www.coding-now.com/en/guides/wifi-qr-code?utm_source=devto
Top comments (0)