> For the complete documentation index, see [llms.txt](https://sdk.metamarket.tech/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://sdk.metamarket.tech/evaluate-and-integrate/authentication.md).

# Authentication

Player and device authentication is the flow a client uses to sign a human in and keep them signed in. Authenticating your *application* to the platform is a separate mechanism, covered in [App Configuration](/evaluate-and-integrate/app-configuration.md).

Read top to bottom for the full model, or jump straight to what you need: [the architecture](#how-authentication-works), [the signing scheme for session issuance](#signing-requests-with-the-device-key), [first sign-in](#signing-in-a-device), [staying signed in](#refreshing-a-session), [adding a second device](#linking-a-second-device), [revoking one](#unbinding-a-device), [resuming a session on app launch](#resolving-a-session-at-startup), and [the separate app-level credential](#app-to-app-authentication).

## How authentication works

The Hub owns identity end to end: auth service, signature verifier, token store (see [Overview](/architecture-and-trust/product-overview.md#the-three-layers) for how the Hub relates to the per-game engine and edge gateway). The per-game engine and edge gateway do not own any of this; they only forward requests to the Hub. Anonymous auth calls are forwarded unchanged. Calls that require a session (`handoff/issue`, `device/unbind`) arrive at the proxy as Bearer-JWT calls; the proxy reads the caller's player id from the JWT and forwards it server-to-server to the Hub.

An external client calls these endpoints under `/api/auth/*` through the edge gateway. The gateway forwards them to the game engine, which proxies them on to the Hub under its own platform credential. That is the route this page documents; you do not address the Hub directly, and the engine sits on the path even though identity is the Hub's.

## Signing requests with the device key

Every device has an ECDSA **P-256** keypair generated on-device. The private key never leaves the device; the public key is registered with the Hub as a **SubjectPublicKeyInfo PEM** string (`devices.public_key`). A session is issued after the device proves possession of that private key, with one exception: the first-time bootstrap of an unknown device (see below), which has no key on file to prove against yet. For a device the Hub already knows, a device id on its own, or a refresh token on its own, is not enough to be issued a session.

The signed payload is always the same shape: a UTF-8 string with three `|`-joined fields:

```
"{context}|{signedAtUnix}|{nonce}"
```

`context` is call-specific: the `deviceId` for a known-device login, the `refreshToken` for a refresh, the handoff `code` for an exchange. The signature is produced and verified as SHA-256 ECDSA, using the **IEEE P1363** signature encoding (not DER/ASN.1). A non-.NET client must produce raw `r‖s` P1363 signatures, not a DER sequence, or the Hub rejects the request with `401`.

The proof object sent alongside the request:

```json
{
  "signedAtUnix": 1752230400,
  "nonce": "6F1C2A9E...",
  "signature": "ybr7cN/bVUmk2yJUFsQgFQd8dU6NCLl..."
}
```

Server-side checks, in order:

1. **Freshness**: `|now − signedAtUnix| ≤ 60s`, else reject.
2. **Signature**: verified against the device's stored public key over the exact context string above.
3. **Nonce**: single-use, checked *after* the signature passes; an in-memory set dedups nonces for 120s.

A proof is generated **once per session-issuance call** (login, refresh, exchange), not per request. This is key-bound rotation, not DPoP; the client SDK's device crypto provider implements proof creation for you.

## Signing in a device

`POST /api/auth/device-login` (anonymous, through the gateway) takes `{ deviceId, publicKey, deviceInfoJson?, proof? }` and behaves as a *resolve*, not an always-create:

* **`deviceId` unknown to the Hub** → trust-on-first-use (TOFU) bootstrap: a new guest `Player` is created, the device row is created with the presented `publicKey`, and a token pair is issued. No proof required. There is nothing to prove possession *against* yet.
* **`deviceId` known** → the device must sign the proof (`context = deviceId`). The Hub rejects (returns 401) if: the device is `is_revoked`; the presented `publicKey` does not match the one on file (a takeover attempt); or the signature/nonce check fails. A bare known `deviceId` never yields tokens.

On success the response is `{ accessToken, refreshToken, accessTokenExpiresAt }`. Access tokens are RS256 JWTs with a 15-minute lifetime; refresh tokens live 30 days server-side.

Issuing a new session for a device (`IssueSessionAsync`) first revokes every other active refresh token on file for that `deviceId`. A device holds at most one live refresh token at a time; logging the same device in twice in a row invalidates the previous session for it.

## Refreshing a session

`POST /api/auth/refresh` takes `{ refreshToken, proof }`, where `proof` signs the refresh token itself (`context = refreshToken`). The Hub looks up the token, checks it is not revoked/expired, checks the owning device is not revoked, and verifies the proof against that device's public key. On success the token row is rotated **in place** (new hash, new expiry) and a fresh `AuthResponse` is returned.

Refresh tokens are key-bound: a captured refresh token is useless without the device's private key, because the refresh proof is signed with that key. Additional account-hardening is on the [Roadmap](/releases-and-reference/roadmap.md).

## Linking a second device

Handoff links two different app installs to one player. The server always sees two different devices, even on the same physical phone, because each app's OS sandbox holds its own keypair. The primitive is symmetric: either app can issue, either can exchange.

```mermaid
sequenceDiagram
    participant A as App A (has session)
    participant Hub as Hub.Api
    participant B as App B (new device)

    A->>Hub: POST /api/auth/handoff/issue {targetAppId} (Bearer JWT)
    Hub-->>A: { code, expiresAt }
    A-->>B: hand off code (out-of-band)
    B->>Hub: POST /api/auth/handoff/exchange {code, deviceId, publicKey, proof over code}
    Hub->>Hub: consume code (single-use) + verify proof
    Hub-->>B: { accessToken, refreshToken, accessTokenExpiresAt }
```

* **`POST /api/auth/logout`** (anonymous) → `{ refreshToken }` → revokes that refresh token and ends the session. This is the ordinary sign-out: the device keeps its registered key and can sign in again. Use it instead of `device/unbind`, which is not reversible.
* **`POST /api/auth/handoff/issue`** (Bearer JWT, proxy resolves the caller's player id) → `{ targetAppId }` → returns a one-time `code` + expiry. `targetAppId` records the app the code is intended for. It is stored with the ticket and is not compared when the code is exchanged, so any holder of the code can redeem it from any app; treat the code itself as the secret.
* **`POST /api/auth/handoff/exchange`** (anonymous) → `{ code, deviceId, publicKey, deviceInfoJson?, proof }`, where `proof` signs the `code`. This is a TOFU key registration for App B's device. The Hub validates the code (not expired, not already consumed; codes are removed on consume so a captured code can't be replayed even inside its TTL), binds `deviceId` to the issuing player, and returns a session for App B. If `deviceId` already exists under a *different* public key, the exchange is rejected as a conflict rather than silently rebinding.

The handoff code is valid for 300 seconds. Code secrecy depends on how you transport it (a real delivery channel like Universal/App Links, rather than copy/paste, is recommended).

## Unbinding a device

`POST /api/auth/device/unbind` (Bearer JWT) → `{ deviceId }`. The Hub checks the device belongs to the caller's own player (mismatch or unknown device → 404, not 401/403), then sets `is_revoked=true` on the device and revokes every active refresh token tied to it. After unbind, both `/api/auth/device-login` (known-device path) and `/api/auth/refresh` return 401 for that device. An access token already issued to it remains valid until it expires (up to 15 minutes); unbind blocks renewal and future login rather than killing a token already in flight. The unbound device is not restored; the player regains access on a different device through a new device-login (a fresh guest) or a handoff.

## Resolving a session at startup

The recommended client boot order, mirrored by the SDK's own `IAuthClient` usage:

1. A stored refresh token and the device key are present → `refresh`.
2. Launched with a handoff code → `handoff/exchange`.
3. The device key is present but the refresh token is unusable (for example it expired) → `device-login` for the known device, signing the proof with that key.
4. No device key on the client at all → `device-login` with no proof, which bootstraps a new guest.

After a device is unbound it can no longer refresh or log in; the player continues by handing off from another signed-in device, or starts over as a new guest.

The implemented credential is the guest bootstrap. Passkey and social sign-in, which would add a step to this boot order, are on the [Roadmap](/releases-and-reference/roadmap.md). For the full client SDK surface, see [sdk-guide.md](/evaluate-and-integrate/sdk-guide.md). The device private key never leaves the device; where and how a given client stores it is a client responsibility settled per integration, not fixed by these docs.

## The authenticated player's identity

Once a session is issued, the player it belongs to is the token's subject: the access token carries the player id in its `sub` claim, and the SDK exposes it as `session.PlayerId`. That id is what an action's `playerId` field names. The display name is issued with the identity and travels with it at the platform level; a connected game receives the shared identity (id and display name) rather than holding the credential behind it.

Handoff codes are held in memory on the Hub. A restart invalidates any code that has not been redeemed yet, and in a load-balanced deployment a code is only redeemable against the instance that issued it. Issue a code at the moment the player is ready to use it rather than ahead of time.

## Abuse controls

A few properties bound the obvious abuse paths. A handoff code is single-use and short-lived. The current window is 300 seconds, widened for a desktop copy-paste flow; treat it as configurable and do not size a partner UX around it; it is removed the moment it is consumed, so it cannot be replayed even inside its window, and exchanging a device id that already exists under a different key is rejected as a conflict rather than silently rebinding. A known device must prove possession of its registered key to be issued or refresh a session.

Guest bootstrap is the one point without that proof: an unseen device id and the public key it presents are accepted as a new guest, since there is nothing yet to prove against. The server does not verify at that moment that the key is freshly generated or that the caller controls the matching private key, so bootstrap is not in itself an abuse control. MetaMarket does not enforce request rate limits today (see [Production Operations](/architecture-and-trust/production-operations.md)), and edge rate limiting is on the [Roadmap](/releases-and-reference/roadmap.md); how repeated guest creation and handoff attempts are bounded for a given deployment is therefore an open question reviewed with the team during due diligence rather than a control asserted here.

## App-to-app authentication

Everything above authenticates a **player/device**. Authenticating your **application** is a separate, orthogonal mechanism: the app's own server-to-server credential, which for an external game is held by the edge gateway and never reaches your client. It has no relationship to the ECDSA device keys on this page. See [App Configuration](/evaluate-and-integrate/app-configuration.md) for how that credential works.

***

Last reviewed 2026-07. Documentation release `docs-release-2026-07`; runtime `net10.0`. The SDK, HTTP API, and configuration schema are separately unversioned ([Compatibility](/releases-and-reference/compatibility.md)). Status source of truth: [Product availability](/overview/current-scope.md).
