Skip to main content

Portals & app-scoped tokens

Crowded Kingdoms uses two kinds of token so a game never receives a player's full identity. The identity session stays where the player signed in (Crowdy Studio, for a browser game; your own client or launcher, for a native one), which portals the player into a game, and each game's stack only ever receives a token confined to that one app.

  • Identity session token — returned by a sign-in: email + password, a magic link, or social/OIDC — see Sign in. It is a management-plane credential (account, studio admin) and the only thing that can mint app tokens. It is not valid for gameplay: the Game API and the realtime/UDP surface reject it. Keep it on your trusted identity origin; never send it to a game stack.
  • App-scoped gameplay token — short-lived (default ~30 min), confined to one app. Used as the Bearer against that app's Game API + realtime surface. A compromised or third-party game only ever sees a token for its own app.

Your studio runs its own accounts? Your organization can sign its players in itself and hand each device its app token, with no Crowded Kingdoms sign-in at all (preview) — see Org-managed players.

Where your game is served does not change sign-in. Crowded Kingdoms does not host games; a browser game on an origin of its own signs players in with hosted sign-in, wherever you host it. See Host your game.

Sign in first​

Everything below starts from an identity session token, which you get from a sign-in on the CK GraphQL API — email + password, magic link, or social. Briefly: requestLoginLink → completeLoginLink (magic link), socialLoginStart → socialLoginComplete (social/OIDC), or login/register (email + password). The full flow is in Sign in. Hold the session token on your identity origin and mint app tokens from it as below.

Terms, age and country​

No gameplay token is issued to a player Crowded Kingdoms may not admit. Every mint, refresh and hosted sign-in checks the player's country, then their age, then the documents they have accepted (Game API v2.60.0): see Age, country and who may play for the first two, which answer TERRITORY_NOT_SUPPORTED, AGE_REQUIRED and AGE_REQUIREMENT_NOT_MET, and Legal documents for the third, including documents of your app's own.

The documents are the Player Terms and the Player Privacy Policy, and any your app requires of its own. Until they are stored, mintAppToken, createPortalAuthorizationCode and refreshAppToken answer LEGAL_ACCEPTANCE_REQUIRED (403), listing what is missing in extensions.missingDocuments. The age comes from the player's birth date. (Until 2026-11-04, when Crowded Kingdoms opened to players 13 and over, the documents were the Game Terms, API Terms, SDK Developer Terms, Free Tier and Billing Basis and Overworld Privacy Policy, with an age-of-majority attestation.)

  • A browser game on its own domain does nothing new: Studio's hosted /authorize asks for the platform's documents before it issues the code. When a refresh is refused this way, retrying cannot succeed; send the player back through hosted sign-in. That also happens when a required document gets a new version.
  • A native or first-party client shows its own checkboxes, linking each document, and records the player's answer with the session token:
query Accepted { playerLegalAcceptance } # false: ask before minting

mutation Accept {
recordPlayerConsents(acceptLegal: true)
}

acceptLegal must be true; attestAgeOfMajority is ignored since 2026-11-04. Pass the app's appId to record its own documents too. Repeating the call is harmless. It records the player's own agreement, so call it only after they ticked the boxes. register takes the same field, and the birth date and country (Signing up): a browser request must send acceptLegal and the birth date, and the account starts accepted. A request with no browser origin may omit them and record them before its first mint. The SDKs wrap both calls (CrowdyJS client.auth.recordPlayerConsents / playerLegalAcceptance, and isLegalAcceptanceRequiredError for the refusal).

Minting an app token​

Native / same-origin (mintAppToken)​

A client that already holds the session token can mint directly:

mutation Enter($appId: BigInt!) {
mintAppToken(input: { appId: $appId }) {
token gameTokenId appId expiresAt gameApiUrl gameApiWsUrl discoveryUrl launchUrl
runtimeGate { status reason }
}
}

Send Authorization: Bearer <session token>. Free/open apps auto-grant access on first mint; an app with no free tier needs a grant first (else ACCESS_NOT_GRANTED). Use the returned token against gameApiUrl / gameApiWsUrl.

Check runtimeGate. A paused app (its organization has no funds, reached a spend cap, or its subscription lapsed) still mints, so your client can tell the player why: when runtimeGate.status is not ACTIVE, the world delivers nothing until the owner fixes it, and reason says which. Show "this world is paused" rather than an empty world. refreshAppToken and exchangePortalCode report it too.

Keep discoveryUrl, and do not confuse it with gameApiUrl. gameApiUrl is the ONE datacenter holding this app's data, which is where gameplay must go. discoveryUrl is the shared origin, resolving to every datacenter — it is the only address that survives losing one, and therefore the only way back when the endpoint you were given stops answering. That matters most for exactly this flow: a client holding only an app token cannot re-mint, because minting needs the session token it does not have. See Datacenters and endpoint routing.

For native clients (Unreal/Unity, desktop, console, mobile, custom launchers), see Native & non-browser clients for the full sign-in → mint → play flow and the launcher / PKCE-loopback handoff patterns.

Hosted sign-in for a game on its own domain​

OAuth2 Authorization Code + PKCE.

This is the only sign-in a browser game on a non-first-party origin can use (ck-api v1.88.0, 2026-09-08): the direct sign-in mutations answer HOSTED_SIGN_IN_REQUIRED from any browser origin other than Studio's, so a customer's game never collects a password. The hosted page is Crowded Kingdoms Studio's /authorize (https://studio.<tier>.crowdedkingdoms.com/authorize). The verifier never leaves the game origin and the session never leaves Studio:

  1. Game origin generates a PKCE verifier + challenge and redirects the player to Studio's /authorize with app_id, code_challenge, code_challenge_method=S256, its redirect_uri and a state. In CrowdyJS: client.portal.signIn({ appId, redirectUri }).

  2. Studio (holds the session; signs the player in or up first if needed) checks consent, then mints a one-time code:

    # For an untrusted app, gate on consent first (trusted apps return consentRequired=false).
    query Consent($appId: BigInt!) {
    portalConsent(appId: $appId) { trusted alreadyGranted consentRequired }
    }

    mutation Authorize($input: CreatePortalAuthorizationCodeInput!) {
    createPortalAuthorizationCode(input: $input) { code redirectUri expiresAt }
    }

    then redirects back to the game's redirect_uri carrying code. The redirect_uri must match the app's registered allow-list (see below) or the call is rejected.

  3. Game origin exchanges the code (with its verifier) — no auth header required, the code + verifier authorize the call. In CrowdyJS: client.portal.handleSignInCallback(), which also stores the token:

    mutation Exchange($input: ExchangePortalCodeInput!) {
    exchangePortalCode(input: $input) {
    token gameTokenId appId expiresAt gameApiUrl gameApiWsUrl discoveryUrl launchUrl
    }
    }

The one-time code is single-use, short-lived (~60s), bound to the supplied redirect_uri, and only issued after the redirect allow-list and consent checks pass.

The official SDK wraps all of this in client.portal: signIn / handleSignInCallback for the game, handleAuthorizeRequest for an identity page (it throws PortalConsentRequiredError for an untrusted app until you approve) — see CrowdyJS. A player who presses Cancel on Studio's consent card is returned to redirect_uri with error=access_denied and no code, when its origin is one of the app's registered redirect URIs or its launch URL (the rule a code's redirect_uri meets, below). Otherwise Studio keeps the player on its page and asks them to go back to the game.

Each app is an OAuth-style client with platform-controlled settings:

FieldMeaning
isTrustedFirst-party apps skip the consent screen. The identity hub (app 1) is trusted. Platform-controlled — studio admins cannot set it.
redirectUrisAllow-list of redirect URIs for hosted sign-in (origin-matched). Also the API's CORS allow-list for the app: a browser on an origin listed here may call the API; one that is not gets no CORS headers. An empty list disallows browser entry. Edit under Studio > Apps > Settings > Sign-in & redirect URIs; honoured on every API instance within ten seconds, no restart.
clientType"public" (browser/PKCE, no secret) or "confidential" (server-side).
launchUrlBrowser destination a player is sent to when they portal into the app, and the page Studio's Live code opens. Set it to wherever you host the game (Host your game); Studio shows it as Launch URL.

Redirect allow-list. createPortalAuthorizationCode validates the requested redirectUri against the app's redirectUris by origin. A mismatch (or an app with no registered URIs) is rejected, so a third party cannot redirect a player's authorization code to an origin the app owner never approved.

Consent gate for untrusted apps. Trusted apps mint a code directly. For an untrusted app, the user must have an active authorization grant; otherwise createPortalAuthorizationCode fails with a FORBIDDEN error whose message is prefixed CONSENT_REQUIRED (see Error codes). The identity origin resolves this by showing a consent screen and recording the grant before minting the code; for a browser game, Studio's hosted /authorize does it:

# Does the user need to consent before this app can receive a token?
query Consent($appId: BigInt!) {
portalConsent(appId: $appId) { appName trusted alreadyGranted consentRequired }
}

# Record the user's approval (idempotent). Call from the consent screen.
mutation Approve($input: AuthorizeAppInput!) {
authorizeApp(input: $input) { grantId appId scopes status grantedAt }
}
# variables: { "input": { "appId": "42" } }

App owners: register client settings. With manage_apps on the app, configure the allow-list and client metadata (the isTrusted flag is not settable here — it is platform-controlled):

mutation Settings($input: SetAppClientSettingsInput!) {
setAppClientSettings(input: $input) { appId trusted consentRequired }
}
# variables: { "input": { "appId": "42",
# "redirectUris": ["https://my-game.example.com/portal/callback"],
# "clientType": "public",
# "launchUrl": "https://my-game.example.com" } }

Connected apps (view & revoke)​

Consent grants are tracked as the user's "connected apps". A user can review and revoke them at any time; revoking also immediately revokes that app's live tokens for the user, so a portaled game loses access on the next call:

# The signed-in user's active authorizations.
query Connected {
myAuthorizedApps { grantId appId appName scopes status grantedAt revokedAt }
}

# Revoke an app's authorization (and its live app tokens for this user).
mutation Disconnect($appId: BigInt!) { revokeAppAuthorization(appId: $appId) }

Staying connected: refresh​

App tokens expire. To keep playing the same app without sending the player back through sign-in, rotate the token before it lapses (re-checks entitlement, revokes the old token):

mutation Refresh { refreshAppToken { token expiresAt } }

Send the current app token as the Bearer. Switching to a different app always goes back through the identity origin (hosted sign-in, or your launcher) for a fresh per-app token. A refresh refused with LEGAL_ACCEPTANCE_REQUIRED needs the player's consents first (above).

Native clients that hold a UDP session: pass currentServer (the ip4 + clientPort that serverWithLeastClients gave you) and read authorizedServer on the response. When it is set, the new token is already known to the Buddy you are on -- keep the socket and switch tokens. When it is null, call serverWithLeastClients again for a fresh placement. It is null when your node is gone, draining or Full, and also when the node is running near its capacity and a cooler sibling has room: the refresh is the cheapest moment for the fleet to spread players, so under load expect to be moved occasionally. The SDKs (CrowdyJS PortalAPI.refresh(currentServer), CrowdyCPP refreshToken) already do this; browser clients on the UDP proxy re-place on every refresh by design.

What an app token may do​

An app token is accepted on the Game API + realtime surface only for its own app, plus read-only me and refreshAppToken on the Management API. Every other management operation (billing, orgs, minting tokens for other apps) requires the identity session token and returns SCOPE_MISSING for an app token.

Errors​

  • LEGAL_ACCEPTANCE_REQUIRED on mintAppToken, createPortalAuthorizationCode or refreshAppToken — the player's consents are not stored; extensions.missingDocuments says which. See Legal documents.
  • TERRITORY_NOT_SUPPORTED, AGE_REQUIRED and AGE_REQUIREMENT_NOT_MET on the same three, and on exchangePortalCode — Crowded Kingdoms is not available where the player is, their age is not known, or they are younger than the app allows there. See Age, country and who may play.
  • SAFETY_LOG_REQUIRED — a player under 18, in an app whose safety log is off. See the safety log.
  • SAFETY_MODERATOR_REQUIRED — a player under 18, in an app whose organization has no appointed moderator. See Moderators.
  • ACCESS_NOT_GRANTED, ACCESS_REVOKED, ACCESS_SUSPENDED (with extensions.suspendedUntil) on mintAppToken, refreshAppToken or exchangePortalCode — the player has no access, the app revoked it, or the app suspended it until a time. None asks the player to buy anything; see Error codes.
  • ACCOUNT_BLOCKED (with extensions.blockedUntil, null for a block with no end) on any of them, and on every other request — Crowded Kingdoms blocked the account on the whole platform. Show it; retrying will not help.
  • FORBIDDEN (message prefixed CONSENT_REQUIRED) on createPortalAuthorizationCode — the user has not authorized this untrusted app yet. Call authorizeApp (after portalConsent) first. See Error codes → Consent.
  • BAD_REQUEST on createPortalAuthorizationCode — the redirectUri origin is not in the app's registered redirectUris allow-list (or the app has none configured). The owner sets them with setAppClientSettings.
  • SCOPE_MISSING — an app token was used for a management operation it isn't allowed to perform, or for a different app.
  • Realtime APP_TOKEN_REQUIRED / APP_SCOPE_MISMATCH, UDP TOKEN_EXPIRED / INVALID_APP_ID — see Error codes.