Authenticated Signaling
The Firebase signaling strategy can sign in with a Firebase Auth custom token before touching the database, so security rules can scope a room to its players.
Authentication is opt-in and purely additive. It changes nothing about the peer-to-peer layer — WebRTC still carries all game traffic — and nothing about MQTT signaling. It only changes who Firebase believes you are while peers are discovering each other.
import { MultiplayerProvider, useRoom } from "@carverjs/multiplayer";Why Authenticate #
By default the Firebase strategy connects to your Realtime Database anonymously. That is why Getting Started keeps the anonymous setup to local development against test-mode rules: with no identity attached to the connection, the only ruleset that works is one that lets everybody read and write everything.
For a prototype that is fine. In production it means any client that knows your database URL can:
read every room's presence and signaling traffic, including rooms it never joined;
write into another room's signaling inbox;
delete presence entries and lobby announcements belonging to other players.
A custom token fixes this by giving the connection an identity with claims your rules can test. The room subtree becomes writable only by the players your backend put in that room. Everything else about the strategy stays exactly as documented in Configuration.
Strategy Options #
Three optional fields on FirebaseStrategyConfig. Omit all three and the strategy signals anonymously: no token is fetched and firebase/auth is never imported.
| Field | Type | Default | Description |
|---|---|---|---|
authTokenProvider | () => Promise<string> | — | Returns a Firebase Auth custom token to sign in with before any database traffic. Must return a fresh token on every call — never a cached one |
apiKey | string | — | Firebase Web API key. Required whenever authTokenProvider is set and you do not supply your own firebaseApp. A public project identifier, not a secret |
onAuthError | (error: Error) => void | — | Called when authentication fails in a way the strategy cannot recover from on its own: a failed sign-in during init, a failed re-auth cycle, or a cancelled database listener |
apiKey is a public project identifier, not a credential. It ships in the bundle of every Firebase web app, and access control is the job of your security rules, not the key. It is required here because Firebase Auth cannot sign in on an app initialized with databaseURL alone — a missing key fails init() with a clear error instead of a confusing internal one. It must still arrive through config (env, shell, or your server), never hardcoded in engine or game source. Pass your own configured firebaseApp instead and the field is unnecessary.
Complete Example #
authTokenProvider fetches from your own backend, and onAuthError rejoins the room — rejoining is the documented recovery, not logging.
import { useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import { Game } from "@carverjs/core/components";
import { MultiplayerProvider, useRoom } from "@carverjs/multiplayer";
// The room this client is in, readable from callbacks that live outside the
// React tree. Your backend needs it to mint the right claims.
const currentRoomId = { current: null as string | null };
function App() {
const rejoin = useRef<() => void>(() => {});
// The provider creates the signaling strategy once, on mount. Memoising the
// config keeps that object's identity stable rather than relying on it.
const strategy = useMemo(
() => ({
type: "firebase" as const,
databaseURL: import.meta.env.VITE_RTDB_URL,
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authTokenProvider: async () => {
const res = await fetch("/api/signaling-token", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: currentRoomId.current }),
});
if (!res.ok) throw new Error(`token endpoint returned ${res.status}`);
const { token } = await res.json();
return token;
},
onAuthError: (error: Error) => {
console.error("[signaling] auth failed", error);
// A cancelled listener is not re-subscribed. Rejoining is what
// rebuilds the subscriptions.
rejoin.current();
},
}),
[],
);
return (
<Game>
<MultiplayerProvider appId="my-game" strategy={strategy}>
<Session rejoin={rejoin} />
</MultiplayerProvider>
</Game>
);
}
function Session({ rejoin }: { rejoin: MutableRefObject<() => void> }) {
// useRoom(roomId?, options?) — no roomId, so nothing auto-joins and the
// room is entered by calling join() yourself.
const { roomId, join, leave } = useRoom(undefined, { displayName: "Player" });
const rejoining = useRef(false);
useEffect(() => {
if (roomId) currentRoomId.current = roomId;
}, [roomId]);
useEffect(() => {
rejoin.current = async () => {
const target = currentRoomId.current;
// Several listeners can be cancelled at once, so onAuthError can fire
// more than once for a single failure. Rejoin once, not per error.
if (!target || rejoining.current) return;
rejoining.current = true;
try {
leave(); // synchronous
await join(target);
} finally {
rejoining.current = false;
}
};
}, [join, leave, rejoin]);
return null; // your lobby and scene go here
}Claims Contract #
Your backend mints the token. The strategy only fetches and presents it — it never inspects the claims, and neither does the engine. The contract exists so your rules have something stable to bind to.
| Field | Value |
|---|---|
uid | An opaque per-player-session id. No PII. This is also the handle you revoke with, so store it against the session |
roomId (claim) | The room this token grants access to |
ns (claim) | The namespace — the appId you pass to <MultiplayerProvider> |
Both claims are required. Any other claim you add (a role, a seat number) is informational: the rules below ignore it.
Database Paths #
The strategy writes under ${appId}/__carver__:
| Path | Written by |
|---|---|
<appId>/__carver__/lobby/<roomId> | The host, announcing the room |
<appId>/__carver__/rooms/<roomId>/peers/<peerId> | Each player's presence node |
<appId>/__carver__/rooms/<roomId>/signals/<peerId> | Peers sending SDP and ICE to that player |
An appId containing a slash simply nests deeper, which is how you scope rules per namespace.
Example Ruleset #
A minimal starting point, bound to the two claims. Adapt it — this is the shape, not a finished ruleset for your project.
{
"rules": {
"$ns": {
"__carver__": {
"lobby": {
".read": "auth != null && auth.token.ns === $ns",
"$roomId": {
".write": "auth != null && auth.token.ns === $ns && auth.token.roomId === $roomId"
}
},
"rooms": {
"$roomId": {
".read": "auth != null && auth.token.ns === $ns && auth.token.roomId === $roomId",
".write": "auth != null && auth.token.ns === $ns && auth.token.roomId === $roomId"
}
}
}
}
}
}$ns matches a single path segment. If your appId contains a slash, the tree nests one level further and your rules must nest to match. Denying the lobby subtree entirely is a legitimate choice when your backend hands out room ids instead of letting clients browse — the strategy treats a denied lobby as a rules decision, not a stale token.
Minting Tokens on Your Backend #
Custom tokens are minted with the Firebase Admin SDK, which requires a service account key. That key can mint a token for any uid with any claims, and it bypasses your security rules entirely. Shipping it to the browser — in the bundle, in an env var exposed to the client, in a config endpoint — hands anyone with devtools the ability to impersonate every player and read the whole database. It stays on your server. Always.
// server/signaling-token.ts — runs on YOUR backend, never in the browser.
import { randomUUID } from "node:crypto";
import { initializeApp, cert } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
initializeApp({
credential: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!)),
});
app.post("/api/signaling-token", async (req, res) => {
const player = await authenticateRequest(req); // your own session check
const { roomId } = req.body;
// The authorization decision lives here, not in the rules. The rules only
// enforce the claims you decided to hand out.
if (!player || !mayJoinRoom(player, roomId)) return res.status(403).end();
// Opaque, per-player-session, no PII — and the handle revocation targets.
const uid = await getOrCreateSignalingUid(player, roomId, randomUUID);
const token = await getAuth().createCustomToken(uid, {
roomId,
ns: "my-game", // must equal the appId passed to MultiplayerProvider
});
res.json({ token });
});Return a newly minted token on every request. The client calls this endpoint again whenever it needs to re-authenticate, and a cached token is exactly what will not work then.
Failure and Recovery #
| Situation | What the strategy does | What your app does |
|---|---|---|
Provider or sign-in fails during init() | Retries the pair up to 4 times total (250ms, 1s, 4s backoff), then calls onAuthError and rejects. The failed init is not cached | Nothing required — a later join retries from scratch |
permission_denied on the presence re-arm | Runs exactly one re-auth cycle for that connection, then rewrites presence | Nothing, unless the re-auth also fails |
| A database listener is cancelled | Calls onAuthError, plus one re-auth cycle when the cancellation is a permission_denied. The listener is not re-subscribed | Rejoin the room — that is what rebuilds the subscriptions |
| The lobby listener is cancelled | Calls onAuthError with no re-auth cycle | Treat as a rules decision, not a token problem |
Two details worth internalizing:
One cycle per connection. A burst of
permission_deniedevents produces a single re-auth, and a cycle that has already failed is not retried until the database reports a new connection. A denied room does not turn into a token-fetching loop.onAuthErroris a rejoin signal, not a log line. Once a listener is gone, that peer silently stops discovering peers and receiving signals. It looks to the player like a game that just never connects. Rejoining is the recovery.
Caveats #
A custom token expires in one hour. The session does not. signInWithCustomToken exchanges the token for a refresh token that the SDK renews indefinitely, carrying the same claims forever.
Your security rules cannot see this — to them the claims simply remain valid. If access has to end when a game session ends, that is a server-side job:
call
revokeRefreshTokens(uid)with the Admin SDK when the session ends, ormint a time-bound claim (an expiry timestamp) and have your rules compare it against
now.
Doing neither means a player who joined once holds room access indefinitely.
Rules that grant write access at room scope grant it to every member of that room — that is the granularity Realtime Database rules operate at. Within a room, presence and signal entries are mutually writable, so a co-player can overwrite fields on your presence node, displayName included. Do not treat anything read from the signaling layer as trustworthy player-supplied data.
The strategy defends the one case that is unrecoverable: it watches its own presence node and rewrites it if it disappears. Without that, a deleted presence entry evicts you from every peer's list with no error raised and no reconnect to trigger recovery — you become a ghost for the rest of the session. Self-healing covers deletion; it does not make the node's fields tamper-proof.
Anonymous Stays Anonymous #
If you do not set authTokenProvider, none of this applies:
no config change is needed —
databaseURLon its own is still a complete setup;no token is ever fetched and no sign-in ever happens;
firebase/authis never imported, so the anonymous bundle does not grow.
The auth import sits inside the branch that only runs when a provider is present, precisely so bundlers can drop it. The recovery paths above go with it: with no token to refresh, a denied write is not retried, and a cancelled listener is still reported through onAuthError — if you supplied one — but never re-subscribed.
What's Next #
Configuration — every option in the multiplayer system, including error codes and their recovery paths.
Getting Started — signaling strategies, TURN servers, and the rest of the setup surface.