Back to News & Insights
JavaScript September 8, 2026 · 9 min read

qrdrop: what 'no server' forces you to build

What 'no server' actually costs a file-transfer tool: a QR that is the whole credential, a derived room ID, an emoji SAS read aloud, and an air-gap mode that can't be encrypted.

qrdrop: what 'no server' forces you to build

"No server" sounds like a feature you'd put in a bullet list. It isn't one. It's a constraint, and constraints don't hand you good decisions — they just remove the bad ones that would otherwise have been easiest. qrdrop is a file-transfer tool (npm i -g qrdrop, or just try it at share.stan-ely.com) built around exactly one rule: nothing in between the two devices ever holds a readable copy of the file. There's no account, no upload, no backend that could see the bytes even if it wanted to. Everything below is what that rule cost, in the order the costs showed up.

One real transfer, filmed from both ends — not staged, not sped up. ▶ Watch the 12-second recording on the original post.

Start from the constraint: there's no server to register a device with, so there's nothing to log into. Two devices in the same room have to recognize each other using only what passes between them, and the one channel a network attacker can't stand in the middle of is a physical QR scan — a phone's camera pointed at a laptop screen.

So the QR has to carry a credential, not a pointer to one. qrdrop's QR is 32 bytes of CSPRNG output, full stop. Everything the protocol needs — the room the peers meet on, the password that encrypts their signalling, the session keys, even the words they'll read aloud to each other — is derived from those 32 bytes with HKDF. There's no identity behind the code, no account it belongs to. That's also the honest limit of what it protects: anyone with the code can join. It's the entire credential. Show the QR to a person, not to a room.

The obvious next move, once you have a 32-byte secret, is to use it directly as the room name two peers meet on. It's the shortcut a first draft reaches for, and it works — the two peers show up in the same place, first try, every time you test it.

It's also wrong, because "no server" means signalling has to happen somewhere public. qrdrop's peers announce over Nostr relays and WebTorrent trackers — networks neither peer controls and neither trusts — and a room name on a public relay is not private. If the room ID were the secret, every relay operator watching announcements would have your decryption key the moment pairing started, for free, before a single file byte moved.

The fix is that the room ID is derived from the secret (HKDF(secret, "topic")), never the secret itself. A relay sees a room name it can't invert back to the QR contents. This is the kind of bug that a demo doesn't catch — the transfer completes, the file arrives, everything looks fine — because publishing the key to a room name doesn't fail the transfer, it just fails the promise you made about it in the README.

A server, if there were one, could do identity checking for you — you'd log in, and the server would tell each side who the other one was. There isn't one here, so nothing can attest that the peer on the other end of the WebRTC data channel is actually the phone that scanned the code, rather than something between them. WebRTC signalling gets a password derived from the secret too (HKDF(secret, "signal")), which stops an observer from substituting their own session description in transit — the textbook man-in-the-middle on WebRTC signalling, and the specific thing the QR exists to prevent. But that's defense against a network attacker forging the connection; it isn't proof the two humans looking at their screens agree on what got connected.

For that, qrdrop falls back to the oldest trust anchor there is: a person. Once the data channel is up, both screens derive and display four emoji — the same four, if the pairing is genuine — and a human reads them aloud and matches them by eye. The words under the emoji are what a screen reader speaks, because those are the words a person actually says out loud to their peer; the emoji tiles are decoration on top of that.

The sender confirms this SAS before the file manifest goes out, because the manifest alone discloses filename and size to whoever's on the other end of that channel. This is the gesture the project treats as non-negotiable: --yes on the CLI skips the receiver's accept prompt, and cannot skip the SAS confirmation. A flag that skipped it would be a vulnerability wearing convenience's clothes — the SAS is the whole defense against a successful man-in-the-middle, and there's no server anywhere in this picture that could do that job instead.

Direct peer-to-peer doesn't always work out. When NAT traversal fails, WebRTC falls back to a TURN relay — in qrdrop's case, the Open Relay Project's public, shared, metered infrastructure, because there's no server of its own to run one. That relay sits between the two devices and forwards every packet. It should not be able to read any of them.

DTLS already encrypts the WebRTC data channel itself, terminating at each peer's browser — but "no server" also means qrdrop can't fully trust the signalling layer underneath that connection, so the file bytes get sealed a second time, independently, with an ephemeral ECDH key exchanged directly between the two peers and used to derive AES-GCM keys per direction. Two layers that fail independently: if Trystero's signalling encryption were ever broken outright, an attacker still needs the QR secret to derive the session key, because the ECDH uses that secret as its HKDF salt. A TURN relay operator, in the worst case, sees ciphertext and byte counts. Never contents.

The load-bearing word in all of that is ephemeral — a keypair generated fresh, per session, and thrown away with the room, which is what gives a leaked code no power over a transfer that already finished. It rests on one line of code: the keypair is created inside joinVia, per call, never hoisted to module scope. Hoisting it looks like an obvious optimization — one keypair generation instead of two, since openRoom races two signalling strategies and only needs one to win — and it would silently turn every transfer a running process ever makes into one long session with forward secrecy quietly gone. test/room.test.mjs pairs twice over the same secret against a fake in-memory strategy and asserts the second session can't decrypt the first's traffic, specifically so that regression fails in two seconds locally rather than surviving to a release.

The WebRTC path — room ID, signalling password, session keys, and the SAS all derive from the 32 bytes in the QR.

Every decision above assumes a network exists, even a hostile, public, unreliable one. Take that away — an air-gapped machine — and there's genuinely nothing left: no relay to announce on, no TURN to fall back to, no data channel to open. The constraint doesn't relax here; it just changes shape. If there's no network, the only channel left is the screen itself.

That's what Beam is: the sender animates QR codes on screen at roughly ten frames a second, and the receiver's camera reads them, with no wire, no radio, and no signalling of any kind between the two devices. The payload is fountain-coded rather than a numbered loop, because a numbered loop turns a missed frame into a coupon-collector problem — waiting for the specific frame you missed, out of a loop you have to sit through again. A fountain frame doesn't care which frames were missed, only how many arrived, which is why the difference between the two grows sharply as frame loss climbs (measured over 1,748 blocks of a 1 MiB payload): 1.05× N frames needed at 0% loss versus 1.00× N for a numbered loop, but 2.08× N versus 10.7× N at 30% loss.

Beam mode — the sender's screen emits ~10 LT-coded QR frames a second; the receiver's camera reads them. No back channel.

And here is where the constraint stops being convenient and just states its own limit plainly: Beam is not encrypted, and it cannot be. No network means no handshake, which means no key agreement, no forward secrecy, and no SAS — there's no peer to authenticate over a channel of light, only photons, and a key shown on the same screen as the payload protects nothing. Both beam screens say so directly. It's offered as the alternative to a USB stick on an air-gapped machine, not as a private channel, and the project is explicit that everything it claims about confidentiality elsewhere in the document stops at Beam's edge.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation