DEV Community

Shawn Bure
Shawn Bure

Posted on Originally published at elm.chat

Single-use invite links are capabilities, not permanent room URLs

I created and maintain elm.chat, the open-source project used as the implementation example below. I developed this article with substantial AI assistance, then reviewed the code paths, tested the claims, and take responsibility for the final text.

A permanent room URL is easy to share and impossible to unshare. A safer invitation separates the authority to locate a room, enter it once, decrypt its content, reconnect, and administer it.

This is the design behind elm.chat's current single-use invite flow. The reusable lesson applies to any capability-link system.

Do not make one URL carry every kind of authority

A reusable secret link often answers several questions at once: which room, who may enter, and which key decrypts the conversation. Forwarding that link forwards every capability it contains.

elm.chat gives each concern a different value:

  • Room ID: a path segment used to route the request.
  • Invite token: a query value the room validates for one admission.
  • Room secret: a URL fragment used by browsers to derive the encryption key.
  • Creator token: a separate capability for issuing invites, revoking them, removing participants, and destroying the room.
/c/{roomId}?invite={inviteToken}#{roomSecret}
Enter fullscreen mode Exit fullscreen mode

Browsers do not send the fragment in a normal HTTP request. The relay receives the room ID and invite token but not the room secret through ordinary navigation. This narrows the relay's authority; it does not make the connection anonymous.

Represent an invite as a state machine

An invite is not a Boolean named valid. It has time and ownership semantics.

type Invite = {
  token: string;
  createdAt: number;
  expiresAt: number;
  consumedAt?: number;
  consumedBySessionId?: string;
  revokedAt?: number;
};
Enter fullscreen mode Exit fullscreen mode

Creation requires the creator capability. The room generates the token, applies a ten-minute default lifetime with a one-minute floor, persists the record, and returns it only after storage succeeds.

Consume before admitting

The join transition rejects a missing, revoked, consumed, or expired invite. For the winning session, it writes consumedAt and consumedBySessionId before the first storage await.

if (!invite || invite.revokedAt || invite.consumedAt || invite.expiresAt <= now) {
  rejectJoin();
}

invite.consumedAt = now;
invite.consumedBySessionId = sessionId;
invites.set(invite.token, invite);
await persistInvites();
admitSocket();
Enter fullscreen mode Exit fullscreen mode

That ordering matters. Another join handled by the same room sees the in-memory consumed state immediately, even while persistence is in progress.

Keeping this transition inside one room-scoped Durable Object avoids a separate read-then-write race across stateless workers or database replicas. If your platform does not provide single-owner coordination, use a conditional write or transaction that changes exactly one unconsumed record.

Cloudflare's current Durable Objects rules describe each object as a globally unique, single-threaded coordination point and explain the input and output gates around storage.

The practical guarantee is at most one new session admitted, not magical exactly-once delivery. A failure after persistence can burn an invite without completing the join.

Reconnect is not a second redemption

At-most-once admission without reconnect semantics creates a brittle product: a page reload consumes a second invite or locks out the intended participant.

elm.chat stores a random, room-scoped session ID in browser session storage. The session that consumed the invite may reconnect with the same ID while the invite remains unrevoked; a new session is rejected.

This is continuity, not identity verification. Anyone who obtains the invite before redemption can still win the race, and browser state can be copied or compromised.

Revocation should end current authority too

Revoking an unused invite prevents a future join. Revoking a consumed invite also disconnects its currently connected consuming session.

Otherwise a creator-facing “revoke” button changes a database flag while leaving the admitted participant online.

Room destruction is broader: it closes every socket and ends the room lifecycle. Neither action can erase plaintext a participant already saved, copied, photographed, forwarded, or backed up.

Test the negative paths

  1. Redeem the same invite concurrently from two new sessions; only one may join.
  2. Reload the winning browser; the same session should reconnect without a new redemption.
  3. Open the consumed link in a different session; it must fail.
  4. Revoke an unused invite; later redemption must fail.
  5. Revoke a consumed invite; its connected session must be removed.
  6. Advance beyond expiry; redemption must fail even if the token was never used.
  7. Restart or hibernate the room owner; consumed and revoked state must survive.

If you need funnel evidence, count creation and redemption only in aggregate. Do not put room IDs, invite tokens, participant IDs, or message data into growth analytics.

Inspect the working implementation

The complete TypeScript path is public:

elm.chat has not had an independent security audit. Message authentication and replay/duplicate protection are unfinished, the Cloudflare relay sees ordinary connection metadata, and endpoints can retain copies. It is not an anonymity, high-risk, regulated-data, or production-finance system.

The project is AGPL-3.0-or-later. If you want to test the coordination model, deploy it to your own Cloudflare account and inspect every claim.

Top comments (0)