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
/authorizeasks 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:
-
Game origin generates a PKCE verifier + challenge and redirects the player to Studio's
/authorizewithapp_id,code_challenge,code_challenge_method=S256, itsredirect_uriand astate. In CrowdyJS:client.portal.signIn({ appId, redirectUri }). -
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_uricarryingcode. Theredirect_urimust match the app's registered allow-list (see below) or the call is rejected. -
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.
Consent and the OAuth client registry
Each app is an OAuth-style client with platform-controlled settings:
| Field | Meaning |
|---|---|
isTrusted | First-party apps skip the consent screen. The identity hub (app 1) is trusted. Platform-controlled — studio admins cannot set it. |
redirectUris | Allow-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). |
launchUrl | Browser 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_REQUIREDonmintAppToken,createPortalAuthorizationCodeorrefreshAppToken— the player's consents are not stored;extensions.missingDocumentssays which. See Legal documents.TERRITORY_NOT_SUPPORTED,AGE_REQUIREDandAGE_REQUIREMENT_NOT_METon the same three, and onexchangePortalCode— 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(withextensions.suspendedUntil) onmintAppToken,refreshAppTokenorexchangePortalCode— 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(withextensions.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 prefixedCONSENT_REQUIRED) oncreatePortalAuthorizationCode— the user has not authorized this untrusted app yet. CallauthorizeApp(afterportalConsent) first. See Error codes → Consent.BAD_REQUESToncreatePortalAuthorizationCode— theredirectUriorigin is not in the app's registeredredirectUrisallow-list (or the app has none configured). The owner sets them withsetAppClientSettings.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, UDPTOKEN_EXPIRED/INVALID_APP_ID— see Error codes.