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
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.
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.
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).
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.
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.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.
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. - There is no environment in which authentication is weaker. The dev bypass
(
devLogin, themockprovider, anddevToken) 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.