Portals & app-scoped tokens
Crowded Kingdoms uses two kinds of token so a game never receives a player's full identity. An Overworld (a hub app — app 1, which is trusted) can portal players into other games, and each game's stack only ever receives a token confined to that one app.
- Identity session token — returned by a passwordless sign-in (magic link, social/OIDC, or the dev bypass — see Sign in (passwordless)). 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.
Sign in first (passwordless)
Everything below starts from an identity session token, which you get from a
passwordless sign-in on the Management API — there is no password. Briefly:
requestLoginLink → completeLoginLink (magic link), socialLoginStart →
socialLoginComplete (social/OIDC), or devLogin (dev/test only). The full flow
is in Sign in (passwordless). Hold the
session token on your identity origin and mint app tokens from it as below.
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 launchUrl
}
}
Send Authorization: Bearer <session token>. Free/open apps auto-grant access on
first mint; paid apps require an existing entitlement (else FORBIDDEN). Use the
returned token against gameApiUrl / gameApiWsUrl.
For native clients (Unreal/Unity, desktop, console, mobile, custom launchers), see Native & non-browser clients for the full passwordless sign-in → mint → play flow and the launcher / PKCE-loopback handoff patterns.
Browser handoff (OAuth2 Authorization Code + PKCE)
When the game runs at a different origin from the Overworld, use the authorization-code flow so the verifier never leaves the game origin and the session token never leaves the Overworld:
-
Game origin generates a PKCE verifier + challenge and redirects the player to the Overworld's authorize page with the challenge and its
redirect_uri. -
Overworld origin (holds the session token) 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:
mutation Exchange($input: ExchangePortalCodeInput!) {exchangePortalCode(input: $input) {token gameTokenId appId expiresAt gameApiUrl gameApiWsUrl 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 — including the consent
gate (handleAuthorizeRequest throws PortalConsentRequiredError for an untrusted
app until you approve) — see CrowdyJS.
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 Overworld (app 1) is trusted. Platform-controlled — studio admins cannot set it. |
redirectUris | Allow-list of redirect URIs for the browser portal handoff (origin-matched). An empty list disallows browser portal entry. |
clientType | "public" (browser/PKCE, no secret) or "confidential" (server-side). |
launchUrl | Browser destination a player is sent to when they portal into the app. |
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 Overworld resolves this by showing a consent screen and recording the grant
before minting the code:
# 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 bouncing back through the Overworld, 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 routes back through the Overworld for a fresh per-app token.
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
FORBIDDENonmintAppToken— no entitlement for a paid app.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.