Skip to main content

Sign in

A user authenticates one of three ways, and every path returns an identity session token (an AuthResponse):

  • Email + passwordregister to create an account, login to 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.

Building a browser game on your own domain? You do not call these.

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 first-party browser origins (Crowded Kingdoms Studio and crowdy.games) 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.

The dev bypass is gone

devLogin and the devToken field on requestLoginLink were removed on 2026-08-20 — deleted, not disabled, so no environment variable brings them back. devLogin returned a session for any address with no proof of ownership, and devToken put the emailed one-time token in the response body where any unauthenticated caller could read it. Automated clients should register an account they hold the password to.

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.

SDK clients

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 }.

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).

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:

ProviderEnabled when
googleGOOGLE_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:

  • register on 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 to login.
  • login refuses 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.

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:

MutationNeedsUse 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 linkcompleting the above
changePassword(currentPassword, newPassword)a session and the current passwordan ordinary change
setInitialPassword(newPassword)a session, and the account must have no passwordadding 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:

Conditionextensions.codeHTTPMessage beginsRemedy
setInitialPassword, password already setPASSWORD_ALREADY_SET409"This account already has a password."changePassword
changePassword, no password setPASSWORD_NOT_SET409"No password is set on this account."setInitialPassword
changePassword, wrong current passwordINVALID_CURRENT_PASSWORD403"Invalid current password"ask again
register, address already has an accountEMAIL_ALREADY_REGISTERED409"An account with this email already exists."follow the emailed link
any, session expired or absentUNAUTHENTICATED401sign 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 a socialLoginStart callback 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

  • logout ends the current session (deletes the game_token that authenticated the request); other devices stay signed in. Signing out an identity session also revokes every app token it minted.
  • logoutAllDevices ends every active session for the user.

Security notes

  • Treat the session token (and any app token) as a secret; use HTTPS only in production.
  • requestLoginLink never reveals whether an address has an account (sent is always true); one-time link tokens are single-use and short-lived.
  • Restrict your own frontends' CORS and redirect origins to trusted hosts; the server validates redirectUri origins for both magic-link and social flows.
  • There is no environment in which authentication is weaker. The dev bypass (devLogin, the mock provider, and devToken) used to make non-production tiers different, which meant a deploy could get security wrong; it is deleted rather than switched off, so there is nothing left to configure incorrectly.