Sign in
A user authenticates one of three ways, and every path returns an identity
session token (an AuthResponse):
- Email + password —
registerto create an account,loginto return to one. - Magic link — a one-time link emailed to the address.
- Social / OIDC — a federated provider (e.g. Google).
These are peers on one email-keyed account rather than alternatives: an account created by magic link can later add a password, and signing in with Google and with a magic link for the same verified address resolves to the same account.
Since 2026-09-08 (ck-api v1.88.0) every sign-in mutation on this page --
login, register, requestLoginLink, completeLoginLink,
socialLoginStart, socialLoginComplete, checkAuthMethod,
requestPasswordReset, resetPassword, confirmEmail,
resendConfirmationEmail, and the /auth/* REST twins -- is served only to
the first-party browser origin, Crowded Kingdoms Studio, and to
non-browser callers (anything that sends no Origin header: Node, a CLI,
Unreal/Unity, CrowdyCPP). From a browser page on any other origin the API
answers extensions.code HOSTED_SIGN_IN_REQUIRED (403).
A game on its own domain signs players in through hosted sign-in: redirect
the player to Studio's /authorize with a PKCE challenge, let them sign in (or
sign up) there, and exchange the code they come back with for an app-scoped
token. Your page never sees a password, and never holds a session. In
CrowdyJS that is two calls, client.portal.signIn({ appId, redirectUri }) and
client.portal.handleSignInCallback(); the mutations underneath are
createPortalAuthorizationCode (Studio's side) and exchangePortalCode
(yours), described in Portals & app-scoped tokens.
Your origin must be one of the app's redirect URIs (Studio > Apps >
Settings), which is also what admits it to CORS.
Why: a form on a customer's domain that collects a Crowded Kingdoms password is indistinguishable, to the platform and to the player, from a phishing page.
There is no unauthenticated shortcut. Automated clients should register an
account they hold the password to.
If your studio runs its own player accounts, your players need not have Crowded Kingdoms accounts at all: your organization can sign them in itself (preview) — see Org-managed players.
The session token is a management-plane credential. It is not valid for gameplay: to play, mint a short-lived app-scoped token from it — see Portals & app-scoped tokens and Game API → Authentication.
CrowdyJS wraps every flow below behind client.auth for first-party pages and
non-browser code, and hosted sign-in behind client.portal.signIn for a game on
its own domain — see
CrowdyJS → Authentication.
You rarely hand-write these mutations.
What you get back
A successful sign-in returns an AuthResponse:
type AuthResponse {
token: String! # identity SESSION token — send as Authorization: Bearer <token>
gameTokenId: String! # id of the underlying session row
user: User! # the authenticated (or just-created) account
}
Send Authorization: Bearer <token> on subsequent Management API requests. Resolve
the caller with me { userId email gamertag }.
Other players' accounts are private. user(id) returns a player's email,
state, confirmation and early-access flags, organization and role fields only to
that player themselves; anyone else reads them as null (ck-api v2.40). A game
client that needs other players' names uses playerProfile(userId) or
playerProfiles(userIds) (up to 100 at a time, with the app-scoped token), which
return only userId, gamertag and disambiguation:
query { playerProfiles(userIds: ["777", "778"]) { userId gamertag disambiguation } }
Studio (ck-api v1.92.1) also receives ck_session (HttpOnly, SameSite=Lax) and
a readable ck_csrf on every session mint. Studio authenticates with
credentials: 'include' and X-CSRF-Token; it does not persist token in
localStorage. Native clients, CrowdyJS, Construct, and scripts keep using
Bearer and skip CSRF. Cookie auth from a non-first-party Origin is
COOKIE_AUTH_ORIGIN_REFUSED. Customer origins hold app tokens only.
From v1.98.0 the guard accepts a header that matches any presented
ck_csrf (csrfMatchesAny), so a leftover parent-zone cookie plus the
tier cookie does not fail hosted /authorize. Studio readCsrfToken
last-wins and prefers the Domain for this tier's API host. Do not narrow
prod's .crowdedkingdoms.com Domain — public Studio needs it.
Prod public Studio is studio.crowdedkingdoms.com. Cookie Domain is
.crowdedkingdoms.com so that host can send CSRF. Dev/test stay
.dev.crowdedkingdoms.com / .test.crowdedkingdoms.com. Do not derive
prod's Domain by dropping the first label of the labelled
studio.prod.crowdedkingdoms.com FRONTEND_URL — that yields
.prod.crowdedkingdoms.com, which public Studio cannot use (v1.92.0).
Magic link (email)
Two steps. Request a link, then complete the sign-in with the token from it.
# 1) Email a one-time sign-in link. Always reports sent=true (no account
# enumeration). Creates the account on first sign-in. Public.
mutation Request($input: RequestLoginLinkInput!) {
requestLoginLink(input: $input) {
sent
}
}
# variables: { "input": { "email": "player@example.com", "redirectUri": "https://app.example.com/auth/callback" } }
# 2) Complete sign-in with the token from the link. Public —
# the token authorizes the call; throws if invalid/expired/already used.
mutation Complete($input: CompleteLoginLinkInput!) {
completeLoginLink(input: $input) { token gameTokenId user { userId email } }
}
# variables: { "input": { "token": "<one-time-token-from-the-link>" } }
The redirectUri origin must be an allowed app/UI origin; it defaults to the
platform sign-in page. The token leaves only by email; there is no way to
read it out of the response. An environment with email delivery switched off
therefore cannot complete a magic-link sign-in at all — use email + password
there.
Social / OIDC
Providers are pluggable. List the enabled ones, then run a standard redirect-based OAuth/OIDC handshake.
# Which providers are enabled right now, e.g. ["google"].
query Providers { availableLoginProviders }
# 1) Begin: returns a URL to send the browser to and an opaque state to round-trip.
mutation Start($input: SocialLoginStartInput!) {
socialLoginStart(input: $input) { authorizeUrl state }
}
# variables: { "input": { "provider": "google", "redirectUri": "https://app.example.com/auth/google/callback" } }
Redirect the browser to authorizeUrl. The provider sends the user back to your
redirectUri with a code; complete the sign-in with that code and the
state you started with:
# 2) Complete: creates/links the account by provider identity, returns a session.
mutation Done($input: SocialLoginCompleteInput!) {
socialLoginComplete(input: $input) { token gameTokenId user { userId email } }
}
# variables: { "input": { "provider": "google", "code": "<from-provider>", "state": "<from-step-1>" } }
The framework is provider-agnostic. Today it ships:
| Provider | Enabled when |
|---|---|
google | GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are configured on the server. |
availableLoginProviders reflects exactly what is configured, so drive your
sign-in UI from it rather than hard-coding provider buttons.
Email + password
register creates the account and returns a session immediately. login
returns to an existing one.
mutation Register($registerUserInput: RegisterUserInput!) {
register(registerUserInput: $registerUserInput) { token gameTokenId user { userId email } }
}
# variables: { "registerUserInput": { "email": "player@example.com", "password": "..." } }
mutation Login($loginUserInput: LoginUserInput!) {
login(loginUserInput: $loginUserInput) { token gameTokenId user { userId email } }
}
# variables: { "loginUserInput": { "email": "player@example.com", "password": "..." } }
Two behaviours to code against, because both are easy to meet by accident:
registeron an address that already has an account does not sign you in. The password is attached pending email confirmation and the mutation throws. This stops somebody who only knows an address from claiming a password on an account they do not control. Fall back tologin.loginrefuses an unconfirmed password when the account has another verified sign-in method, with "Confirm your email to enable password sign-in for this account." The remedy is the emailed confirmation link, not a different password. A password-only account signs in immediately, because there is no other method to protect.registerfrom a browser carries the clickwrap. A request with anOriginheader must sendacceptLegal: trueinregisterUserInput(the player ticked the box), or it is refused withLEGAL_ACCEPTANCE_REQUIREDbefore any account exists;attestAgeOfMajorityis ignored since 2026-11-04. A request with noOriginmay omit it, but no gameplay token is issued until it is recorded withrecordPlayerConsents. See Terms, age and country.registertakes the player's birth date and country (birthDate,country, andparentalConfirmationunder 18; Game API v2.60.0). Every sign-up is refusedTERRITORY_NOT_SUPPORTEDfrom a place Crowded Kingdoms does not serve, and one whose birth date is too youngAGE_REQUIREMENT_NOT_MET, before any account exists. That refusal names no age, and the same device or client is then locked out for a while. A browser sign-up must give the birth date (AGE_REQUIREDwithout it). See Signing up.
Managing passwords
Four mutations, and which one applies is decided by what the caller has already proven rather than by what they want to do:
| Mutation | Needs | Use when |
|---|---|---|
requestPasswordReset(email) | nothing (public) | the user is not signed in, or has forgotten the password |
resetPassword(resetPasswordInput: { token, newPassword }) | the token from the emailed link | completing the above |
changePassword(currentPassword, newPassword) | a session and the current password | an ordinary change |
setInitialPassword(newPassword) | a session, and the account must have no password | adding password sign-in to a magic-link or social account |
checkAuthMethod(input: { email }) answers hasPassword for an address before
sign-in, without revealing whether the address is registered.
# Signed in, and the account has no password yet (magic link or social only).
mutation Add($newPassword: String!) { setInitialPassword(newPassword: $newPassword) }
# Signed in, and it does.
mutation Change($currentPassword: String!, $newPassword: String!) {
changePassword(currentPassword: $currentPassword, newPassword: $newPassword)
}
setInitialPassword refuses an account that already has a password, and
that refusal is the point: without it, the mutation would be changePassword
with the current-password check deleted, and that check is what stops a stolen
session from replacing a credential the owner still knows. The password it sets
works immediately — the session is the proof of account control, so there is no
confirmation email to wait for — and a security notification is emailed to the
account address. That notification is deliberately the mitigation rather than
a refusal: a stolen session can already attach durable attacker-controlled
access with linkIdentity, so refusing here would close nothing while leaving
the legitimate user of a passwordless account with no way in at all.
Sessions are revoked on recovery (ck-api v1.88.0). resetPassword deletes
every session of the account and every app token minted from one -- a reset is
what an owner does after losing control, and the sessions are what the other
party holds. changePassword deletes every session but the calling one, so
the client that changed the password does not have to sign in again.
setInitialPassword leaves sessions alone (it adds a credential to an account
the caller is already signed in to). Until v1.88.0 none of them revoked
anything, and this page said so as if it were a feature.
Rate limits. Every public sign-in mutation is limited per address and per
client; login counts failures per address -- ten in fifteen minutes answers
RATE_LIMITED (429), and the reset email is the way out. The masked mutations
(requestPasswordReset, resendConfirmationEmail, requestLoginLink) stay
masked when limited: their usual success shape, no side effect.
Telling the refusals apart
Each refusal has its own extensions.code from ck-api v1.60.0. Verified
against a live tier:
| Condition | extensions.code | HTTP | Message begins | Remedy |
|---|---|---|---|---|
setInitialPassword, password already set | PASSWORD_ALREADY_SET | 409 | "This account already has a password." | changePassword |
changePassword, no password set | PASSWORD_NOT_SET | 409 | "No password is set on this account." | setInitialPassword |
changePassword, wrong current password | INVALID_CURRENT_PASSWORD | 403 | "Invalid current password" | ask again |
register, address already has an account | EMAIL_ALREADY_REGISTERED | 409 | "An account with this email already exists." | follow the emailed link |
| any, session expired or absent | UNAUTHENTICATED | 401 | — | sign in again |
Only the last one means the session is bad. That row is the reason the other
four exist: before v1.60.0 the first two shared UNAUTHENTICATED with it and
the other two arrived as INTERNAL_SERVER_ERROR, so a client branching on the
code either signed a user out for mistyping their current password or reported a
routine outcome as a server fault. If you must support a tier older than
v1.60.0, branch on extensions.httpStatus (always correct) or on the message
text, which is unchanged in both directions and will stay that way.
The CrowdyJS SDK ships isPasswordAlreadySetError, isNoPasswordSetError,
isInvalidCurrentPasswordError and isAlreadyRegisteredError, each of which
accepts the code and the older wording, so you do not have to carry either —
see Managing passwords.
Federated identities (linking sign-in methods)
An account can have several linked sign-in identities (a Google identity, an email
magic-link identity, …). The account is created on first sign-in; on later
sign-ins an identity is matched by (provider, subject) and linked to an existing
account by verified email, so signing in with Google and with a magic link for
the same verified address resolves to one account.
# The signed-in user's linked identities (requires a session token).
query Mine { myIdentities { identityId provider subject email emailVerified lastLoginAt } }
linkIdentity(input: { provider, code, state })— link an additional identity from asocialLoginStartcallback to the signed-in account. Throws if that identity is already linked to another account.unlinkIdentity(identityId)— remove a linked identity. Refuses to remove your last remaining sign-in method (so you can never lock yourself out).
Sessions and sign-out
logoutends the current session (deletes thegame_tokenthat authenticated the request); other devices stay signed in. Signing out an identity session also revokes every app token it minted — and, since 2026-09-15, tells the replication servers, so a native client's realtime session ends within seconds rather than when its app token would have expired. The same is true oflogoutAllDevices,changePassword,resetPasswordandrevokeAppAuthorization.logoutAllDevicesends every active session for the user.
Security notes
- Treat the session token (and any app token) as a secret; use HTTPS only in production. A token is shown once. The platform stores only a hash of your session, e-mail confirmation, password-reset and organization tokens, so it cannot show one again and a leaked database row is not a credential; app-scoped tokens are the exception (the replication server uses that string as the per-message HMAC key). When this shipped, every credential on the tier was reset once — sessions, app-scoped tokens, organization tokens, and any confirmation / reset link minted before it — so everyone signed in and re-minted again.
requestLoginLinknever reveals whether an address has an account (sentis alwaystrue); one-time link tokens are single-use and short-lived.- Restrict your own frontends' CORS and redirect origins to trusted hosts; the
server validates
redirectUriorigins for both magic-link and social flows. - Authentication is the same on every public environment. There is no weaker "dev" sign-in path.