A QR Code on a Text Screen: Pairing Phones to a BBS Without a Login
September 2, 2026
There’s a moment in every mobile-client story where the design gets embarrassing. Your BBS just grew a beautiful native iOS app — SwiftUI, an embedded terminal, SSH, the works. And then someone asks the question that matters: how does the phone prove it’s you?
The boring answer is a login form on a touchscreen. Type your username. Type your password. Maybe paste a token. On a touch keyboard. For a terminal system whose whole identity is that it never needed a GUI.
We went the other way. Phosphor’s Pair Device screen renders the QR code as text — right there in the terminal, in ANSI block characters, next to a 60-second countdown. Scan it with the phone, and you’re paired forever. No password ever typed, no shared secret emailed, no configuration file to copy between devices.
The whole protocol fits in one sentence
The QR encodes a single URI:
phosphor://192.168.1.10:2222/pair?c=KD73XP
Host, SSH port, and a one-time enrollment code. The phone’s camera reads it, the app parses it, and then the actual handshake runs over the SSH connection the BBS already speaks:
- The app connects to the server (pre-auth — this is the whole point)
- It execs
bbs-pair KD73XP "Josephs iPhone" - The server validates the code: not expired (5-minute TTL), not already used, calling IP not rate-limited
- The server burns the code and mints a long-lived device token bound to your account
- It replies with one line of JSON —
{"ok":true,"device_token":"..."}— and the app files it in the iOS Keychain
From then on, launching the app connects with the stored token (bbs-auth), and the server’s persistent-session system drops you exactly where you were last time. Lose the phone? Revoke the device in the user admin screen; it falls back to a normal login.
Why a QR code, though?
Pairing is a trust bootstrapping problem: how does a brand-new device prove it’s you? Every answer that involves typing a secret moves the weakness onto the human. Codes get mistyped, reused, screenshotted into group chats.
The QR sidesteps all of it with a physical assumption: you have to be logged into the BBS and standing in front of the screen for the code’s five-minute lifetime. Defense in depth stacks up behind that:
- The code is 6 characters of Crockford base32 — no
0/Oor1/Iconfusions — from a space large enough that guessing is pointless - Single use: redemption destroys it
- Rate limiting: 5 failures per IP per 10 minutes, checked before the database is touched, so a brute-forcing IP learns nothing at all
- Audit logging records success and failure with the remote IP — but never the code or the full token
But a terminal can’t show a QR code
This is the fun part. A terminal is a grid of character cells. A QR code is a grid of black and white modules. They’re both grids — the only mismatch is vertical resolution. A character cell is one pixel wide but effectively two tall, because Unicode has block glyphs that fill halves:
█— both halves dark▀— top half only▄— bottom half only- (space) — neither
So the renderer walks the QR matrix two rows at a time and fuses each vertical pair into one character. Dark-dark becomes █, dark-light becomes ▀, light-dark becomes ▄. A 33×33 QR matrix renders as 33 columns × 17 rows of text — physically scannable off any terminal screen, including over SSH, including on a phone.
for (int y = 0; y < height; y += 2) {
boolean top = matrix[y][x];
boolean bottom = pairedRow ? matrix[y + 1][x] : top;
if (top && bottom) sb.append('█');
else if (top) sb.append('▀');
else if (bottom) sb.append('▄');
else sb.append(' ');
}
One subtlety worth stealing: an odd-height matrix leaves a dangling last row, and the naive fix renders it as a halved edge — which can clip the QR’s timing patterns. The fix is to let the final row duplicate itself into the bottom half-slot, so a lone dark module renders as a full block instead of a truncated half. Edge integrity matters more than symmetry.
The encoding itself comes from ZXing core (pure Java, no heavyweight dependency), and the renderer is round-trip tested: render the matrix to text, re-decode the text back into a matrix, assert it matches the original. If the glyph mapping ever drifts, the tests catch it before a user scans an unscannable code.
The custom scheme matters too
phosphor:// isn’t decoration. When the phone’s camera recognizes a URL it offers to open a browser; with a custom scheme, iOS routes the scan straight into the Phosphor app, which registers itself as a handler. The QR becomes a launch button with cargo, not a link to a documentation page.
Why it matters
The whole point of a digital third place is that it belongs to the people who use it — including from the devices they actually carry. A community server you can only reach from a desktop with a stored password file is a place with a door tax. Pairing via a QR code drawn in text characters keeps the door free and the ceremony short: open the screen, scan, done.
The deeper point is that the terminal never stopped being capable. Between half-block glyphs, ANSI control sequences, and a little care about edge cases, a 1970s interaction model absorbed a 2010s authentication pattern without breaking a sweat. The BBS is back, and it speaks modern.
Full post: https://phosphorbbs.net/blog/a-qr-code-on-a-text-screen-pairing-phones-to-a-bbs-without-a-login/
#BBS #terminal #QRcode #ANSIart #opensource #jterm #mobile #digitalthirdplace