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.

tsx
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:

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.

FieldTypeDefaultDescription
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
apiKeystringFirebase 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) => voidCalled 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

Complete Example #

authTokenProvider fetches from your own backend, and onAuthError rejoins the room — rejoining is the documented recovery, not logging.

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

FieldValue
uidAn 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__:

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

json
{
  "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"
          }
        }
      }
    }
  }
}

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.

ts
// 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 #

SituationWhat the strategy doesWhat 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 cachedNothing required — a later join retries from scratch
permission_denied on the presence re-armRuns exactly one re-auth cycle for that connection, then rewrites presenceNothing, unless the re-auth also fails
A database listener is cancelledCalls onAuthError, plus one re-auth cycle when the cancellation is a permission_denied. The listener is not re-subscribedRejoin the room — that is what rebuilds the subscriptions
The lobby listener is cancelledCalls onAuthError with no re-auth cycleTreat as a rules decision, not a token problem

Two details worth internalizing:


Caveats #


Anonymous Stays Anonymous #

If you do not set authTokenProvider, none of this applies:

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 #