On this pageIntroduction
Getting started

Introduction

Better Room is room-scoped contextual authorization for Better Auth. Someone presenting a room code gets a membership, not an identity: joining is not authenticating.

Better AuthWho someone is.
Your applicationWhat each role may do.
Better RoomWho is in which room, and for how long.

It is not an identity provider, a permission engine or a realtime server. It is the hard parts of letting people into a shared space with a short code: joining without an account, capacity that holds under a race, codes safe to leak a database over, and a change signal you broadcast yourself.

Installation

Better Room uses your application’s copy of Better Auth, so it needs an existing Better Auth setup.

$ npm install better-room

Yarn does not add peer dependencies for you. With Yarn, also run yarn add better-auth @better-auth/core better-call zod.

Setup

Add the plugin to your Better Auth instance, and its client plugin to your auth client.

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()]
})

Then run Better Auth’s migration or schema generation so the five room tables exist. Call betterRoom() once per betterAuth() instance: the code key is derived from the secret of the instance it first binds to.

Concepts

The model

Five terms, resolved in one direction. Each depends only on the one before it.

  1. Identity
  2. Actor
  3. Room
  4. Membership
  5. Access
Identity
Who someone is: a user and a session. Owned by Better Auth.
Actor
Whoever acts in a room. Anonymous, or the single actor a user owns. Owned by Better Room.
Room
A bounded context: active, locked or closed, with optional maxMembers and expiresAt. Owned by Better Room.
Membership
One actor in one room, with a role, an optional deadline, and leftAt or revokedAt once it ends. Owned by Better Room.
Access
Whether this actor may act here, now. Decided from the membership and the room when you ask. Owned by Better Room.

Roles are an open set of opaque strings. A join gets participant; any other role, such as facilitator, comes from addRoomMember on your server. room.createdBy is provenance and grants no authority: there is no host, only the roles your application names.

Joining is not authenticating

Better Auth establishes identity and sessions. Better Room resolves a code into a membership in one room. A caller with a session joins as their user’s actor; one without gets an anonymous actor and a signed grant cookie. Joining never creates a user.

Authentication compared with joining a room
AspectAuthenticationJoining a 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?
join.requireSession

Turn it on to refuse callers without a session as JOIN_NEEDS_A_SESSION. The refusal comes after the code resolves, so an unknown code still counts against the attempt budgets, and before anything is written. Anonymous members admitted earlier keep their access until it expires or is revoked.

When an anonymous member signs in, call promote so their memberships follow them to their account.

Room states

A room is active, locked or closed, and may carry a maxMembers and an expiresAt. A membership ends when its actor leaves, is revoked, or passes its own deadline.

active
Admits joins by code and additions.
locked
Refuses codes. Members stay; addRoomMember still adds.
closed
Ended for good. Cannot be undone or rotated.
past expiresAt
Not a stored status: the clock decides, so no write and no event marks it.

Leaving keeps the row and lets the same actor rejoin. Revoking is permanent. Show occupied from getRoomOccupancy as the number of people in a room, not room.memberCount, which is the admission gate and may sit above real occupancy after a write that failed halfway.

Capturing the code

Plaintext codes are returned only by createRoom and rotateRoomCode. No reversible copy exists anywhere, so if you drop one, the only way to get a working code for that room is to rotate. Four ways applications handle it, in rough order of how often they fit:

  1. 1Return it to the creator. Hold it in client state while the waiting screen lives. Enough for a code read out loud.
  2. 2Store it next to your room. Encrypted with a key you control, when a host must see the same code days later.
  3. 3Rotate on demand. Show the new code. The previous one still admits for the grace window.
  4. 4Share it at creation. Send it through your own link or invitation email and never persist it.
API reference

Operations

Self-service operations are routes, confined to the caller’s own actor, and grant nothing the caller did not already hold. The administrative operations are not routes at all: Better Auth never mounts them, a guessed path answers 404, and the client does not offer them. Your backend calls them through auth.api after deciding who may. createRoom is the one exception: it has a route, which refuses with CREATION_IS_SERVER_ONLY unless creation.overHttp is on.

Operations, routes and who can reach them
OperationRoute
createRoom()POST /better-room/create
joinRoom()POST /better-room/join
getRoomAccess()GET /better-room/access
getRoomOccupancy()GET /better-room/occupancy
listRoomMemberships()GET /better-room/memberships
leaveRoom()POST /better-room/leave
promoteRoomActor()POST /better-room/promote
addRoomMember()none, auth.api only
revokeRoomMember()none, auth.api only
rotateRoomCode()none, auth.api only
lock · unlock · closenone, auth.api only
reconcileRoomCapacity()none, auth.api only

Endpoints return reports, not rows. Each body is built field by field, so a column you add never leaks into a response. RoomReport, MembershipReport and their siblings are exported, and the client infers them.

createRoom()

Server-onlyPOST /better-room/create

Creates a room and returns its plaintext code, the only time it is readable. Every field is optional: userId is provenance only and grants no authority. With creation.overHttp on, a signed-in caller can create over HTTP and is recorded as the creator.

server
const { room, code } = await auth.api.createRoom({
body: {
userId: creator.id,
maxMembers: 8,
expiresAt: new Date(Date.now() + 3_600_000)
}
})
Returns
{ room: RoomReport, code: string }

joinRoom()

Self-servicePOST /better-room/join

Presents a code and returns a membership with the participant role. An anonymous caller gets a signed room_grant cookie, valid for grant.lifetime. A caller with a session resolves to that user’s actor and carries no grant.

client
const { data, error } = await client.betterRoom.join({
code: 'k7qd-2m4p'
})
if (error) throw new Error(error.message)
Returns
{ membership: MembershipReport }

getRoomAccess()

Self-serviceGET /better-room/access

Says whether the caller may act in the room right now. Over HTTP the room is reported only to a caller related to it; anyone else gets authorized: false with null room and membership. Through auth.api any room is reported in full, and a missing one throws UNKNOWN_ROOM.

client
const { data } = await client.betterRoom.access({
query: { roomId }
})
if (data?.authorized) {
// let them in
}
Returns
{
authorized: boolean,
room: RoomReport | null,
membership: MembershipReport | null
}

getRoomOccupancy()

Self-serviceGET /better-room/occupancy

Counts the memberships holding the room right now, from the memberships themselves. Show occupied, not room.memberCount. It counts memberships, not presence. Over HTTP only members may read it.

client
const { data } = await client.betterRoom.occupancy({
query: { roomId }
})
Returns
{ roomId: string, occupied: number, maxMembers: number | null }

listRoomMemberships()

Self-serviceGET /better-room/memberships

Lists the caller’s standing memberships, each paired with its room. Pages read up to 200 memberships; follow next as before until it is null, even past an empty page.

client
let before: string | undefined
do {
const { data } = await client.betterRoom.memberships({
query: { before }
})
before = data?.next ?? undefined
} while (before !== undefined)
Returns
{
memberships: { membership, room }[],
complete: boolean,
next: string | null
}

leaveRoom()

Self-servicePOST /better-room/leave

Gives the caller’s seat back and keeps the membership row, so the same actor can rejoin. Leaving twice returns no second seat. It never overrides a revocation or an expiry.

client
const { error } = await client.betterRoom.leave({ roomId })
Returns
{ membership: MembershipReport }

promoteRoomActor()

Self-servicePOST /better-room/promote

Merges the anonymous actor into the signed-in user’s and carries its memberships across. It needs both the grant and the session on the request. Where both hold a membership in one room, the authenticated one is kept by a fixed rule, never by role. Past 10000 memberships complete is false, and your server finishes with auth.api.promoteRoomActor({ body: { actorId: merged, userId } }).

client, after sign-in
const { data, error } = await client.betterRoom.promote()
if (error) throw new Error(error.message)
const { carried, discarded, complete, merged } = data
Returns
{ actorId, merged, carried, discarded, complete }

addRoomMember()

Server-only

Adds an actor named by exactly one of userId or actorId, with any role and an optional expiresAt. It takes a seat like a join and works on a locked room. It is the only way to grant an elevated role.

server
const { membership } = await auth.api.addRoomMember({
body: { roomId, userId: invitee.id, role: 'facilitator' }
})
Returns
{ membership: MembershipReport }

revokeRoomMember()

Server-only

Withdraws a membership permanently and returns its seat. A later join by that actor is refused as MEMBERSHIP_REVOKED; a second revocation as ALREADY_REVOKED.

server
await auth.api.revokeRoomMember({ body: { roomId, actorId } })
Returns
{ membership: MembershipReport }

rotateRoomCode()

Server-only

Issues a new code. The previous one keeps admitting for code.grace seconds, so a code already shared does not break at once. Rotating again ends that window. A locked room can be rotated; a closed or expired one cannot.

server
const { code } = await auth.api.rotateRoomCode({ body: { roomId } })
Returns
{ code: string }

lockRoom() · unlockRoom() · closeRoom()

Server-only

Locking refuses joins by code and keeps the members already in; addRoomMember still adds. Closing ends the room and cannot be undone. Asking for the state a room already has succeeds without a write.

server
await auth.api.lockRoom({ body: { roomId } })
await auth.api.unlockRoom({ body: { roomId } })
await auth.api.closeRoom({ body: { roomId } })
Returns
{ room: RoomReport }

reconcileRoomCapacity()

Server-only

Collects seats still held by memberships whose deadline passed, or whose revocation stopped halfway. Runs in batches of 1 to 1000, 200 by default; call it until owing comes back 0.

server, on a schedule
const { owing, released } = await auth.api.reconcileRoomCapacity({
body: { batch: 200 }
})
Returns
{ owing: number, released: number }
Configuration

Options

Every option is optional. These are the defaults.

auth.ts
betterRoom({
code: {
format: 'crockford', // or 'numeric'
length: 8,
grace: 120
},
grant: { lifetime: 60 * 60 * 24 * 7 },
creation: { overHttp: false },
join: { requireSession: false },
attempts: { window: 60, perIp: 10, everyone: 600 },
schema: {
// rename tables and columns; room and roomMember take additionalFields
},
onChange: event => {}
})
Option defaults and ranges
OptionDefaultRange
code.format'crockford''crockford' or 'numeric'
code.length8, or 6 for numeric1 to 64 symbols
code.grace1201 to 86400 seconds
grant.lifetime6048001 to 34560000 seconds
creation.overHttpfalseboolean
join.requireSessionfalseboolean
attempts.window601 to 86400 seconds
attempts.perIp101 to 2147483647
attempts.everyone6001 to 2147483647

Options are checked when betterRoom() runs: a number out of range throws a RangeError, and a flag that is not a real boolean, an unknown format or a reserved additionalFields name throws a TypeError. Every duration has a ceiling, because a value that overflows date arithmetic silently disables what it configures. crockford uses 32 symbols without look-alike letters and folds I and L to 1, O to 0; numeric is digits only, for keypads and phone prompts.

Realtime

Connections

Commands travel over HTTP and live updates over your own socket. The plugin owns no transport. It gives you the two halves every integration needs: a way to authorize a connection, and a signal for every change.

client ── POST join ────────────▶ better-room ── onChange ──▶ your broker ──▶ your sockets client ── open socket (cookies) ─▶ your server ── getRoomAccess ──▶ better-room

Check the room when the connection opens, with the headers the upgrade carried. Cookies travel on a same-origin upgrade, so a grant and a session resolve exactly as they do over HTTP.

server/socket.ts
const access = await auth.api.getRoomAccess({
query: { roomId },
headers: request.headers
})
if (!access.authorized) {
// refuse the upgrade, or close with your own code
}
// access.membership.actorId names the actor this socket speaks for

Authorization is decided when you ask, so a revoked member keeps a live socket until you close it. Keep each socket’s roomId and actorId, and close on the events that end access. A locked room keeps its members.

auth.ts
betterRoom({
onChange: event => {
if (event.type === 'revoked' || event.type === 'left') {
sockets.close({ roomId: event.roomId, actorId: event.membership.actorId })
}
if (event.type === 'closed') sockets.close({ roomId: event.roomId })
}
})

Events

onChange runs after every change the plugin saves, typed as RoomEvent. A switch on event.type narrows each one.

Events raised by onChange
typeFieldsRaised when
joinedroomId, membershipA code admitted an actor, including a rejoin.
addedroomId, membershipaddRoomMember added one.
leftroomId, membershipA member left.
revokedroomId, membershipA membership was revoked.
expiredroomId, membershipReconciliation took back a seat whose deadline passed.
createdroomId, roomA room was created. Its code is never in an event.
locked · unlocked · closedroomId, roomThe room changed state.
rotatedroomIdThe code was rotated. The new code is never in an event.
promotedactorId, mergedAn anonymous actor was merged into actorId.
erasedactorIdThe actor of a deleted user was erased.
  • Fires once per saved change, after the write, never for a refusal or a call that changed nothing.
  • Runs in the process that handled the request, and the request waits for it. Hand slow work to a queue; with several instances, publish to your own broker.
  • A listener that throws is logged and cannot undo the change or fail the request. Events are not retried.
  • A deadline raises nothing when it passes. Schedule your own timer from expiresAt if clients must hear it at that moment.
Security

Room codes

A room code is a secret that admits strangers. It is stored as an HMAC of its canonical form, keyed by a subkey derived from your Better Auth secret, so a database dump does not hand out a working key to every live room.

Presented
'k7qd-2m4p'
Canonical
'K7QD2M4P'
Identifier
hmac(subkey(secret), canonical)
Stored in
roomCode.identifier
Plaintext
never persisted
  • Codes are folded through a fixed ASCII table, so case, spaces and dashes do not matter, and any other character is refused.
  • Rotating keeps the previous code admitting for code.grace seconds, 120 by default. Only one generation is kept in grace.
  • Over HTTP, a caller with no relation to a room gets the same answer whether it exists or not, so room ids cannot be walked.

Brute force and privilege

A failed code counts twice: against the caller’s address and against a global budget. An address that spends its own ceiling waits for its window to roll over. The global ceiling only gates an address with a failure of its own, so a flood cannot lock out callers who never guessed. There is no per-room lockout, because that would let anyone who knows a room exists lock its members out.

Every failure to resolve is the same CODE_DID_NOT_RESOLVE; telling them apart would roughly halve the work of sweeping the code space. Once a code resolves, the refusal names its reason, because the caller has already proved the code is good.

Discovery is not privilege. A code admits as participant. Elevated roles only come from addRoomMember, called by your backend after your own authorization checks.
Reference

Error codes

Reachable as auth.$ERROR_CODES on the server and as ROOM_ERROR_CODES from both entry points. On the client, use ROOM_ERROR_CODES for values; client.$ERROR_CODES is for types only.

app/join.ts
import { ROOM_ERROR_CODES } from 'better-room/client'
if (error?.code === ROOM_ERROR_CODES.ROOM_AT_CAPACITY.code) {
// show that the room is full
}
Error codes and HTTP statuses
CodeStatusRaised when
CODE_DID_NOT_RESOLVE400The code matched no live room.
EXACTLY_ONE_IDENTITY400A member was named by both userId and actorId, or neither.
NO_GRANT_TO_PROMOTE400Promotion arrived without a grant to merge.
RESUME_NEEDS_BOTH_NAMES400Resuming a promotion named only one of the two.
MEMBERSHIP_EXPIRES_IN_THE_PAST400An added membership would expire before it began.
ROOM_EXPIRES_IN_THE_PAST400A created room would expire before it began.
PROMOTION_NEEDS_A_SESSION401Promotion arrived without a session.
CREATION_NEEDS_A_SESSION401Creation over HTTP arrived without a session.
JOIN_NEEDS_A_SESSION401A join arrived without a session while join.requireSession is on.
ROOM_LOCKED403The room refuses joins by code.
ROOM_CLOSED403The room has ended.
ROOM_EXPIRED403The room’s deadline passed.
MEMBERSHIP_REVOKED403The membership was withdrawn.
MEMBERSHIP_EXPIRED403The membership’s own window closed.
CREATION_IS_SERVER_ONLY403Creation was called over HTTP while overHttp is off.
RESUME_IS_SERVER_ONLY403Resuming a promotion was called over HTTP.
NOT_A_MEMBER403 / 404Nobody holds the membership the call names.
UNKNOWN_ROOM404No room has that id.
UNKNOWN_ACTOR404No actor has that id, or it was erased mid-request.
ROOM_AT_CAPACITY409The room has no seat left.
ROOM_CONTENDED409The room or membership kept changing while the request settled it.
ALREADY_A_MEMBER409The actor already holds a membership there.
ALREADY_LINKED409The actor already belongs to a user.
ALREADY_REVOKED409The membership was already revoked.
GRANT_IS_STALE409The grant no longer matches the actor it names.
TOO_MANY_ATTEMPTS429The caller’s attempt budget is spent.
CODE_SPACE_EXHAUSTED503No free code was found for this room.

Database

Five tables, declared through Better Auth’s plugin schema, so the official migration and schema generators create them. Row ids are Better Auth’s to generate, so every generateId mode works.

roomActor
One per anonymous participant, or one per user. userId is unique and deliberately not a foreign key.
room
Status, memberCount gate, optional maxMembers and expiresAt, and createdBy as provenance.
roomCode
The HMAC in a unique identifier column, and a status that rotation walks.
roomMember
Unique on (roomId, actorId), so two simultaneous joins collapse into one.
roomAttempt
The brute-force budgets. Required: joining does not work without it.

Four unique indexes carry behaviour, not just speed: roomMember (roomId, actorId), roomCode.identifier, roomAttempt.key and roomActor.userId. Drizzle, Kysely and Better Auth’s Prisma generation emit them. A hand-written Prisma schema must carry all four, and on MongoDB the roomActor.userId index must be created by hand as a partial unique index. The full details live in the reference.

What Better Room deliberately doesn’t do

  • 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.