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

Security

This page gathers what you need to know to operate a game integrated with Open Play securely: where to keep keys, how to rotate them, how the browser session is protected, and how to verify webhook requests. Read it together with the production checklist before going to production.

1. API keys

There are two key types, and both are bound to a single environment:

TypeWhere it is keptWhat it can do
Server key (server)Only on your server (a secret manager or an environment variable)All server endpoints
Client key (client)Can be embedded in the clientSensitive commands (for example add_xp, set_attribute) do not work; but some endpoints have no key-type gate (endpoints)

Warning

A client key is not “harmless”. On endpoints such as character creation/deletion, inventory movements, trading, merchant buy-sell, and wallet transfer, there is no key-type gate in code; a key embedded in the client can call them. Call these endpoints only from your server, and for the player use the endpoints that work with a player session (/v1/player/..., plugins, PlayerClient).

Key format and storage

  • Format: crx_live_<prefix>_<secret>. The server stores only the key’s digest (argon2id); it cannot regenerate the secret part.
  • The secret value is shown only once, at creation time. If you lose it, it cannot be recovered; you generate a new key.
  • Do not put the key in the repository, logs, error reports, the client bundle, or screenshots. Filter the Authorization header out of logs.
  • Use a separate key per environment and per service (for example the live API server, a batch job, an admin script); in a leak you revoke only one.
  • Give the key an expiry date. expiresAt can be set when creating it in the panel; an expired key is rejected.

Lifecycle

Keys are managed in the panel on the Game settings: API keys screen (API keys):

ActionEffect
CreateA new key; the secret value is shown once
Disable / enableTemporary and reversible
RotateCreates a new key with the same name and immediately revokes the old one in the same operation
RevokePermanent; cannot be undone
DeleteSoft delete + revoke

The key state is re-read from the database on every request: a revoke or disable takes effect on the next request (only the verification result of the secret value is cached for 60 s, the state is not). A reason is written for every operation and it goes into the operation history.

Rotation without downtime

The Rotate action in the panel revokes the old key immediately; until you roll out the new key, your calls get 401. To rotate without downtime:

  1. In the panel, create a second key with a different name (there can be only one active key with the same name and owner at a time).
  2. Write the new key into your secret manager and roll it out by restarting your services.
  3. Verify that no service uses the old key (the operation history and your own logs).
  4. Revoke the old key.

If a leak happens

  1. Revoke the leaked key immediately (the effect is on the next request).
  2. Roll out the new key in the order above.
  3. In the operation history and your own logs, look for unexpected operations after the moment of the leak (wallet transfers, item minting, webhook registration).
  4. In the environments the key could access, review the webhook endpoints; an attacker may have registered an endpoint (registered secrets are shown once, but a new registration generates a new secret).

2. Player session and browser security

  • After player sign-in the server writes three cookies: __Secure-crx.session (session), __Secure-crx.refresh (refresh), and __Secure-crx.csrf (CSRF). All three carry HttpOnly, Secure, Path=/, and SameSite=Lax (scripts cannot read them); the plain value of the CSRF token given to the script is not in the cookie but in the response header and body. The __Secure- prefix requires HTTPS: a session cannot be established over plain HTTP.
  • The session slides for 30 days: it is extended if the player makes a request at least once a day. The absolute ceiling is 180 days: no matter how active, a new sign-in is needed after 180 days. An open realtime socket does not keep the session alive. The durations are fixed in server code.
  • The environment can turn on a single active session rule if it wants (a new sign-in drops the old one); this is among the sign-in settings in the panel.
  • The access JWT (EdDSA) is not the session token; your game server verifies it offline with GET /v1/auth/jwks (server verification).

CSRF

In a cookie session, every modifying (POST, PUT, PATCH, DELETE) request passes two protections:

  1. Origin check: the host in the Origin (or Referer) header must be one of the environment’s allowed origins. A host bound to an environment can target only that environment. It is blocked if Sec-Fetch-Site: cross-site and Sec-Fetch-Mode: navigate are together. Clients that send no Origin at all (curl, server-to-server) pass this check; the layer that protects them is double submit.
  2. Double submit: the __Secure-crx.csrf cookie and the x-csrf-token header must arrive together and match. The token is single-use and rotates on every successful verification: store the new Set-Cookie and x-csrf-token value in the response for the next request. GET /v1/auth/csrf for a new token.

The TypeScript SDK manages this automatically; if you write HTTP by hand, see the sessions and CSRF page. The failure case: 403 and the plain text permission denied.

CORS and same origin

The server sends CORS headers only to https://<domain> origins in the environment domain list (and the operator’s global allowed list), and only on the public API: the allowed origin is reflected verbatim, Access-Control-Allow-Credentials: true is added, * is never used, and the panel path (/v1/panel/*) never gets CORS. CORS is not authorization: Origin, the CSRF double submit, and the environment checks run unchanged; CORS only lets the browser read the response. Do not add extra CORS headers to your own proxy: duplicate headers cause a browser error.

Because the session cookie is SameSite=Lax, a direct call works only if the game and Open Play are on the same site (subdomains of the same registrable domain); on a different site, use a reverse proxy. Topology: architecture and hosting.

The allowed origins are your environment domains. The domain you define for the environment in the panel is the basis both for routing to that environment and for the Origin check (Game settings: environments). A modifying request from an unknown host is rejected.

3. Webhook verification

Open Play sends the events you subscribe to your endpoint with a signed POST. You must verify every request; do not process an unsigned request.

HeaderMeaning
X-Crx-Signaturet=<unix seconds>,v1=<hex>
X-Crx-Event-IdThe event ID (UUID); the deduplication key
X-Crx-Event-TypeThe event type (webhook events)

The signature: HMAC-SHA256(secret, "{t}.{raw body}"), lowercase hexadecimal. The key is the whsec_… text you received at registration itself (as bytes). Rules:

  1. Sign/verify the raw body: if you decode the JSON and rewrite it, the bytes change and the signature does not match.
  2. Use a constant-time comparison (timingSafeEqual, hmac.compare_digest).
  3. Compare the t value with your own clock and reject anything outside the window (5 minutes is recommended; this is a receiver policy, the server does not enforce it).
  4. At-least-once: the same event may arrive more than once. Deduplicate with X-Crx-Event-Id and make the operation replay-safe.
  5. Only a 2xx response is success. Respond quickly (queue the work); other statuses are retried.

Delivery behavior:

BehaviorValue
RetryExponential backoff: starts at 1 s, grows by powers of 2, capped at 5 minutes, ±20% jitter
Maximum attempts10; after that the delivery becomes failed (dead-letter queue, DLQ)
RedirectNot followed
Body limitA body above 64 KiB is not sent
Endpoint addresshttps only; private, loopback, and link-local IP addresses and addresses containing user info (user:password@) are rejected at registration (SSRF protection)
Secret keyReturned in the registration response only once; rotation = deleting the endpoint and registering it again

Note

There is no in-place “rotate” for the webhook secret. For a seamless transition: register the new endpoint (with the new secret), have your receiver verify both, and delete the old endpoint.

Verification example (Node, node:crypto):

import { createHmac, timingSafeEqual } from "node:crypto";

/** İmzalı webhook isteğinin doğrulama sonucu. */
export type WebhookCheck = { ok: true; eventId: string; eventType: string } | { ok: false; reason: string };

const TOLERANCE_SECONDS = 300; // yeniden oynatma penceresi: alıcı politikasıdır, 5 dakika önerilir

/**
 * Open Play webhook isteğini doğrular. `rawBody`, ayrıştırılmamış HAM bayt dizisi olmalıdır:
 * JSON'u çözüp yeniden yazarsanız imza tutmaz.
 * İmza: HMAC-SHA256(secret, "{t}.{ham gövde}"), başlık biçimi `t=<unix saniye>,v1=<hex>`.
 */
export function verifyWebhook(
  secret: string,
  rawBody: Buffer,
  headers: { signature: string | undefined; eventId: string | undefined; eventType: string | undefined },
  nowSeconds: number = Math.floor(Date.now() / 1000),
): WebhookCheck {
  const parts = Object.fromEntries((headers.signature ?? "").split(",").map((part) => part.trim().split("=", 2) as [string, string]));
  const timestamp = Number(parts["t"]);
  const received = parts["v1"];
  if (!Number.isInteger(timestamp) || received === undefined) return { ok: false, reason: "imza başlığı bozuk" };
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return { ok: false, reason: "zaman damgası pencere dışında" };
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: "imza uyuşmuyor" };
  if (headers.eventId === undefined || headers.eventType === undefined) return { ok: false, reason: "olay başlıkları eksik" };
  return { ok: true, eventId: headers.eventId, eventType: headers.eventType };
}

Monitor delivery status and the DLQ in the panel: Webhooks and event queue. For registration and endpoint management, see the webhooks guide.

4. Rotating secrets: summary

SecretHow it is rotated
Server/client API keyCreate a second key, roll it out, revoke the old one (above)
Webhook secretRegister a new endpoint, update the receiver to accept both, delete the old one
Player passwordThe player changes their own password (all sessions drop) or the password reset flow (email and password)
Player sessionsThe user signs out; an admin revokes the player’s sessions (panel or POST /v1/auth/admin/revoke-player-sessions)

5. What not to write to logs

The API key, session and refresh tokens, the CSRF token, the realtime ticket, the webhook secret, OAuth state and code values, passwords, and the guest secret value. The SDKs do not include these in error objects; in your own logging code, too, do not print the request raw.

6. Other notes

  • Error detail is not leaked: the server gives no internal detail in a 500 response; account existence is also not leaked (a wrong password and an unknown address get the same response).
  • Banned account: on sign-in endpoints, 403 account_banned after ownership has been proven; no distinction is made on other endpoints (errors).
  • Safety switches: in the panel you can loosen or force back some protection rules per environment; every change is recorded with a reason (Safety switches).
  • Plugins are trusted code compiled into the server; running third-party plugins and isolation do not exist yet (plugins).
  • Panel admin accounts have a separate identity line and two-factor authentication; there is no two-factor authentication for player accounts.