Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sessions and CSRF

A player who signs in is given a session. This page describes how the session is carried, how long it lives, how it is refreshed and closed, and the CSRF (cross-site request forgery) protection that is required on state-changing requests. If you use an SDK, it manages most of this for you; if you are writing raw REST, this page is the contract.

How the session is carried

The server reads the session from one of two places; the cookie first, otherwise the Authorization header:

ChannelWho uses it?Format
CookieBrowser games__Secure-crx.session=<64 hex> (HttpOnly; Secure; SameSite=Lax; Path=/)
BearerClients without cookies (command line, some native clients)Authorization: Bearer <session_token> (the opaque token in the sign-in response)

Every player request also carries x-crx-env: <environment-id>. What is carried on the Bearer path is the opaque session token; it is not the access JWT (that is for offline verification by your game server: Server-side verification).

Reading the session

GET /v1/auth/session
x-crx-env: <environment-id>
{
  "environment_id": "0192…",
  "player_id": "0192…",
  "session_id": "0192…",
  "is_guest": false,
  "active_organization_id": null,
  "displayName": "Alex",
  "email": "[email protected]"
}

email is null for guest and email-less (Google-only) accounts. The response carries personal data and comes with Cache-Control: private, no-store. If there is no session, it has been revoked, it has expired, or the account is banned, 401 is returned (the distinction does not leak).

Lifetimes

ItemDuration
Session and refresh window30 days, sliding
Absolute upper bound180 days (from the start of the session family; cannot be exceeded by any means)
Access JWT15 minutes
Cookie Max-Age15,552,000 s (180 days); it is only an upper bound, the decision is on the server
CSRF cookie15 minutes
  • Sliding: when the player makes a request with the session at least once a day, the session end is pushed 30 days ahead (without exceeding the upper bound). Every refresh also pulls the duration back to 30 days.
  • A session that makes no request for 30 days dies. On day 180 even an active session ends; the player signs in again (a guest returns to the same account with the same secret).
  • Keeping a realtime connection open does not extend the session.

Refresh

POST /v1/auth/refresh
content-type: application/json
x-crx-env: <environment-id>
x-csrf-token: <csrf-token>

{}

The refresh token is carried in the body ({"refresh_token":"…"}) or in the __Secure-crx.refresh cookie (the body takes precedence; if the cookie is present, the body can be empty). The response is 200 and a new session and refresh pair (the cookies are also renewed; the shared sign-in envelope). The old refresh token counts as used.

  • Reuse detection: presenting a used refresh token again revokes the entire session family (a defense against a stolen token).
  • Error: 403 (the token is invalid, expired, reused, or a CSRF rejection).
  • The SDKs refresh on their own when needed; you do not call it.

Sign-out

POST /v1/auth/logout
content-type: application/json
x-crx-env: <environment-id>
x-csrf-token: <csrf-token>

{}

204; the session is revoked and the cookies are deleted (Max-Age=0). The call is idempotent: an unknown token succeeds silently. If there is no refresh token at all (the cookie and the body are empty), 400 is returned.

Password change, password reset, and an operator ban also revoke sessions; see the relevant pages.

Single active session (optional)

The Game settings › Sign-in methods › Sign-in settings › Single active session switch in the panel (super admin only) is turned on per environment. When it is on, every sign-in path that gives the player a new session revokes the player’s other sessions after the new session is written (“the latest sign-in wins”).

  • The switch does not close open sessions at the moment it is turned on; it applies only from the next sign-in.
  • Refresh does not trigger this policy.
  • The session on the other device gets 401 on its next request; the SDK state becomes reauthRequired. Your game can show a screen such as “signed in from another device” in this case.

CSRF double-submit

A session carried in a cookie is also added automatically by the browser to other sites’ requests. To prevent this, double-submit CSRF protection is applied to state-changing requests: the same token must arrive both in the cookie and in the header.

Scope. It is required on these requests: all non-GET requests under /v1/auth/* (including the sign-in endpoints); plugin commands called with a player session (and queries called with POST); player store purchases (only in a cookie session; in an Authorization: Bearer session only the Origin and Fetch Metadata checks apply); the realtime ticket request. GET requests are exempt. Calls made with a server key are not subject to CSRF.

Flow.

  1. GET /v1/auth/csrf is called. The response: body {"token":"<hex>"}, header x-csrf-token: <the same token>, and Set-Cookie: __Secure-crx.csrf=<sealed grant>.
  2. Every state-changing request sends the cookie (__Secure-crx.csrf) and the header (x-csrf-token) together. The browser adds the cookie itself; your code adds the header.
  3. On every successful request the token rotates: the response carries a new x-csrf-token header and a new Set-Cookie. Use the new one on the next request.
  4. The token is single-use. A request sent again with the old token gets 403 (replay protection).
# Get a token
TOKEN="$(curl -sS -c "$JAR" "$OPEN_PLAY_URL/v1/auth/csrf" | jq -r .token)"

# Use it; keep the new token from the response header for the next request
curl -sS -D headers.txt -b "$JAR" -c "$JAR" -X POST "$OPEN_PLAY_URL/v1/auth/logout" \
  -H "content-type: application/json" -H "x-crx-env: $OPEN_PLAY_ENV" -H "x-csrf-token: $TOKEN" -d '{}'
TOKEN="$(grep -i '^x-csrf-token:' headers.txt | awk '{print $2}' | tr -d '\r')"

Things to watch out for:

  • Do not send two concurrent mutations with the same token; when one succeeds, the other gets 403. Put the requests in a queue. The SDKs do this with a single lock.
  • The CSRF cookie lives for 15 minutes; when it expires you get 403. Get a fresh token and retry once. The 403 is plain text and does not distinguish between a CSRF rejection and an authorization rejection; if no new x-csrf-token header came in the response, you know it was a CSRF layer rejection.
  • The Origin check is the first layer of CSRF: the Origin domain of a browser request must be bound to the environment. See Architecture and hosting.
  • Clients without cookies (Bearer) must also carry the CSRF cookie and header on /v1/auth/* and plugin endpoints (the Bearer branch of the store purchase is the exception). The Unity SDK does this by managing the cookies itself in a native build and with the browser’s cookies in WebGL.

SDK and session states

TypeScript session.state.status values:

StateMeaning
signedOutNo session
authenticatingSign-in or restore in progress
authenticatedSession open; playerId, sessionId, isGuest, displayName, email are filled
refreshingRefresh in progress
reauthRequiredThe session was lost or could not be refreshed; sign-in is needed again
signingOut / logoutUncertainSign-out in progress / its outcome is uncertain (can be retried)
bannedThe account is banned; terminal

In Unity the equivalent is the SessionStatus values (SignedOut, Authenticating, Authenticated, Refreshing, ReauthRequired, SigningOut, LogoutUncertain, Banned, Disposed); state changes arrive through the Session.StateChanged event.

import { CrxError, ErrorKind, createPlayerClient, type PlayerClient, type SessionState } from "@ferdiunal/open-play-ts";

/** Oturum durumunu arayüze bağlar; `state.status` değerleri kapalı bir kümedir. */
export function watchSession(client: PlayerClient, render: (label: string) => void): () => void {
  const label = (state: SessionState): string => {
    switch (state.status) {
      case "authenticated": return state.isGuest === true ? "Misafir" : (state.displayName ?? "Oyuncu");
      case "banned": return "Hesap askıya alındı";
      case "reauthRequired": return "Yeniden giriş gerekiyor";
      case "signedOut": return "Giriş yapılmadı";
      default: return "…"; // authenticating, refreshing, signingOut, logoutUncertain
    }
  };
  render(label(client.session.state));
  return client.session.subscribe((state) => render(label(state))); // dönen işlev aboneliği kapatır
}

/** Sayfa kapanırken: sunucu oturumunu kapatmak için `logout`, yalnız yerel kaynakları bırakmak için `dispose`. */
export async function leave(client: PlayerClient, signOut: boolean): Promise<void> {
  try {
    if (signOut) await client.session.logout();
  } catch (error) {
    if (!(error instanceof CrxError && error.kind === ErrorKind.UnknownOutcome)) throw error;
    // Yanıt kaybolduysa sunucu oturumu kapanmamış olabilir (durum `logoutUncertain`); gerekirse logout'u yeniden deneyin.
  } finally {
    await client.dispose();
  }
}

export function createClient(environmentId: string): PlayerClient {
  return createPlayerClient({ baseUrl: window.location.origin, environmentId });
}

dispose() only releases local resources; to close the server session, call logout() first. AuthenticatedSession.email is only for the player’s own screen; do not write it to logs or analytics.