---
title: "Authentication"
description: "Your users (called players in the API) start using your app or game before they sign up."
url: "/docs/guides/auth/"
lang: en
index: "/llms.txt"
---
# Authentication

Your users (called *players* in the API) start using your app or game **before** they sign up. `Kumo.init()` creates
a guest on first visit and resumes it on every visit after that; when a user cares about their data, they attach an
email, Google, Discord, Apple or X to the same account and keep everything.

## Guests (automatic)

```js
const kumo = await Kumo.init({ projectKey: 'pk_live_…' });
kumo.auth.player;        // { id, displayName, isGuest: true, createdAt, identities: [{ provider: 'guest' }] }
await kumo.auth.setDisplayName('Ace');   // shown in rooms
```

Pass `autoGuest: false` to `init` if you want to show your own sign-in screen first. Sessions are kept in
`localStorage` per publishable key (so dev and prod never mix); if storage is blocked (private mode, some iframes),
the SDK falls back to memory and the user is a new guest on each reload.

## Email and password

```js
// turn the current guest into a permanent account — their data stays
await kumo.auth.linkEmail('ace@example.com', 'correct horse battery');

// on another device
await kumo.auth.signInWithEmail('ace@example.com', 'correct horse battery');

// a brand-new account without a guest phase
await kumo.auth.signUpWithEmail('ace@example.com', 'correct horse battery', 'Ace');

await kumo.auth.signOut();
kumo.auth.onChange((player) => renderAccount(player));   // null when signed out
```

Passwords need at least 8 characters. Sign-in errors never reveal whether an email is registered.

## The KUMODeck news box on your sign-up screen (on by default)

Email sign-up screens show one more box, drawn by KUMODeck: "Get news from KUMODeck" — KUMODeck's own news
(new games and features, up to 2 emails a month). It is on by default: **put it on your screen, even if you build the
screen yourself.** You can remove it for your app (below).

```js
const news = await kumo.news.mountNewsOptIn(document.querySelector('#news-box'));  // draws the box (or nothing)
await kumo.auth.signUpWithEmail(email, password);
await news.submit(email);   // does nothing unless the user ticked it
```

Why this is fine for you and your users:

- **The user chooses.** It is a separate box, **unchecked at first**, and signing up works the same without it.
- **A confirmation email decides.** Nothing is sent until the user clicks the link in KUMODeck's confirmation email.
- **It costs you nothing.** KUMODeck pays for this list; it is not on your bill.
- Only adults see it; apps and games for kids (`audience: "kids"`) never show it. The box names KUMODeck and says it is the
  service that runs your app — KUMODeck draws the words so they are always right, so do not write your own.

To remove the box from your app, turn it off in the dashboard (Overview → "KUMODeck news box") or in
`kumo.config.json` and push:

```sh
kumodeck features off kumoNews && kumodeck config push --env production   # writes "kumoNews": { "optIn": false }
```

Keep the code as it is: with the box removed, `mountNewsOptIn` draws nothing and `submit` does nothing. Users who
already signed up keep getting KUMODeck's news until they stop it. Turn it back on with `kumodeck features on kumoNews`.

## Sessions

| Token | Lifetime | Stored |
|---|---|---|
| Access token (JWT) | 15 minutes | memory + `localStorage` |
| Refresh token | 90 days, **rotated on every use** | `localStorage` |

You never handle tokens: the SDK refreshes on `token_expired` and retries once. Tabs of the same app coordinate
through the Web Locks API so two tabs never race the same refresh token. Replaying an already-used refresh token
revokes the whole session chain (a stolen token dies the moment either party uses it again).

Signing out ends the refresh token, but an access token already handed out keeps working until it expires, **up to 15
minutes** (it is checked by its signature, without a lookup). The SDK forgets it on sign-out, so this only matters if
the token was copied elsewhere. A ban stops the user's **writes at once**; their reads keep working until the next
refresh (at most 15 minutes), which then fails and signs them out.

## Sign in with Google, Discord, Apple or X

Every provider is **off until you turn it on** in `kumo.config.json`:

```json kumo.config.json
{
  "auth": {
    "redirectUrls": ["https://mygame.example.com/*"],
    "providers": {
      "google": { "enabled": true },
      "x": { "enabled": true }
    }
  }
}
```

| Provider | What you set up |
|---|---|
| X | Register your own X app and enter its OAuth 2.0 Client ID and Client Secret in the dashboard under **Sign-in methods** → X. Steps: [Your own X app](#your-own-x-app) |
| Google, Discord, Apple | Create an OAuth client with the provider and enter its client id and secret (Apple: Services ID, Team ID, Key ID and private key) in the dashboard under **Sign-in methods**. The redirect URI to register is shown there (`…/v1/auth/oauth/<provider>/callback`). Credentials are stored encrypted and never go in the config file |

```js
// popup by default — call it from a click handler
await kumo.auth.signInWithProvider('google');
await kumo.auth.signInWithProvider('discord', { mode: 'redirect' });   // for in-app browsers
await kumo.auth.linkProvider('apple');                                 // attach to the current guest, keep progress

// after a redirect, the result is picked up by Kumo.init(); read it with:
const result = await kumo.auth.completeRedirectSignIn();   // { player, provider, linked } or null
```

- **Return URLs are allow-listed** in `auth.redirectUrls` (exact URLs, or a trailing `*` for a path prefix). Localhost in
  development and your KUMODeck-hosted URLs are allowed automatically. This stops attackers from receiving sign-in codes
  on their own site.
- OAuth uses PKCE: the code that comes back in the URL fragment is useless without a verifier that exists only in the
  user's browser.
- Accounts are never merged by email. An identity that already belongs to another user fails with
  `identity_already_linked`.
- Inside X's in-app browser the default mode is `redirect`, because popups are unreliable there.
- `kumo.auth.unlinkIdentity(provider)` removes a sign-in method (for example one the user doesn't recognise) and signs
  out every other device. **The last way to sign in can't be removed**: if no other provider or email-with-password would
  be left, it fails with 409 `last_login_method` and nothing changes. Otherwise the user could lose the account (and
  their saves) the moment this device is signed out. Ask the user to add another method first
  (`linkEmail` / `linkProvider`), then remove the old one:

  ```js
  try {
    await kumo.auth.unlinkIdentity('google');
  } catch (e) {
    if (e instanceof KumoError && e.code === 'last_login_method') showHint('Add another sign-in method first');
    else throw e;
  }
  ```

### Your own X app

Sign in with X runs on an X app you register, like the other providers. Until you save one, starting Sign in with X fails
with 409 `provider_not_configured`. It takes 10–20 minutes, once per app (or studio):

1. Sign in to [console.x.com](https://console.x.com) with your X account, accept the Developer Agreement and Policy, and
   describe your use (for example: "users of my web app sign in with X; we only read their public profile").
2. Create an app in the **pay-per-use Production** environment. Apps in the old Free / development environment are refused
   by X (403 `client-not-enrolled`).
3. Name, description and icon: your app's name is fine — it is what X's consent screen shows. Don't use "X", "Twitter" or
   the X logo in any of them.
4. Under **User authentication settings**, turn on OAuth 2.0 with app type **Web App** (confidential client). KUMODeck
   adds PKCE for you, so there is nothing to set for it.
5. Scopes: only `users.read` and `tweet.read`.
6. Callback URL: the `…/v1/auth/oauth/x/callback` URL shown in the dashboard under **Sign-in methods** → X, exactly as shown.
7. Fill in your website, terms of service and privacy policy URLs. Your privacy policy should say what you get from X
   (handle, name, avatar, X user id).
8. Generate the OAuth 2.0 **Client ID and Client Secret** (X shows the secret once), paste them in the dashboard under
   **Sign-in methods** → X and save, then turn on `auth.providers.x.enabled`.

X bills your X app directly for its API use; KUMODeck does not charge for it. According to X, reading the signed-in
user's profile (`/2/users/me`) for sign-in is free.

X's sign-in rules apply to your app's screens: make the Sign in with X button at least as prominent as your other sign-in
options, show the user their X handle, avatar and the X logo after sign-in (`kumo.x.profile()`), and link to your
privacy policy before sign-in.

Where KUMODeck's shared X app is available, the dashboard also offers **Use without setup** instead of your own app. See
[Sharing on X](/docs/guides/sharing/index.md#sign-in-with-x).

## Email verification and password reset

Email links come back to your app's page (it must be in `auth.redirectUrls`). If you don't pass a `redirectUrl`, the link
opens a small, unbranded built-in page on your app's domain (`/.auth/verify`, `/.auth/reset`) and finishes the job there.
Passing your app's URL lets your own screen handle it.

```js
await kumo.auth.resendVerification();                     // for the signed-in email account
await kumo.auth.sendPasswordReset('ace@example.com');     // always succeeds (never reveals whether an email exists)

// when the page is opened from a reset email:
const pending = kumo.auth.pendingAction();                // { type: 'reset_password', token } or null
if (pending) await kumo.auth.resetPassword(pending.token, newPassword);
```

Verification links last 24 hours and reset links 30 minutes; each works once. A password reset signs the user out
everywhere. `kumo.auth.player.emailVerified` tells you whether the email was confirmed.

## Data export and account deletion

App stores require in-app account deletion and privacy laws give users a right to a copy of their data. Put both in your
settings screen:

```js
const data = await kumo.account.exportData();       // everything stored about this player, as JSON (no password hashes)
await kumo.account.delete({ password });            // irreversible; guests confirm with their session instead
```

Deleting removes the user's data. `auth.exportData()` and
`auth.deleteAccount()` are the same functions. You can also export or delete a user from your server
(`/v1/admin/players/:playerId/export`, `/v1/admin/players/:playerId/delete`) or the dashboard.

## Banning players

Ban a user who cheats or harasses others. The ban signs them out on every device, closes their room connections at
once and refuses their sign-ins and writes. Banning always works: there is no feature to turn on.

| From | How |
|---|---|
| Dashboard | **Players** → the user → **Ban player** / **Unban player** |
| AI agent (MCP) | `player_ban` / `player_unban` (asks you to confirm every time) |
| Your server or Functions (secret key) | `POST /v1/admin/players/:playerId/ban` `{ reason?, durationHours? }` · `POST /v1/admin/players/:playerId/unban` `{}` |

From your server or Functions, `durationHours` (more than 0, up to 8784 = 366 days) makes the ban end by itself; leave it
out and the ban lasts until you lift it. `reason` (up to 500 characters) is for you: it shows in the dashboard and the
audit log. The answer is `{ player: { id, banned, bannedAt, banReason, revokedSessions, banExpiresAt } }`. An unknown
user, or one from another environment, is 404 `player_not_found`. Publishable keys cannot ban.

There is only one ban per user: a ban set in any of these places can be lifted from any other. Your code can ban a
cheater the moment it catches one; in the Functions starter, `players.ban()` / `players.unban()` in `src/kumo.ts` do the
call for you ([Functions](/docs/guides/functions/index.md)).

### Ban appeals

A banned user can ask you to look at the ban again, once per ban. Appeals are part of banning, so they are always
available. In your app, read the ban and send the appeal:

```js
const { sanctions } = await kumo.sanctions.mine();
const ban = sanctions.find((s) => s.kind === 'ban' && s.active);
if (ban && !ban.appeal) await kumo.sanctions.appeal(ban.id, message); // one appeal per ban, up to 2000 characters
```

The banned user can read their bans with `kumo.sanctions.mine()` (`GET /v1/players/me/sanctions`) while their session
is still open, up to 15 minutes after the ban, so show the appeal form then. On a later visit the SDK still sends the
appeal: it keeps the refresh token the ban revoked and uses it as proof (`POST /v1/auth/appeal`). If the app no longer
knows the ban's id, call `kumo.sanctions.appeal(null, message)`: the appeal goes to the ban that revoked that token.

You answer each ban appeal in the dashboard (**Players** → **Ban appeals**: accept and unban, or reject, with an optional
short reply), with your AI agent (`appeals_list` / `appeal_resolve`, asks you to confirm every time), or from your server
(`GET /v1/admin/appeals?status=open` · `POST /v1/admin/appeals/:appealId/resolve` `{ accept, response? }`).
Afterwards the ban's appeal shows `status: 'accepted'` (the ban was lifted) or `'rejected'`, with your reply in `response`.

## Server-side checks

If your own server (or your Functions) needs to know who a user is, have the page send its access token
(`Authorization: Bearer <token>`, from `await kumo.auth.getAccessToken()`), then ask KUMODeck from your server with
your environment's **secret key**:

```http
POST /v1/admin/players/verify-token
Authorization: Bearer sk_…
{ "token": "<the user's access token>" }
→ { "player": { "id", "displayName", "banned" } }
```

A token from another environment, or an expired one, is 401 `token_expired` (the page refreshes and retries). Check
`banned` yourself: a banned user still verifies, so your code decides what they may do. The Functions starter wraps
this as `players.verify()` in `src/kumo.ts` (with a 30-second cache), and the `requireUser()` helper in the
`functions-d1` Skill answers 401 / 403 for you ([Functions](/docs/guides/functions/index.md)). Keep the secret key on the
server; never send it to the page. See the [REST API](/docs/reference/rest-api/index.md).
