← all writing
2026-06-26 · 8 min

Your SSH key is already an account: building multiplayer apps over SSH

Every SSH connection proves who the user is before your app runs a byte. I built a small Go framework on that, and here is what treating the key fingerprint as an account makes possible.

Almost every app starts with the same chore. Before a user can do anything, you build a way to know who they are. An email field, a password hash, an OAuth redirect, a confirmation link, a session cookie, a forgot-password flow. It is real work, and none of it is the thing you actually set out to build.

SSH has handled this since the 1990s, and almost nobody builds on it.

When a client connects over SSH, the handshake cryptographically proves that the client holds the private key for the public key it presents. By the time your code runs, the connection has already answered the question every login form exists to answer. The key fingerprint is a stable, unique, verified identifier for that user. There is no email, no password, no signup form, and no third party in the middle. You have an account before the first byte of your application runs.

I kept noticing one-off "ssh into this" demos that used a sliver of this idea and then rebuilt everything else from scratch: presence, broadcasting, storage, rate limiting. So I pulled the common parts into a small Go framework called wharf. Writing apps on top of it changed how I think about a whole category of small, social, terminal-native software.

This is the case for it.

Identity you do not have to ask for

The core move is to treat the public key fingerprint as the primary key for everything.

A fingerprint is not friendly to look at, so wharf derives a stable handle and color from it: the same key always becomes the same brave-otter in the same shade. Two strangers who have never signed up for anything now have persistent, distinct identities for as long as they keep their key. That is enough to build the features a normal app needs an accounts table for: read and unread state, bookmarks, a "continue where you left off" that just works, a per-user history that is simply there when they reconnect tomorrow.

What you get is an account that is anonymous and persistent at the same time. You never learn the person's email, which means you are not holding data you have to protect, but you can still recognize them perfectly across visits. For a lot of small tools, that is the exact tradeoff you want.

The whole identity layer is this: read the key off the connection, hash it into a handle, use the fingerprint as a key into a store. No tables to design, no flows to build.

A room is a single goroutine

Multiplayer is the other half. Once several people are connected, you need to broadcast to everyone in a space and keep an accurate list of who is present. The naive version is a shared map of members guarded by a mutex, and it is a reliable source of races as soon as people join and leave while messages are flying.

wharf models each room as an actor. One goroutine owns the member set. Joining, leaving, and broadcasting are all messages sent to that goroutine over channels. Because a single goroutine is the only thing that ever touches the member map, there are no locks anywhere and no data races by construction. Concurrency becomes message passing instead of shared memory, which is the part of Go that actually holds up under pressure.

The result at the app layer is that broadcasting to a room is one line, and presence is just a side effect of the same join and leave messages the actor already handles. An app author never sees a mutex.

There is a nice second-order effect. Because the room is already the thing serializing every mutation, it is the natural home for shared state. wharf has stateful rooms, where the room holds a value and a reducer, hands each newcomer a snapshot the moment they join, and broadcasts the new state after every action. A live poll, a shared counter, a tiny game board: all of them become a reducer and a render, with the hard concurrency already solved one layer down.

Persistence as a seam, not a dependency

Per-user state needs somewhere to live, and this is where it would have been easy to make a mistake.

The tempting move is to pick a database and wire it in. The problem is that a framework which imports a Postgres driver forces that driver on everyone, including the person who only wanted to keep things in memory for a weekend toy. So persistence in wharf is a small interface, five methods, with the in-memory implementation living in the core and nothing else. Every real backend is its own Go module: SQLite, Postgres, Redis, and an embedded single-file option. Importing the core pulls in no database at all. Importing an adapter pulls in exactly one.

All of them pass a single shared conformance suite, so "swappable" is a property I can prove rather than a word in a readme. I verified the SQLite and Postgres adapters against live databases, and the same per-key history that works in memory survives a restart unchanged when you point it at a file or a server.

This is the kind of decision that looks like fuss for a toy and turns out to be the difference between a library people can adopt and one they have to fork. The cost of getting it right was an afternoon. The cost of getting it wrong is every future user inheriting your dependency choices.

The bug that explained the design

The most useful thing I learned came from a failure.

Early on, presence was occasionally wrong. When two people connected, one of them would sometimes not see the other arrive, even though both were clearly in the room. Broadcasts worked. Identity worked. Only the ordering of who joined when was off, and only sometimes.

The cause was timing. Before a session starts rendering, the terminal layer asks the client a question about its background color and waits for the answer. On a real terminal that reply comes back in milliseconds. On a client that never answers, the code waits for the timeout, and in my first version that wait happened before the session joined its room. So a slow or silent client would join late, after someone who connected after it, and the join and leave events arrived out of order.

The fix was to join the room before negotiating anything about rendering, so presence never waits on a cosmetic question to the terminal. It is a small change, but it only became obvious once I stopped assuming that "connected" and "in the room" happen at the same instant. The lesson generalized. The moment you decouple identity from rendering, a class of ordering bugs disappears, and the whole system gets easier to reason about.

I keep this story in the writeup because the framework is more trustworthy with the scar visible than without it.

What it is good for, and what it is not

wharf is not a chat library, even though chat is the obvious demo. The primitives are identity, presence, rooms, shared state, and storage, and those fit a lot of things. The examples I shipped are a collaborative drawing canvas where you watch other people's cursors move, a keyless chat room, and a live poll. The same shapes cover guestbooks, small multiplayer games, status boards, internal tools your team reaches with the keys they already have, and a personal assistant you talk to in your terminal.

There is one honest limitation worth stating plainly. SSH is pull-only. Nobody re-runs a command on a Tuesday for no reason, so an SSH app cannot tap someone on the shoulder the way an email or a push notification can. The reading experience is excellent and the retention is near zero unless you add a way back in. In practice that means pairing the SSH front door with something that can reach out, a small web mirror for discovery, or a notification when there is an actual reason to return. The terminal is the experience. It is not the reminder.

Why bother

The code was the easy part. The thing worth sharing is the reframing.

A large amount of the software we build spends its first few weeks rebuilding identity, and a large amount of that machinery exists only because the web has no built-in notion of who is on the other end. SSH does. It has had a clean, verified, cryptographic answer to "who is this" for decades, sitting one port over from where everyone is already working. wharf is a small bet that this answer is more useful than we treat it, and that there is a category of fast, personal, multiplayer terminal apps waiting on the other side of taking it seriously.

It is open source under the MIT license. If you want to read the actor model, the adapter split, or the demos, the repository is at github.com/doganarif/wharf. If you build something odd with it, I would like to see it.

New writing in your inbox.

Roughly monthly. Backend, AI, production systems. No spam.

Email to subscribe