v0.2.1A Better Auth plugin

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.

auth.ts
import { betterAuth } from 'better-auth'
import { betterRoom } from 'better-room'
export const auth = betterAuth({
database,
plugins: [betterRoom()]
})
auth-client.ts
import { createAuthClient } from 'better-auth/client'
import { betterRoomClient } from 'better-room/client'
export const client = createAuthClient({
plugins: [betterRoomClient()]
})
Why use it

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.

  1. 01promote()
    Join without an accountAnyone with the code gets a membership. When they sign up, promotion carries it to their account.
  2. 02maxMembers
    Capacity that holdsA seat is claimed in one guarded write, so a race for the last seat never admits two.
  3. 03HMAC
    Codes safe to leak a database overStored only as an HMAC, rate limited, and rotatable with a grace window.
  4. 04lockRoom()
    Authorization, not just membershipLock, close, expire and revoke, with roles that are your own opaque strings.
  5. 05onChange
    Realtime stays yoursEvery saved change is reported once, after the write. You broadcast it over your own socket.

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.

Authentication compared with joining a room
AspectAuthentication · Better AuthJoining a room · Better Room
PresentsCredentials: password, OAuth, passkey, magic linkA room code
EstablishesA user and a sessionA membership in one room, and a room_grant cookie when anonymous
ScopeThe whole applicationOne room
AnswersWho are you?May you act here, now?
Lifecycle

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.

  1. 1Room createdauth.api.createRoom()
  2. 2Code capturedreturned once, stored as HMAC
  3. 3Code sharedyour channel: screen, link, email
  4. 4Actor joinsclient.betterRoom.join({ code })
  5. 5Access checkedgetRoomAccess() → authorized
  6. 6Access endsleave · revoke · close · expiresAt
In code

Create, join, check, listen.

server/rooms.tsauth.api
const { room, code } = await auth.api.createRoom({
body: {
maxMembers: 8,
expiresAt: new Date(Date.now() + 3_600_000)
}
})
// Keep `code` now. It is never readable again.
Returns
{
room: {
status: 'active',
memberCount: 0,
maxMembers: 8,
…
},
code: 'K7QD2M4P'
}

The plaintext code is returned here and nowhere else. Keep it in state, store it encrypted, or rotate when you need it again.

Security

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.
API surface

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.

Self-serviceHTTP and client, own actor only
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.
Server-onlyauth.api, after your own checks
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.

Boundaries

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.

betterAuth()your instance
userssessionsdatabase
plugins…yoursbetterRoom()
addsroomActorroomroomCoderoomMemberroomAttempt

Authorization belongs in context.

Add room-scoped access to Better Auth without redefining identity. Open source under Apache-2.0.