“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.
No account, so the QR has to be the entire credential#
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 room ID can’t be the secret — a public relay is watching#
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.
No server to vouch for either side, so a human has to#
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.
Bytes cross a relay you don’t control, so they’re sealed twice#
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.

No network at all means no transfer — unless the screen is the network#
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.

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.
The constraint is what made it worth building#
None of this reads like a feature list, and that’s the point. A server would have made every one of these problems disappear — an account instead of a 32-byte credential, a private room name instead of a derived one, a trusted third party instead of four emoji read aloud, TLS to a backend instead of a second encryption layer over a public relay. Removing the server didn’t remove the problems; it just moved every one of them onto the two devices and the person standing between them, which is exactly where a network attacker can’t reach the contents.
The honest caveats stay attached rather than getting rounded off: TURN is free, shared, and capped at 100 MB for exactly that reason; Firefox and Safari buffer whole files in memory before saving, so practical transfers there cap out well short of what Chromium can stream to disk; the page serving the code could itself be compromised, and no in-browser design stops that — it’s mitigated by a strict CSP and shipped source maps so the claims can be checked against what’s actually running, not eliminated. None of that is dressed up, because the constraint that produced the interesting decisions is the same one that produces the limits.
That distinction is not academic. A report came in from the field —
Out-of-order frame: expected 0, got 13877, on a transfer under a megabyte — and behind
it was a stranger in the rendezvous room ending a live transfer with fourteen bytes of
well-formed cleartext. Frame headers were being read for type and sequence before the
AES-GCM tag was checked, and inbound frames weren’t filtered to the paired peer, so one
packet from someone who held no key and never paired was enough to make the receiver
abort and throw away the partial file. Nothing was readable, forgeable or alterable; what
was lost was availability, which is still a real thing to lose. Frames are authenticated
before their headers are trusted now, and one that fails its tag is dropped and counted
rather than treated as a fault in the transfer.
The part worth keeping isn’t the fix. It’s that the report was legible as a bug at all. A stray packet only reads as an attack if the document already says who was supposed to be able to send one.
If you want to see whether it holds up: open share.stan-ely.com on a laptop, drop a file onto it, and point a phone’s camera at the code that appears. Watch the four words. Then check that they matched.