Nebula

Authentication and player identity

Give every player a stable identity across sessions, either anonymously or from an OpenID Connect provider such as Google, Auth0, Firebase, or Clerk.

Nebula gives every connected player an identity: a 64-character string that stays the same each time that player connects, on every worker and client. Use it to key saved characters, inventories, and accounts. The client ID (PlayerInfo.ClientId, NebulaClient.ClientId) is a session ID: it lasts for one session, including a reconnection within that session, and a new session gets a new one. The identity lasts across sessions.

The gateway establishes the identity when a client connects. There are two ways to get one:

ModeHow it worksUse it when
Anonymous (default)The gateway issues a token the first time a client connects. The client stores it in PlayerPrefs and presents it on later connections.You want players to keep progress without a sign-in flow, or during development.
OpenID ConnectYour game signs the player in with a provider, gets an ID token, and passes it to NebulaClient. The gateway checks the token against the provider's published keys.You need real accounts, cross-device play, or a login that other services share.

Both modes can be on at the same time. A client that presents an ID token is identified by it; a client that presents nothing gets an anonymous identity.

How the identity is derived

The identity is the SHA-256 hash of the token's issuer and subject claims, written as lowercase hex. PlayerIdentity.Derive(issuer, subject) computes it. The same account at the same provider always yields the same identity, and two providers can never collide. Anonymous identities use the issuer nebula and a random subject.

Nebula never stores the provider's user ID, email, or name. To read a few claims of the token on workers, such as a role or a username, list them in ForwardedClaims (see Forward token claims to workers). For anything else, read the profile from the provider yourself.

Read the identity in your game

On a worker, override the PlayerInfo overload of OnSpawnPlayer. player.Identity is the stable identity; player.ClientId is the session ID, a ulong that stays the same when the player reconnects within the session (see Sessions and reconnection).

ExampleGameMode.cs
public sealed class ExampleGameMode : NebulaGameMode
{
    public GameObject PlayerPrefab;

    public override NetworkIdentity OnSpawnPlayer(NebulaWorker worker, in PlayerInfo player, Container container)
    {
        var identity = NetworkPrefabs.Instantiate(
            NetworkPrefabs.IdOf(PlayerPrefab),
            NebulaContainerHelpers.RandomPointIn(container, margin: 2f, y: 0f),
            Quaternion.identity,
            container.transform);
        identity.name = $"Player {player.Name}";
        // Key the saved character on the identity so the player finds it again next session.
        identity.GetComponent<PersistentEntity>().Key = "player:" + player.Identity;
        worker.Spawn(identity, container, player.ClientId);
        return identity;
    }
}

A game mode that overrides the four-argument OnSpawnPlayer(worker, ulong clientId, playerName, container) keeps working: the PlayerInfo overload calls it by default. Override one or the other.

Every spawned entity carries its owner's identity in NetworkIdentity.OwnerIdentity, on workers, ghosts, and clients alike. It is empty for entities no player owns.

On a client, NebulaClient.Identity and NebulaRuntime.LocalIdentity hold the local player's identity once the gateway has welcomed the client.

See Persistence for how keys select saved records.

Anonymous identities

With the default configuration, a client that connects without a token gets a new identity and a token for it in the gateway's welcome. The client saves the token under the PlayerPrefs key nebula.identityToken and presents it on every later connection, so the player stays the same across restarts.

Bots started with -nebula-bot do not save or reuse tokens. Each bot process gets a fresh identity, so several bots on one machine never become the same player.

Call NebulaClient.ForgetStoredToken() to drop the saved token. The next connection gets a new identity, and the old player's saved entities stay behind. Use it for a "reset progress" button.

The signing key

Anonymous tokens, and the session tokens described below, are signed with a key every gateway of a mesh must share. The gateway picks the key in this order:

  1. NebulaConfig.AuthSigningKey, or -nebula-auth-key, or the NEBULA_AUTH_KEY environment variable for the standalone gateway.
  2. Derived from MeshToken when one is set. The gateway logs a warning in this case, because the mesh token changes with every deployment and every anonymous identity and session token changes with it. Set AuthSigningKey to a secret of its own for any mesh that is deployed more than once.
  3. A random key stored in nebula-auth.key next to the gateway executable. This is what nebula start uses. Delete the file to invalidate every anonymous identity.

nebula deploy to Hetzner generates a NEBULA_AUTH_KEY the first time it writes the orchestrator VM's environment file and keeps that value across later deployments, so anonymous players keep their identities when you redeploy. See Deploy to Hetzner Cloud.

If the key changes, every saved anonymous token stops verifying. The client notices the rejection, forgets its token, and reconnects as a new player.

Sessions and reconnection

A session is one player's stay in the world, from the first welcome until the player leaves. The gateway that welcomes a client assigns it a session ID that is unique across every gateway of the mesh: the gateway's random 32-bit incarnation, drawn when the gateway process started, in the high half and a counter in the low half. Workers key player state by this ID, and it is what game code sees as PlayerInfo.ClientId and NetworkIdentity.OwnerClientId.

Every welcome also carries a session token: a signed token that names the session (its ID, identity, display name, bot flag, and connection generation). It is signed with a key derived from the player signing key, so every gateway of the mesh can verify it, and it is valid for 24 hours. The client keeps it in NebulaClient.SessionToken for the life of the process and presents it whenever it reconnects. Disconnect() keeps it too, so a deliberate reconnect continues the session. With single-session coordination disabled, clear it to start a new session. With coordination enabled, the identity still names the existing session.

When a client presents a session token that verifies and belongs to the identity that just authenticated, the gateway that answers, whichever gateway of the mesh it is, welcomes the client with the same session ID, a newer connection generation, and NebulaClient.SessionReclaimed set. If a worker still holds the player's pawn, the client gets that pawn back; the worker re-announces it and routes the session's input through the new gateway from then on. If the pawn is gone, the gateway asks a worker to spawn the player again through OnSpawnPlayer, with the same session ID.

Because the session ID does not change, state that only the owner may see, a behavior with the Owner sync audience, reaches the reconnected client without any code of yours: the new connection starts from the newest keyframe and receives every update after it.

A session token proves which session a client had. It does not prove that the session still exists, and it is not an identity: a token for another identity, an expired token, or one signed for another mesh is ignored. With single-session coordination enabled, the authenticated identity can still reclaim its existing session after the previous gateway acknowledges disconnection. Otherwise a new session starts. The gateway accepts it only together with the client's identity credential (the anonymous token or ID token), so a copied session token alone does not take over a session.

One connection per player

By default, gateways coordinate one admitted connection per authenticated identity through the orchestrator. Before welcoming a replacement, the coordinator asks the previous gateway to disconnect its connection and waits for that gateway's acknowledgment. If the previous gateway cannot acknowledge within 10 seconds, the new join is rejected. An unavailable coordinator or coordination store also blocks admission. Existing connections keep playing during a coordinator outage.

The coordinator keeps the session ID and increments its connection generation. A replacement reuses the existing pawn or pending spawn's worker assignment, even when no pawn has been announced yet. Workers still reject stale generations, but they do not decide which identity may connect. Coordination uses per-identity lookups and gateway-specific disconnect requests; it does not add player records to the topology snapshots sent to workers.

The file, SQLite, and PostgreSQL control-plane backends store claims before acknowledging them. Claims survive an orchestrator restart even when the topology is reset. The in-memory control plane does not retain claims across process restarts; use it for tests or runs where all participants stop together.

A gateway that crashed without releasing its claims can therefore block a replacement. To recover those claims, first stop every gateway of this mesh, including gateways on other machines. Then run nebula start --reset-sessions from the game project. This clears session ownership records without deleting saved entities. Never clear claims while an earlier gateway can still serve clients. A separately launched orchestrator accepts -nebula-reset-sessions for the same recovery procedure.

The connection that loses the player gets a SessionReplaced message with a reason fit to show, and its link is closed. On that client, NebulaClient.SessionReplaced fires with the reason, LastError holds it, the session token is cleared, and the client stops reconnecting: it does not compete for the player. Call Connect() to take the player back, which closes the other connection in the same way.

ConnectionUi.cs
client.SessionReplaced += reason =>
{
    // "this player connected again somewhere else"
    message.text = reason;
    reconnectButton.gameObject.SetActive(true);
};

A game that wants one identity to hold several pawns at once, such as a fleet of bots signed in to one account or a spectator beside a player, sets NebulaConfig.SingleSessionPerPlayer to false (or passes -nebula-single-session false) on every gateway. Independent connections then get separate sessions; a valid session token still reconnects to its named session. Separate anonymous identities remain separate players. Two clients sharing a saved anonymous token share an identity and follow the same coordination rule as signed-in players.

Custom control planes must implement IGatewaySessionControlPlane to support this policy. Its callbacks run on the gateway thread, normally during IControlPlane.Tick. A control plane without that extension rejects joins while SingleSessionPerPlayer is enabled.

The reclaim grace

When a client's link drops, or a gateway's link to a worker is lost, the worker keeps the affected pawns for NebulaConfig.SessionReclaimSeconds (default 30) and despawns them afterwards unless the session was reclaimed. During the grace the pawn stays in the world without input, and NetworkIdentity.IsOwnerConnected is false. OnPlayerDisconnected fires when the grace starts, OnPlayerReconnected if the player comes back in time, and OnPlayerDespawn when the grace expires (see Players who drop and come back). Set the value to 0 to despawn at once.

The grace follows the pawn. If the pawn changes worker while its player is away, for example because it stands in a container that is handed to another worker, the new worker continues the countdown with the time that was left, and removes the pawn when it runs out unless the player has reconnected.

The client reconnects by itself: one second after a lost link, and at once when the gateway tells it that it is being taken out of service (see Connecting clients). Each attempt presents the session token, so a player who comes back within the grace continues with the same pawn.

A player who leaves on purpose skips the grace. When the client calls NebulaClient.Leave(), it sends the gateway a goodbye, and the gateway ends the session at once: the worker calls OnPlayerDespawn and removes the pawn, the session directory forgets the session, and the player's next sign-in starts a new session without waiting. A player removed with NebulaWorker.Kick or NebulaGateway.Kick loses the session the same way. Disconnect() and a lost link keep the full grace. See Leave the game.

A player who connects again while in the world takes their session over rather than starting a new one (see One connection per player), so the reclaim grace is about a player who is away, not about a player who came back.

Each reconnection carries a newer connection generation. A worker ignores a player claim or a despawn with an older generation than the one it holds, so a gateway that lost the client cannot undo what the gateway that gained it did, and it accepts input for a session only from the gateway that currently holds it. Game code does not see generations; they are described in the wire protocol specification.

OpenID Connect providers

To accept sign-ins, list the providers the mesh trusts and the audience their tokens must carry.

SettingCommand lineMeaning
AuthIssuers-nebula-auth-issuersIssuer URLs, separated by commas. The value must match the token's iss claim exactly, apart from a trailing slash. Examples: https://accounts.google.com, https://your-tenant.us.auth0.com/, https://securetoken.google.com/your-firebase-project.
AuthAudience-nebula-auth-audienceThe aud claim a token must carry: the client ID the provider issued your game. Leave it empty only during development; without it, a token minted for any other app of the same provider is accepted.
AuthAnonymous-nebula-auth-anonymous falseSet to false to require a sign-in token from every client.

Set them on the NebulaConfig asset. nebula build exports them to the standalone services, and the orchestrator passes them to the gateway it starts.

The gateway fetches each issuer's /.well-known/openid-configuration document, then its JSON Web Key Set, the first time a token from that issuer arrives, and caches the keys. A token signed with an unknown key ID triggers one refresh, which is how key rotation is handled. Supported signature algorithms are RS256, RS384, RS512, ES256, ES384, and ES512. Tokens must carry exp and sub; the gateway allows 60 seconds of clock skew.

Present the token from the client

Sign the player in with the provider's SDK or a browser flow, then hand the ID token to the client before connecting:

SignIn.cs
var client = NebulaBootstrap.Instance.Client;
client.AuthToken = idToken; // the JWT your sign-in flow returned
client.ConnectTo(gatewayAddress, gatewayPort, displayName);

AuthToken is presented on every connection until you clear it. Refresh it before it expires if the player may reconnect later; a gateway rejects an expired token. Bots and test clients can pass -nebula-auth-token <jwt> on the command line, or ?nebula-auth-token= in a web build's URL.

Nebula does not run the sign-in itself. A desktop build typically opens the provider's login page in the system browser and receives the token on a loopback port or through a custom URL scheme; a web build uses the provider's JavaScript SDK; a mobile build uses the platform SDK. Each provider documents these flows.

When a join is rejected

If the gateway cannot accept a token, it sends the reason and closes the connection. On the client, NebulaClient.JoinRejected fires with the reason, LastError shows it (the title screen displays LastError), and the client stops reconnecting. A rejection with the reason gateway is draining is about the gateway, not the client: the client retries after a second instead, and a load balancer hands the retry to another gateway. Common reasons:

ReasonFix
this game requires signing inAuthAnonymous is off and the client presented no token.
issuer '...' is not trusted by this meshAdd the provider to AuthIssuers.
token was not issued for this game (audience)AuthAudience does not match the token's aud. Check the client ID.
token has expiredRefresh the token before connecting.
token signed with an unknown keyThe provider rotated keys and the refresh did not find the key ID. Check that AuthIssuers names the issuer that signs this token.
could not reach the identity providerThe gateway could not download the provider's keys. Check the gateway host's outbound HTTPS access.

The gateway logs every rejection with the client's session ID and the same reason.

Forward token claims to workers

A worker sees who a player is (PlayerInfo.Identity), but not the rest of the sign-in token. To gate game features on a claim of the token, such as an admin flag or a subscription tier, list the claims the gateway should pass on:

SettingCommand lineMeaning
ForwardedClaims-nebula-auth-forward-claimsClaim names, separated by commas, for example is_admin, username. At most 16 names; extra names are ignored with a warning. Empty forwards nothing.
NameClaim-nebula-auth-name-claimA claim that becomes the player's name, for example username or name. Empty keeps the name the client asked for.

Set them on the NebulaConfig asset next to AuthIssuers. nebula build exports them to the standalone services, and the orchestrator passes them to the gateway it starts.

When a client signs in, the gateway copies the listed claims from the verified token and sends them to the worker that holds the player's pawn. Every value arrives as a string: "alice", "true" or "false" for a boolean, "42" for a number. A claim is left out when:

  • the token doesn't have it, or it's null, an array, or an object;
  • its value is longer than 256 characters (it's dropped, not shortened, and the gateway logs a warning);
  • it isn't in ForwardedClaims. The gateway never forwards a claim you didn't list.

Anonymous players have no claims, because the gateway issued their token itself. Clients are never sent claims, including the local player's.

Read a claim on a worker

Claims are on three worker-side APIs, each an IReadOnlyDictionary<string, string> that is empty, never null, when there are none:

  • PlayerInfo.Claims, in OnSpawnPlayer and BeginSpawnPlayer.
  • NetworkIdentity.OwnerClaims, on every entity a player owns: the pawn, and anything spawned for that player later. TryGetOwnerClaim(name, out value) reads one.
  • NebulaWorker.GetPlayerClaims(clientId), by session ID.
AdminCommands.cs
public sealed class AdminCommands : NetworkBehaviour
{
    public void RequestTeleport(Vector3 destination)
    {
        if (!IsOwner) return;
        ServerRpc(RpcTeleport, destination);
    }

    [ServerRpc]
    private void RpcTeleport(Vector3 destination)
    {
        if (!HasAuthority) return;
        // Only a player whose verified token says is_admin may teleport.
        if (!Identity.TryGetOwnerClaim("is_admin", out var admin) || admin != "true") return;
        transform.position = destination;
    }
}

The claims travel with the entity. A ghost of it on another worker has them, and so does the worker that takes it over in a handover, including one in another simulation scope. When the player reconnects, the gateway checks the sign-in token again and sends the claims of that token, which replace the old ones on the pawn and its ghosts. A role you take away from the account is gone once the player reconnects with a new token. It isn't removed from a session already in progress.

Take the player's name from the token

A client chooses its own name in its Hello, so a player can call themselves anything. With NameClaim set, the gateway uses that claim of the verified token as the player's name instead. PlayerInfo.Name, the gateway's logs and its server extensions all see the token's name. A player whose token lacks the claim, and every anonymous player, keeps the name their client asked for.

What this does and does not protect

Authentication identifies the player so that saved state follows the right person. It does not encrypt the client link on its own: native client traffic is readable on an untrusted network unless you enable encryption. See Encrypt client connections. WebRTC links from web builds use Datagram Transport Layer Security (DTLS) encryption.

Anonymous tokens have no expiry. Changing the signing key invalidates a copied token; forgetting the token on one client does not invalidate another copy. Session tokens expire after 24 hours and require the identity credential they were issued for.

Gateways and workers authenticate each other only when the mesh has a MeshToken: each presents a credential derived from it when it connects, and a peer without one is refused. Without a mesh token every gateway and worker peer is accepted, which is fine on a development machine or a private network.

The identity is not an authorization system. Deciding what a player may do, and validating their inputs, stays in your game code on the worker. Forwarded claims give that code verified facts to decide on; the decisions are still yours.

On this page