Photon Fusion 2 Connection Tokens and Custom Player Data: The 128-Byte Problem

Photon Fusion 2 limits the Connection Token to 128 bytes. That number breaks most naive custom-player-data designs the first time a real auth payload meets it. Here's how to actually structure player data that survives the limit, with the backend pattern that ships.

Photon Fusion 2 limits the Connection Token to 128 bytes. That number is the entire reason this post exists. It's also the number most Fusion tutorials don't mention until you're three weeks into your integration and you discover your nicely-designed player payload doesn't fit.

This is the practical version of how to build custom auth and custom player data in Fusion 2, with the architecture that actually works at production scale and the failure modes that catch teams the first time.

Why the 128-byte limit matters more than it looks

128 bytes is small. To put it in perspective: a Steam ID is 8 bytes, a session UUID is 16 bytes, a typical JWT for player auth is 200-400 bytes. The JWT does not fit. Most of what you'd naively want to pack into the token does not fit.

The temptation is to encode player state directly into the Connection Token: level, cosmetics, progression flags, inventory checksum. You can't. Even modest amounts of player state blow past 128 bytes fast. A serialized C# struct with five integer fields plus a string nickname plus a player id is already over the limit.

What the limit forces you to design instead: the Connection Token is a reference, not a payload. It carries a session identifier and maybe one or two flags. The full player data lives on your backend and is looked up by the session identifier during the auth callback.

This is the right architecture even without the limit. The 128 bytes just makes it the only architecture.

The auth flow in Fusion 2, end to end

Here's what actually happens when a client connects with custom authentication:

  1. The client builds a Connection Token (≤128 bytes), typically containing a session id obtained from a prior login call to your backend.
  2. The client calls NetworkRunner.StartGame() with custom auth values set.
  3. Photon's cloud receives the connection attempt and calls your configured Custom Authentication Provider URL server-to-server, passing the token.
  4. Your backend looks up the session id, validates the player, fetches whatever data the game server will need (display name, level, cosmetic ids, etc.).
  5. Your backend returns a JSON response with ResultCode 0 (success), optional Data object containing the player context, and optionally a server-side player nickname.
  6. Photon attaches that Data to the connecting player and allows the join.
  7. Your NetworkRunner spawn logic reads the player's AuthData and uses it to construct the network player object.

The whole round trip happens in under a second on a healthy backend. The bottleneck is the database lookup in step 4, so that path needs to be tight.

The shape of a Connection Token that works

Inside the 128 bytes, the practical content for most games is:

  • Session ID (16 bytes as a UUID, or 24 bytes base64-encoded if you need string compatibility).
  • Game mode flag (1-2 bytes - "ranked," "casual," "private," etc).
  • Lobby code if your game has named lobbies (8-16 bytes).
  • Anti-cheat nonce if you need one (16-32 bytes).
  • Reserved for future additions.

That comes in around 60-90 bytes for a typical multiplayer game. The remaining headroom is intentional. The first time you ship and discover you need to add a feature flag to the token, you'll be glad you didn't pack to the byte limit on day one.

Don't put: player level, currency balance, inventory state, friend lists, anti-cheat history. None of those belong in the token. They belong in your backend, indexed by the session id.

The backend side: what AuthData should contain

Your custom auth backend returns a JSON response that Photon forwards to the game server. The shape:

{
  "ResultCode": 0,
  "UserId": "stable-player-id",
  "Nickname": "DisplayName",
  "Data": {
    "level": 47,
    "cosmetics": "12,38,91",
    "vipUntil": 1735603200,
    "antiCheatFlags": 0,
    "regionPreference": "eu-west"
  }
}

Two constraints to remember about the Data object:

  1. No nested arrays or objects. Fusion's serializer only handles flat key-value pairs. The cosmetics field above is a comma-separated string, not a JSON array. The same constraint applies to anything you want to pass through - flatten or serialize to a string.
  2. Total size is bounded. Fusion doesn't publish a hard byte limit on AuthData but in practice you want to keep it under a few hundred bytes. If you need to pass more (full inventory, talent tree, friend list), pass an indirection - a short URL or backend-lookup id - and fetch the heavy data over your own channel after the join completes.

The pattern that actually scales

Here's the architecture we see working at production scale across studios using Fusion 2:

1. Login → session creation, server-side

The player authenticates with your backend before they ever touch Photon. Email/password, Steam ticket, Epic EOS exchange, whatever. On successful auth, your backend creates a session row in a database keyed by a fresh session id, stamps it with the player id, and returns the session id to the client.

The session row holds everything the game server might need: current loadout, level, cosmetics, ban status, region preference, party affiliation. Time-to-live 10-60 minutes. Long enough to cover the time between login and joining a match.

2. Connection Token carries the session id only

The client builds a 128-byte Connection Token containing the session id plus a few flags. Nothing else. This is the smallest possible payload that lets the backend identify the player.

3. Photon's auth callback → backend lookup

When Photon calls your auth URL, your backend does a single fast lookup (session id → session row), validates it's not expired or banned, builds the compact AuthData object, and returns it. The whole callback should finish in under 100ms; aim for under 30ms on the lookup itself.

4. Game server uses AuthData for spawn

Your NetworkRunner spawn handler reads the player's AuthData on join. Display name comes from there. Level, cosmetics, anti-cheat flags come from there. The fields you put in AuthData are the fields the spawn logic can use without an extra round-trip.

5. Heavy data fetched separately as needed

If the game server needs inventory, friend list, talent tree, or anything not in AuthData, it makes a backend call after the join, using the player's session id (which is also passed in AuthData) as the lookup key. This call is on your timeline, not Photon's. You can take 200-500ms here without impacting the join experience.

The auth provider integration, concretely

What "custom authentication provider URL" actually means in the Photon dashboard:

You configure a URL like https://api.yourgame.com/photon/auth in your Photon application settings (under the "Custom Authentication" section). When a client connects with custom auth, Photon POSTs to that URL with the auth payload. Your backend handles the request, returns the JSON above, and the connection proceeds or is rejected based on ResultCode.

The endpoint needs:

  • HTTPS (Photon requires this).
  • Low p95 latency (target under 100ms; over 1s and Photon times out).
  • A way to validate the request is actually from Photon (Photon includes signing headers - verify them).
  • Graceful failure handling. If your backend is down, Photon's behavior is to reject the connection. Make sure your reject responses don't leak debug data to the client.

What happens if the backend says no

Three rejection patterns to know:

  • Session not found / expired: Return ResultCode 2 with a message the client can show ("Session expired, please re-login"). The client should bounce them back to your login flow.
  • Player banned: Return ResultCode 3 with a generic message. Do not include the ban reason in the response (avoids feedback loops for cheaters). Log the rejection on your side for the support team.
  • Backend hiccup: Return a 5xx HTTP status. Photon will retry briefly and eventually fail the connection. The client should treat this as a transient error and offer "Try again."

Things to avoid

Three patterns we keep seeing in early Fusion 2 integrations that need to be fixed before production:

Embedding a JWT in the Connection Token. JWTs are 200-400 bytes. They don't fit. Use a short opaque session id, not a JWT. The JWT can live on the client and the backend; Photon doesn't need to see it.

Calling the backend twice - once for auth, once for player data. If your auth callback returns ResultCode 0 but doesn't populate AuthData, the game server has to make a second backend call to get player info. That's twice the latency and twice the backend load for no reason. Stuff everything the spawn logic needs into AuthData in the single auth response.

Using the player's account id as the Photon UserId. Photon UserId should be stable and unique. Account id works most of the time but breaks when you want to support guest play or anonymous trials. Better: derive the UserId from the session, scoped to your auth namespace. Then guest accounts get distinct UserIds without any account-id reuse risk.

The Crux integration angle

If you're building a game backend from scratch to sit behind Photon Fusion 2, the auth + session model above is most of what you need. Crux ships exactly that shape out of the box: a login endpoint that creates a session row, a session-lookup endpoint sized for the Photon callback, a player-data store keyed by session id, and SDK helpers for both the client and the game server.

Practically that means: when a player logs in to your game, Crux hands the client a session id; the client embeds it in the Connection Token; Photon calls Crux's auth endpoint with the token; Crux returns AuthData populated with the player's level, cosmetics, and flags; your spawn logic reads it. Done. The 128-byte constraint is respected automatically because Crux's session ids are 16 bytes by default.

The alternative is rolling your own backend (perfectly fine, the patterns above are platform-independent) or using a different managed backend. Pick on fit. The architecture is the same either way.

Related reading