Room-scoped accessfor Better Auth.
Someone presenting a room code gets a membership, not an identity. Better Auth owns who someone is, your app owns what each role may do, and Better Room owns who is in which room, and for how long.
Better Auth owns identity.Better Room owns room access.
One plugin on your existing Better Auth instance, one on its client. Sessions, users and sign-in stay exactly where they are.
The hard parts of a room code, done once.
Many products let people into a shared space with just a short code: a classroom quiz, a support session, a game lobby.
- 01Join without an accountAnyone with the code gets a membership. When they sign up, promotion carries it to their account.
promote() - 02Capacity that holdsA seat is claimed in one guarded write, so a race for the last seat never admits two.
maxMembers - 03Codes safe to leak a database overStored only as an HMAC, rate limited, and rotatable with a grace window.
HMAC - 04Authorization, not just membershipLock, close, expire and revoke, with roles that are your own opaque strings.
lockRoom() - 05Realtime stays yoursEvery saved change is reported once, after the write. You broadcast it over your own socket.
onChange
Joining is not authenticating.
Authentication tells your application who someone is. Joining tells one room that someone may act inside it. Better Room keeps the second from ever answering the first.
| Aspect | Authentication · Better Auth | Joining a room · Better Room |
|---|---|---|
| Presents | Credentials: password, OAuth, passkey, magic link | A room code |
| Establishes | A user and a session | A membership in one room, and a room_grant cookie when anonymous |
| Scope | The whole application | One room |
| Answers | Who are you? | May you act here, now? |
From a room to room-scoped access.
Anonymous actors and signed-in users take the same path. A session is used when present, and required only if you turn on join.requireSession.
- 1Room created
auth.api.createRoom() - 2Code captured
returned once, stored as HMAC - 3Code shared
your channel: screen, link, email - 4Actor joins
client.betterRoom.join({ code }) - 5Access checked
getRoomAccess() → authorized - 6Access ends
leave · revoke · close · expiresAt
Create, join, check, listen.
The plaintext code is returned here and nowhere else. Keep it in state, store it encrypted, or rotate when you need it again.
Room codes are secrets.
A code admits strangers, so a database dump should not hand anyone a working key to every live room. And a code that works still cannot say who the actor is, or make them more than a participant.
- Presented
- 'k7qd-2m4p'
- Canonical
- 'K7QD2M4P'
- Identifier
- hmac(subkey(secret), canonical)
- Stored in
- roomCode.identifier
- Plaintext
- never persisted
- Readable onceOnly createRoom and rotateRoomCode return a plaintext code. Nobody can read it afterwards, you included.
- Keyed by your secretThe HMAC uses a subkey derived from your Better Auth secret, so a database dump hands out no working codes.
- Every miss looks the sameA code that never existed, left its grace window or was revoked all answer CODE_DID_NOT_RESOLVE.
- Attempts are budgetedFailures count per address and against a global budget. There is no per-room lockout to turn against members.
- Rooms are invisible to strangersOver HTTP, a caller unrelated to a room gets the same answer whether it exists or not.
- Discovery is not privilegeA code admits as participant. Elevated roles only come from addRoomMember on your server.
Small on purpose.
What a caller may do for itself is a route. What changes someone else’s access is not a route at all.
joinRoom()Present a code; receive a membership.getRoomAccess()May the caller act in this room right now?getRoomOccupancy()Count who holds the room, for its members.listRoomMemberships()The caller’s standing memberships, paged.leaveRoom()Give the seat back; rejoin later.promoteRoomActor()Carry anonymous memberships into an account.createRoom()Create a room and read its code once.addRoomMember()Add an actor with any role.revokeRoomMember()Withdraw a membership for good.rotateRoomCode()Issue a new code; the old one has a grace window.lockRoom() · unlockRoom() · closeRoom()Stop codes, resume them, end the room.reconcileRoomCapacity()Take back seats held past a deadline or by a half-done revocation.Administration is never mounted on the router: a guessed path answers 404. createRoom alone has a route, refused unless you turn on creation.overHttp.
What Better Room deliberately doesn’t do.
Each was considered and left out for a recorded reason, not forgotten.
- Not An identity providerBetter Auth signs people in and owns users and sessions.
- Not A permission engineRoles are opaque strings. What each may do is your policy. Deferred, not rejected.
- Not A realtime serveronChange hands you each change. Connections, retries and fan-out stay yours.
- Not Roles carried by a codeA code admits as participant. Roles are assigned on the server.
- Not A reversible stored codeOnly an HMAC of the canonical code is kept.
- Not A per-room lockoutIt would let anyone who knows a room exists lock its members out.
- Not A metadata JSON columnAdd typed columns through schema additionalFields, or keep it in your own tables.
Extends Better Auth. Doesn’t wrap it.
It runs inside your instance on whatever adapter you gave it, and reads the sessions Better Auth already issues. Better Auth’s own migration or schema generation creates its five tables.
roomActorroomroomCoderoomMemberroomAttemptAuthorization belongs in context.
Add room-scoped access to Better Auth without redefining identity. Open source under Apache-2.0.