Privacy is a product requirement here, not a compliance chore. Scanned values routinely include product keys, serial numbers, activation codes, Wi-Fi credentials, and customer information.
No accounts. Neither side needs an identity for the core workflow.
Temporary sessions. Pairing credentials expire in minutes; sessions in tens of minutes.
Strong randomness. Every identifier and secret comes from a CSPRNG with the entropy specified in protocol.md.
TLS everywhere. HTTPS and WSS only. The relay refuses plaintext.
Minimal retention. Scan values exist in server memory only as long as it takes to forward them. Never written to a database, analytics, access log, error log, or monitoring system.
Automatic deletion. Session state disappears when the session ends. There is no deletion job because there is no store.
No indexing. X-Robots-Tag: noindex, nofollow on all responses plus a
blanket-disallow robots.txt.
One-time pairing. The pairToken is consumed by the first scanner to use it. A
second phone cannot join. Switching devices requires an explicit action on the PC, which
issues a fresh token.
Data flows one way: phone → relay → browser. An attacker who joins a session does not read scans. They write them.
That matters more than it first appears. The technician's next action is to paste the received value into an activation form, a licence field, or occasionally a terminal. An attacker who can push a value into the receiver is influencing what gets pasted into privileged places on a customer's machine.
Mitigations, in order of importance:
- One scanner per session. First valid
pairTokenuse wins; everything after is rejected. An attacker must beat the legitimate phone to the join, in the seconds the QR is on screen. - Short pairing TTL (10 minutes) and pairing QR removed from the screen the instant a scanner connects — it should not sit visible while the technician works.
- Visible connection state. The receiver shows exactly one connected scanner. A
scanner_connectedevent the technician did not cause is a visible anomaly. - The value is always displayed before it is used. The receiver never auto-types, auto-submits, or auto-executes. A human reads it before pasting. This is why "clipboard sync" and "auto-type" are on the permanent out-of-scope list — they would convert this from a visible anomaly into a silent compromise.
A captured line of text is attacker-influenced in exactly the same way a decoded barcode
is, and is handled identically: textContent everywhere, never parsed as markup, and
never sent without the human tap that chose it.
The pairing QR is displayed on the customer's screen in a public retail space. It can be photographed. This is inherent to the design and cannot be engineered away; the one-time pairing and short TTL above are the mitigation. Accept it, and never weaken either control for convenience.
If a session is ever suspected of being joined by the wrong device, "Pair another phone" invalidates the current pairing and issues a new token. That is the recovery path.
sessionId is 128 bits and grants nothing by itself; tokens are 256 bits. Guessing is not
a practical avenue. Token comparison must be constant-time regardless, and
invalid_session and session_expired must be indistinguishable to an unauthenticated
caller.
The relay is an open, unauthenticated pipe. The caps in protocol.md — sessions per IP, frames per second, value size, total concurrent sessions — are the defence and belong in the relay itself so self-hosted deployments inherit them.
This is where a well-designed system leaks in practice. Get it right at the first commit; retrofitting it means auditing every log line ever written.
Log technical events. Never log decoded content.
good: scan_received session=0hK2… format=CODE_128 length=19
bad: scan_received value=ABCD-EFGH-IJKL-MNOP
Concretely:
- Never log: scan values,
pairToken,receiverToken,scannerToken, or full pairing URLs (the fragment carries the secret). - Safe to log:
sessionId, role, event name, format, value length, error code, timestamps, coarse client info. - Loggable events: session created, receiver connected, scanner paired, scanner disconnected, scan relayed, session ended with reason, protocol error, rate limit hit.
Rules that hold even in development builds:
- Scan values never enter an exception message, so they cannot ride into a stack trace.
- Structured logging with an explicit allowlist of fields, not object spreading. One
log.debug({msg})on a whole message object leaks everything. - If a crash reporter or APM is ever added, it needs a scrubber and a test proving the scrubber works.
- Debug builds may log more events, never more content. A developer debugging a decode problem needs the format and length, not the key.
- Session credentials in
sessionStorage, neverlocalStorage, never cookies. They die with the tab, which is the intended lifetime. - Scan history lives in memory for the page's lifetime only. A refresh may restore the session; it must not restore the history.
- A strict CSP with no third-party origins. No analytics, no fonts from a CDN, no tag managers — anything loaded into the receiver page can read the scanned values.
- No third-party scripts, ever. This is a hard rule, not a default.
autocomplete="off"andspellcheck="false"on any element rendering a scan, so the value is not sent to a spellcheck service or captured by form autofill.
- Scanned values are held only until acked, then dropped from any UI buffer beyond the last-scan display.
- No scan history persisted to disk.
- Text capture is entirely on-device. The captured frame is drawn to a canvas, read once, and dropped when the call returns. No image is uploaded, and no recognition request leaves the phone. The engine is either the browser's own or a WASM build served from our origin — see architecture.md.
- The recognition engine and its language model may be cached by the browser, as any static asset is. That is a program, not content: nothing derived from a captured frame or a scanned value is ever written anywhere.
- The engine reports progress and error events. Only its
statusname and numericprogressare read, and its error handler is deliberately empty — an engine string could carry something it read, and the logging rules above hold for the phone too. - Camera permission is requested at first use with a plain explanation, not on launch before context exists.
- Preferences (sound, vibration, auto-send) may persist. Session credentials and scanned content may not.