Introduction
This documentation refers to version 1.0 of Five Minute Player Authentication.
User authentication lets your client prove who the user is when it connects, instead of simply declaring an id. A signed-in user gets a stable unique user id that only they can use, a session that survives reconnects, and sign-in methods they can add or remove. Leaderboards, experiments, metric events and moderation all act on the unique user id, so signing users in is what makes those features trustworthy: without it, any client can claim any id.
This document is for developers of clients that sign users in, and for the developers of your own server, who need to know which unique user id to use and what the server can and cannot do about a user's identity.
Requirements
- A Five Minutes client library on protocol 2.0: the npm packages 2.0 for a web page or another JavaScript client, which sign users in with their
signInoption (see npm packages 2.0 and Client libraries in the Five Minute wire protocol). - The Unity asset cannot sign users in: it speaks protocol 1.7 or earlier (asset 1.20 speaks protocol 1.6, asset 1.23 speaks protocol 1.7). Its users keep self-declared ids, and it is refused by any application that turns on Require authenticated sessions. So are the npm packages 1.8.
- Signing users in works on every plan. Requiring it for every login is a per-application setting that depends on your plan (see Requiring authenticated sessions).
- For the Custom sign-in method: your own backend that verifies users (for example through Google, Apple, Steam or your own accounts).
Quick facts for AI agents
- Covers: user sign-in on the wire (Device and Custom), the identity resolution order, sessions and tokens, logout, link and unlink, refusals and client retry logic, the Require authenticated sessions setting, and which unique user id your server uses.
- Build with: the client signs in through a Five Minutes client library on protocol 2.0, never through its own implementation of the protocol: in JavaScript, the npm packages 2.0 with
signIn,session,onSessionChangedandlogout()(see npm packages 2.0); the Unity asset does not sign users in. Your server uses the Server API routes named below. - Your server: base URL
https://signalr.fiveminutes.cloud/api/v1. Signing a user in needs no server call. Related routes:POST /api/v1/{applicationId}/user/register(scopeApplication.Users.Registerfor Standard users, orApplication.Admin.FullAccess, "Full access") pre-creates a user that a later sign-in adopts, which is how your server chooses a user's unique user id (see Knowing which user a client is); minting ephemeral app keys (Application.ClientCredentials.Mint) and banning users (Application.Admin.FullAccess) are documented in the Server API. - Server auth:
POST /api/v1/api-clients/authenticatewith{"applicationId":"<application id>","clientSecret":"<API key secret>"}returns{"token","expiresInSeconds":600,"scopes"}; sendAuthorization: Bearer <token>. - Client: protocol 2.0; messages
ClientUserInfoResponse(sign-in fields),ServerWelcome,ClientLogoutRequest,ClientLinkIdentityRequest,ClientUnlinkIdentityRequestand their typed responses; send only after ServerWelcome; a new MessageId per message; match replies on ResponseToMessageId. (ClientUserInfoResponseis the login step itself;ServerWelcomeanswers it.)- Resolution order:
SessionToken>RefreshToken>AuthProvider> legacy. The first non-empty field wins; a failure never falls through to the next one. - Refusal:
ServerGoodbyewithReasonTypeUserAuthenticationFailedandAllowAutoReconnectfalse. A server error or an outage during the sign-in, or a sign-in the server could not decide yet, is no refusal:ReasonTypeInternalServerErrorandAllowAutoReconnecttrue. Keep the stored tokens and the credential. - Access token lifetime 3600 seconds; refresh token 30 days, sliding, single use. New tokens arrive only on refresh and credential logins.
ServerWelcome.UniqueUserIdis authoritative on every login. Always adopt it.AuthProvideris exactlyDeviceorCustom(case-sensitive).
- Resolution order:
- Identifiers: unique user id (
UniqueUserId): 64 lowercase hex characters for a user created by signing in, or the adopted legacy id (at most 256 characters). It is private to the user: never show it to other users or send it to them; they address the user byDisplayId. Refresh token: 64 lowercase hex characters. Access token: a JWT; treat it as opaque. - Limits:
UniqueUserId1-256 characters;Username3-64 letters, digits or-;AuthDeviceIdat most 256 characters;AuthCustomIdat most 256;AuthSecretat most 1024. - Retries: never repeat a refused input automatically; after a refused access token or refresh token, log in once with the next input. After an
InternalServerErrorgoodbye, log in again with the same input and the sameUniqueUserIdafter a bounded backoff, at most 5 times; the same credential signs in the same user. Every attempt uses a new connection, new MessageIds and, if you use them, a new ephemeral app key. Repeating a logout, or a link to the same user, is harmless. - Does not exist: an in-session token refresh message; a REST endpoint to verify an access token, look up the user behind a
CustomId, list or revoke sessions, or list identities; a public JWKS; a message that lists a user's sign-in methods; account merge; Custom secret rotation; a warn-only mode for Require authenticated sessions; anyAuthProviderother thanDeviceandCustom. - Related: Five Minute wire protocol, Five Minute Server API, Five Minute API Contracts.
Concepts
Application admission and user identity. Every connection passes two separate checks. Application admission is the handshake step where the client sends ClientCredentialsResponse with your Application ID and an application secret or ephemeral app key; it proves the connection belongs to your application and says nothing about who the user is. User identity is the next step, ClientUserInfoResponse, which decides which user the connection acts as for the rest of its life. This document covers the second step. The handshake itself is described in the Five Minute wire protocol.
Application ID. The GUID that identifies your application.
Application secret. The static secret the client sends in the handshake (dashboard: "App client secrets"). It admits the application only and is never used for REST calls.
Ephemeral app key. An ecs_... string your server mints for one connection and hands to the client, which sends it in place of the application secret. It admits the application only and carries no user identity.
User. One account in one application. Users never span applications: the same device or the same CustomId in two applications gives two unrelated users.
Unique user id. The id every other feature uses for the user: UniqueUserId on the wire, uniqueUserId in REST, ownerId for a user's leaderboard record. A user created by signing in gets an id of 64 lowercase hex characters. A legacy user, or a legacy user adopted by a sign-in, keeps the id the client declared, for example user-42. The unique user id is private to the user: never show it to other users or send it to them, and never put it into anything other users receive, such as the user's name or metadata, chat, whisper or leaderboard metadata, or a channel name. Other users see and address the user by display id. Until your application requires authenticated sessions, anyone who knows a unique user id can log in as that user (see Legacy login).
Display id. DisplayId: the public id other users see and use to address the user. It is not a secret. It is generated from the user's name when the user is created (the first six characters of the name, padded with x, then seven digits, for example Alicex4821937) and generated again when the name is changed with ClientSetUsernameRequest or the Server API. A rename through Username or AuthUsername at login keeps the display id. Leaderboard entries show other users by display id and name (OwnerDisplayId, OwnerName), never by unique user id.
Legacy login. A login that presents no token and no credential. The server trusts the UniqueUserId the client declares (a self-declared id), so anyone who knows or guesses the id can act as that user. Every client below protocol 2.0, including every version of the Unity asset, logs in this way.
Authenticated login. A login that presents a SessionToken, a RefreshToken or an AuthProvider credential, and so proves the user's identity.
Sign-in method. A credential attached to a user, also called an identity. There are two providers, Device and Custom. A user can hold several of each.
Device. Sign-in with a device id alone. There is no secret: whoever has the device id is the user.
Custom. Sign-in with a CustomId and Secret pair that your own backend issues after it has verified the user.
Access token. A short-lived token (3600 seconds) returned in ServerWelcome.AccessToken. The client presents it as SessionToken when it reconnects.
Refresh token. A long-lived token returned in ServerWelcome.RefreshToken. It is valid for 30 days, and every use replaces it with a new one that is valid for another 30 days. The client presents it as RefreshToken to get a new token pair when the access token has expired. Each refresh token works once.
Session. One sign-in of one user, typically on one device. Every credential login starts a new session; a refresh continues it with a new token pair; logout, revocation or 30 days without a refresh end it. An access token and a refresh token always belong to one session and one application.
Require authenticated sessions. A per-application setting. When it is on, a login that presents no token and no credential is refused, and a user can no longer name another user by that user's unique user id.
How a login decides who the user is
The server reads the sign-in fields of ClientUserInfoResponse in a fixed order and takes the first branch whose field is non-empty (a value of only spaces counts as empty). A failure in the chosen branch refuses the login; the server never falls back to a later branch. An outage is no such failure: when a service the branch needs does not answer, the login ends with a goodbye that allows a reconnect, and nothing about the user is decided (see When a login is refused).
| Branch | Taken when | Server checks | On failure | ServerWelcome returns |
|---|---|---|---|---|
| SessionToken | SessionToken is non-empty. Always validated, on every application, even when Require authenticated sessions is off. |
The access token is genuine, not expired, issued for this application, and its session has not been revoked. | ServerGoodbye UserAuthenticationFailed. The refresh token and the credential are not tried. |
AccessToken and RefreshToken empty, ExpiresIn 0, UniqueUserId = the token's user, which overrides the UniqueUserId you sent. No new tokens: keep your stored pair. |
| RefreshToken | SessionToken is empty and RefreshToken is non-empty. |
The refresh token is the current one of its session (not already used), not revoked, not expired, issued for this application, and the user is not banned. | ServerGoodbye UserAuthenticationFailed. The credential is not tried. |
A new, rotated AccessToken and RefreshToken, ExpiresIn 3600, UniqueUserId. |
| AuthProvider | Both token fields are empty and AuthProvider is non-empty. |
The provider name, the credential fields, the credential itself (or AuthCreate for an unknown one), and that the user is not banned. |
ServerGoodbye UserAuthenticationFailed. |
A new AccessToken and RefreshToken, ExpiresIn 3600, UniqueUserId = the existing user, the adopted legacy id, or a newly created id. The UniqueUserId you sent is only an adoption hint. |
| Nothing (legacy) | SessionToken, RefreshToken and AuthProvider are all empty. Credential fields sent without AuthProvider are ignored. |
Only whether the application requires authenticated sessions. | ServerGoodbye UserAuthenticationFailed when the setting is on. |
AccessToken and RefreshToken empty, ExpiresIn 0, UniqueUserId = the id you sent. |
After the branch has decided who the user is, the server completes the login as that user. On the SessionToken and legacy branches a banned user is refused at this point, with InvalidCredentials; on the RefreshToken and AuthProvider branches the ban is already caught inside the branch, with UserAuthenticationFailed. A non-empty Username renames the user on every branch (see User names).
Rules that follow from the order:
- Present one input per login: a token or a credential. You may send
SessionTokenandRefreshTokentogether, but only theSessionTokenis used. A valid one leaves the refresh token unused; an expired one refuses the login even though the refresh token would have worked. UniqueUserIdmust be set on every login (the contracts library refuses to send an empty one). On the token branches it is ignored; on the AuthProvider branch it is offered for one-shot adoption; only on the legacy branch is it trusted.ServerWelcome.UniqueUserIdis set on every login of a protocol 2.0 client, including a legacy login. Store it and use it as the unique user id everywhere, including in your server's calls.- Replace your stored token pair only when
ServerWelcome.AccessTokenis non-empty.
Example: first sign-in with a device
A fresh install signs in with its device id. No legacy user has the id it sends, so a new user is created.
ClientUserInfoResponse:
| Field | Value |
|---|---|
UniqueUserId |
<new GUID> (any non-empty value; see Keeping existing users) |
Username |
empty |
SessionToken |
empty |
RefreshToken |
empty |
AuthProvider |
Device |
AuthDeviceId |
<device id> |
AuthCustomId |
empty |
AuthSecret |
empty |
AuthCreate |
true |
AuthUsername |
Alice |
ServerWelcome:
| Field | Value |
|---|---|
DisplayId |
Alicex4821937 |
AccessToken |
<access token> (a JWT) |
RefreshToken |
<refresh token> |
ExpiresIn |
3600 |
UniqueUserId |
<unique user id> |
The client stores the pair, the expiry (receipt time + 3600 seconds) and the unique user id.
Example: signing in through your backend
Your backend has verified the user and returned a CustomId and Secret. Your app used to log this user in as the legacy id user-42, and keeps sending that id so the existing user is adopted.
ClientUserInfoResponse:
| Field | Value |
|---|---|
UniqueUserId |
user-42 (the id your app used before) |
Username |
empty |
SessionToken |
empty |
RefreshToken |
empty |
AuthProvider |
Custom |
AuthDeviceId |
empty |
AuthCustomId |
<custom id> |
AuthSecret |
<secret> |
AuthCreate |
true |
AuthUsername |
empty (ignored for Custom) |
ServerWelcome:
| Field | Value |
|---|---|
DisplayId |
the display id user-42 already had |
AccessToken |
<access token> |
RefreshToken |
<refresh token> |
ExpiresIn |
3600 |
UniqueUserId |
user-42 (adopted) |
Had user-42 not existed, or had another sign-in already adopted it, UniqueUserId would be a new 64-character hex id instead.
Example: reconnecting with a valid access token
The client reconnects 20 minutes later. Its access token has 40 minutes left, so it sends only the access token.
ClientUserInfoResponse:
| Field | Value |
|---|---|
UniqueUserId |
<unique user id> (the stored id) |
SessionToken |
<access token> |
RefreshToken |
empty |
AuthProvider |
empty |
AuthDeviceId, AuthCustomId, AuthSecret, AuthUsername |
empty |
AuthCreate |
true (the library default; ignored on this branch) |
ServerWelcome:
| Field | Value |
|---|---|
AccessToken |
empty |
RefreshToken |
empty |
ExpiresIn |
0 |
UniqueUserId |
<unique user id> |
The client keeps its stored pair and its stored expiry.
Example: reconnecting after the access token expired
The client reconnects the next day. The access token has expired, so it sends only the refresh token. Sending the expired access token as well would refuse the login.
ClientUserInfoResponse:
| Field | Value |
|---|---|
UniqueUserId |
<unique user id> |
SessionToken |
empty |
RefreshToken |
<refresh token> |
AuthProvider |
empty |
AuthCreate |
true (ignored on this branch) |
ServerWelcome:
| Field | Value |
|---|---|
AccessToken |
<new access token> |
RefreshToken |
<new refresh token> (the old one no longer works) |
ExpiresIn |
3600 |
UniqueUserId |
<unique user id> |
The client replaces its stored pair and expiry at once.
Signing in with a device (Device)
The Device method signs a user in with a device id alone. It is the right choice for guest access and for apps without accounts.
Set these fields on ClientUserInfoResponse and leave both token fields empty:
| Field | Value |
|---|---|
AuthProvider |
Device (exactly this spelling) |
AuthDeviceId |
your stable per-install id, at most 256 characters |
AuthCreate |
true to create the user the first time this device is seen; false to refuse unknown devices |
AuthUsername |
optional name for the user; see below |
UniqueUserId |
the stored unique user id, or the old legacy id on the first sign-in (see Keeping existing users) |
Rules:
- The device id is a bearer credential. There is no secret, so anyone who has the device id is the user. Never show it, log it or send it anywhere else.
- A device id belongs to one application. The same device id in two applications gives two unrelated users.
- The contracts library sets
AuthCreatetotrueunless you set it tofalse; the server applies no default of its own. Withfalse, an unknown device is refused with "Player authentication failed: credential rejected." AuthUsernamenames the new user when the device is first seen, and renames the existing user on every later Device sign-in that carries it. A non-emptyUsernamein the same message wins, because it is applied afterwards. LeaveAuthUsernameempty unless you want to set the name.- Use a stable id per install: generate a random GUID on first launch and persist it (on WebGL, in browser storage). Reinstalling, or clearing the storage, loses the id; the next Device sign-in with
AuthCreatetruethen creates a new user. Link a Custom sign-in method if users must be able to recover their account on another device. - Five Minutes never stores the raw device id. The dashboard shows a keyed hash of it instead.
With the npm packages 2.0, pass the credential as signIn. The client fills AuthProvider, AuthDeviceId and AuthUsername from it and AuthCreate from create (default true), and refuses a device id that is empty or longer than 256 characters, and a name that breaks the rules, before it connects. createDeviceId() makes the per-install id, also on pages served over http:
import { ChatClient, createDeviceId } from "@fiveminutes-io/chat-client";
// Once per install: a new device id is a new user
const deviceId = localStorage.getItem("fm-device") ?? createDeviceId();
localStorage.setItem("fm-device", deviceId);
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
signIn: { provider: "Device", deviceId, username: "Alice" },
uniqueUserId: legacyId, // the id your app used before, on the first sign-in; see Keeping existing users
session: storedSession, // the stored session, see Sessions and tokens
});
await client.connect();
Signing in through your backend (Custom)
The Custom method signs a user in with a CustomId and Secret pair that your own backend issues after it has verified the user with any system you choose. Use it for accounts, cross-device access and platform logins.
- Client: signs the user in with your system (Google, Apple, Steam, your own accounts or anything else) and calls your backend with the proof that system gives it.
- Your server: verifies the proof and gets a stable subject for the user (for example the platform's user id).
- Your server: returns a
CustomIdand aSecretfor that subject to the client, over your own authenticated connection. - Client: connects to Five Minutes and sends
ClientUserInfoResponsewithAuthProvider=Custom,AuthCustomId,AuthSecret,AuthCreateandUniqueUserId, both token fields empty. Success is aServerWelcomewith a non-emptyAccessToken.
Five Minutes never calls your backend. It only checks that the Secret matches the one stored when the CustomId was first used.
Derive both values on your server from the verified subject with a key only your server holds, so the same subject always gets the same pair and you never have to store them:
key = a random 32-byte key stored only on your server (never in the client)
subject = "<provider>:<verified subject>" for example "steam:<Steam id>"
CustomId = "c_" + lowercase_hex( HMAC_SHA256(key, "customid|" + subject) ) -> 66 characters
Secret = base64url( HMAC_SHA256(key, "secret|" + subject) ) -> 43 characters, no padding
Rules:
- Never use a third-party token (an OAuth access token, an ID token, a session ticket) as the
Secret. Those change, and the stored secret never does: the user would be locked out at the next sign-in. - The first sign-in with a new
CustomIdandAuthCreatetruecreates the user and stores that secret. Creation is first come, first served, so aCustomIdmust not be guessable: someone who guesses it before the real user signs in owns it. HMAC-derived ids are not guessable. - Use one letter case for
CustomId(the example uses lowercase hex) and never issue two ids that differ only in case. - A wrong secret and an unknown
CustomIdwithAuthCreatefalsegive the same refusal, "Player authentication failed: credential rejected.", so the response never reveals whether an id exists. - There is no secret rotation: the secret stored for a
CustomIdnever changes on its own. Linking the sameCustomIdto the same user again with another secret succeeds but keeps the original secret. Derive the secret deterministically so it never has to change. (The only way to store a new secret is to unlink Custom and link it again; see Troubleshooting.) - The contracts library defaults
AuthCreatetotrue, so a Custom sign-in with an unknownCustomIdcreates a user unless you set it tofalse. Usefalsewhen a sign-in must never create a user, for example on a "sign in to your existing account" screen. AuthUsernameis ignored for Custom. To name the user, useUsername.- Keep the pair on the device in secure storage, or fetch it from your backend whenever the client needs a credential login.
With the npm packages 2.0, pass a function as signIn to fetch the pair from your backend only when the client needs a credential: on the first sign-in, and when it holds no usable token. Add create: false to the returned credential where a sign-in must never create a user. The client refuses an empty customId or secret, a customId longer than 256 characters and a secret longer than 1,024 before it connects:
import { ChatClient } from "@fiveminutes-io/chat-client";
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
signIn: async () => {
const pair = await (await fetch("/api/fiveminutes-pair")).json(); // your backend, after verifying the user
return { provider: "Custom", customId: pair.customId, secret: pair.secret };
},
session: storedSession,
});
User names
Username: 3-64 characters, each a letter, a digit or-. The contracts library refuses to send a message with an invalid non-emptyUsername. The server refuses one too: a login with it ends with aRequestFormatViolationgoodbye (see When a login is refused), and aClientSetUsernameRequestwith it getsSuccessfalse and the rule's text.- A non-empty
Usernamerenames the user on every login, whatever the branch. Send it empty to keep the current name. - A user created with no name gets
Anonymous-followed by 7 digits, for exampleAnonymous-4821937. AuthUsernameapplies to Device sign-ins only (see Signing in with a device). The contracts library does not check it, so follow theUsernamerules yourself.- The npm packages 2.0 check both before connecting: their
usernameandsignIn.usernameoptions must be empty or follow the rules, orconnect()rejects with aClientConfigError. An e-mail address is not a valid name; it can be the unique user id. - The display id is derived from the name the user had when the user was created. A user created by a Custom sign-in, or by a Device sign-in without
AuthUsername, starts asAnonymous-..., so their display id starts withAnonym, and aUsernamein the same message renames them without changing the display id. UseAuthUsernameon the first Device sign-in if the display id should follow the name. - To rename a user during a session, send
ClientSetUsernameRequest(see Five Minute API Contracts). It also gives the user a new display id; read it fromServerSetUsernameResponse.DisplayId.
Keeping existing users
Users who logged in with a self-declared id before you added sign-in keep their account, with its display id, name, chat history, scores and moderation state, through one-shot adoption (a Username, or a Device AuthUsername, in the same message still renames it). The first authenticated sign-in takes over the old id when all four conditions hold:
- It is a credential login (the AuthProvider branch) that creates a new user: the device or
CustomIdis not known andAuthCreateistrue. - Its
UniqueUserIdfield is non-empty and at most 256 characters. - A user with exactly that id already exists in the application, created by a legacy login or by your server's register call.
- No authenticated user has taken over that id before. Adoption happens once per id, ever.
When condition 1 holds but condition 2, 3 or 4 fails, the sign-in creates a new user with a new 64-character hex id instead, and the old user is left unchanged. (When condition 1 fails, a known credential logs in as the user it already belongs to, and an unknown one with AuthCreate false is refused.) Users are never merged afterwards, so get this right in the first release that signs users in.
Rules:
- Adoption happens only on the credential login that creates the user. A login with a session token or refresh token, a login with a credential that already belongs to a user, and a link never adopt.
- Keep sending the old id in
UniqueUserIdon the first authenticated sign-in. The Unity asset used the "Unique user id" field of its connection component (UserIdin code), orSystemInfo.deviceUniqueIdentifierwhen that field was empty; send the same value. The npm packages 1.8 used theirdeviceIdoption. With the npm packages 2.0, pass the old id asuniqueUserIdtogether withsignIn: the client sends it inUniqueUserIduntil the server has named the user. - A banned legacy user cannot be adopted: the sign-in is refused and nothing is saved.
- If the server cannot confirm in time whether the old user exists, it saves nothing and ends the login with an
InternalServerErrorgoodbye that allows a reconnect. Log in again with the same credential and the sameUniqueUserIdafter a backoff; the adoption offer still stands. - If two new sign-ins offer the same id at the same moment, one adopts it and the other becomes a new user.
- Adoption trusts the id the client sends, so a legacy id is only as private as it always was. While Require authenticated sessions is off, a legacy login that declares the adopted id still acts as that user; turn on Require authenticated sessions once your users have moved to signed-in clients.
Sessions and tokens
Lifetimes and storage
| Item | Lifetime | What the client stores |
|---|---|---|
| Access token | 3600 seconds from issue (ServerWelcome.ExpiresIn) |
The token and its expiry: receipt time + ExpiresIn seconds. |
| Refresh token | 30 days from issue; every refresh replaces it and starts a new 30 days | The token, in secure storage. |
| Unique user id | Permanent | ServerWelcome.UniqueUserId from the latest login. |
| Device id | As long as the install keeps it | The id, generated once per install. |
| Custom pair | Permanent (the secret never changes) | The pair in secure storage, or nothing if you fetch it from your backend when needed. |
Store tokens and the unique user id per Application ID: a token works only for the application that issued it. Keep tokens in the platform's secure storage where there is one, never write them to logs, and treat the refresh token like a password: whoever holds it can sign in as the user for up to 30 days.
The npm packages 2.0 keep the unique user id, the token pair and the expiry for you. Store the session that onSessionChanged gives you (or getSession() returns) and pass it back as session when you create the client; null means forget it:
client.onSessionChanged((session) =>
session ? localStorage.setItem("fm-session", JSON.stringify(session)) : localStorage.removeItem("fm-session"));
The server accepts an access token for up to 1 minute past its expiry, to allow for clock differences. Do not rely on this: send an access token only while your own clock says it is valid, and use the refresh token after that.
Reconnecting
Choose the input for each new connection from what you have stored:
- If you hold an access token and the current time is before its expiry minus a safety margin (60 seconds is a good margin), send it as
SessionTokenand leaveRefreshTokenempty. - Otherwise, if you hold a refresh token, send it as
RefreshTokenand leaveSessionTokenempty. - Otherwise, sign in with the credential (Device or Custom).
The npm packages 2.0 choose the input this way on every connection, reconnects included, with a margin of 60 seconds.
Rules:
- A login with
SessionTokenreturns no new tokens (ExpiresInis 0) and does not extend the access token. Keep the expiry you computed when the token was issued. - A valid
SessionTokensent together with aRefreshTokenleaves the refresh token unused. An expired one refuses the login, even though the refresh token would have worked, so never send an access token you believe has expired. - Compute the expiry from
ExpiresInand your own clock at receipt. Do not parse the access token.
Refresh tokens rotate
- A refresh token works once. A successful RefreshToken login returns a new pair, and the old refresh token stops working at that moment.
- Of two logins that use the same refresh token at the same time, one gets the new pair and the other is refused with
UserAuthenticationFailed. Keep one pair per install and never share it between processes or devices. - A refresh checks bans again: a banned user's refresh is refused. When the server cannot make that check because a service it needs does not answer, it exchanges nothing: the login ends with
InternalServerErrorandAllowAutoReconnecttrue, and the same refresh token works on the next login. - If the connection drops after the server has rotated the token but before
ServerWelcomereaches the client, the client still holds the old refresh token, which no longer works. Its next RefreshToken login is refused: drop the stored pair and sign in with the credential. A client must therefore always be able to fall back to its Device id or Custom pair. - A session that goes 30 days without a refresh expires. The next login needs the credential.
When the access token expires while connected
- The connection stays up, and other messages keep working as the same user. The server checks the access token only when a connection logs in.
- Link and unlink need a current access token on the connection. Once the access token from the login has expired they answer
NotAuthenticatedwith "No authenticated player session is bound to this connection." After a SessionToken login, that is the presented token's own expiry, which may be only minutes away. - There is no message that refreshes tokens inside a connection. To get a fresh pair, reconnect with the refresh token, then send the link or unlink request. With the npm packages, call
disconnect()and thenconnect(): once the access token has expired, the client logs in with the refresh token.
Several devices and regions
- Every credential login starts a new, independent session with its own token pair. Signing in on a second device does not affect the first one. Signing in with the credential again on the same device also starts a new session and leaves the old one to expire, so reconnect with tokens whenever you have them.
- Sessions work in every region. A token is accepted by every region of the same application, and rotation and logout take effect everywhere.
Revocation
A session is revoked when the user logs out, or when a member of your organization signs the user out on the dashboard.
- The session's refresh token stops working at once, in every region.
- The session's access token is refused on new logins once the revocation has spread: usually within seconds, allow up to 2 minutes. If the revocation cannot be distributed, the access token is refused only once it has expired: at most 3600 seconds after it was issued, plus the 1-minute clock allowance (see Lifetimes and storage).
- Connections that are already logged in are not disconnected. On such a connection, link and unlink answer
NotAuthenticatedwith "The player session bound to this connection is no longer valid." - Revocation is not a ban. The user can sign in again at once with any of their sign-in methods. To keep a user out, ban them (see Your server and user identity).
Signing out (ClientLogoutRequest)
The client ends its session with ClientLogoutRequest, sent after ServerWelcome. The server sends ServerAck as soon as it has the request, then answers with ServerLogoutResponse. Field tables: End the session (ClientLogoutRequest).
- Client: sends
ClientLogoutRequestwithRefreshTokenset to its stored refresh token. - Five Minutes: revokes the session that the refresh token belongs to and answers
ServerLogoutResponse.Success=truemeans the session has ended. - Client: deletes its stored access token and refresh token, then closes the connection itself.
Rules:
- The whole session is revoked: its refresh token and every access token issued in it (see Revocation). The user's other sessions, for example on other devices, are not affected. No message signs a user out everywhere; the dashboard can.
Successistruealso when the session was already revoked or has expired, so repeating a logout is harmless.- The server does not close the connection and sends no
ServerGoodbye. From the response on, the connection holds no session (link and unlink answerNotAuthenticated), but it still acts as the same user for other messages until it closes. Disconnect right after the response. - Delete the stored tokens even when
Successisfalse. Keep the device id, and the Custom pair if you store one, only if the user should be able to sign in again without your backend.
| Success | FailureReason (exact text) | Meaning | Retry? |
|---|---|---|---|
true |
empty | The session has ended, now or earlier. | No need. |
false |
The refresh token is not valid or has already been revoked. |
The refresh token is unknown, is an older token of a session that has since been refreshed, or belongs to another application. A current token of a revoked session gets Success = true instead. |
No. If you hold a newer refresh token of the same session, send that one. |
false |
Logout could not be completed: the authentication service is unavailable. |
Temporary failure; the session may or may not have ended. | Yes, after a short wait, with a new MessageId. A repeat is harmless. |
false |
ApplicationId and RefreshToken are required. |
The request carried an empty refresh token. The contracts library refuses to send one. | No. Send the stored refresh token. |
false |
Logout failed (<status>). |
Unexpected answer. | Once, later. Otherwise drop the tokens; the session expires 30 days after its last refresh. |
With the npm packages 2.0, logout() sends ClientLogoutRequest with the stored refresh token and acts on the answer: on Success = true, and on The refresh token is not valid or has already been revoked., it forgets the tokens (onSessionChanged gets null) and disconnects. On Logout could not be completed: the authentication service is unavailable., and when no answer comes, it rejects and keeps the tokens: call it again. On any other refusal it forgets the tokens and disconnects, then rejects with the RequestFailedError. Without a refresh token, after a legacy login, it only forgets the user and disconnects.
try {
await client.logout();
} catch (error) {
// The session may not have ended: offer to try again
}
Linking and unlinking sign-in methods
A signed-in user can add sign-in methods and remove them. The usual case is a guest who started with Device and later creates an account: link the account as Custom, and the user can then sign in with either, on any device.
Link and unlink need a session on the connection: the connection logged in with a SessionToken, a RefreshToken or a credential, has not logged out, and the access token from that login has not expired. A connection that logged in the legacy way always gets NotAuthenticated.
Link a sign-in method (ClientLinkIdentityRequest)
Adds a Device or Custom sign-in method to the user on this connection. Reply: ServerAck, then ServerLinkIdentityResponse. Field tables: Add a sign-in method (ClientLinkIdentityRequest).
Turning a guest into an account holder:
- Client: is logged in on a Device sign-in (it holds a session).
- Client and your server: the user signs in to your system, and your server returns a
CustomIdandSecret(see Signing in through your backend). - Client: sends
ClientLinkIdentityRequestwithProvider=Custom,CustomIdandSecret. - Five Minutes: answers
ServerLinkIdentityResponsewithSuccess=trueand an emptyErrorCode. From now on a Custom sign-in with that pair, on any device, logs in as this user with the sameUniqueUserId. - If
ErrorCodeisIdentityAlreadyLinked, that account already belongs to another user, for example one created by signing in with Custom on another device first. Users are never merged. Let the user choose: stay on the guest user, or sign in with Custom and continue as the account's user (the guest user stays reachable only through its device id).
Unlink a sign-in method (ClientUnlinkIdentityRequest)
Removes every sign-in method of one provider from the user on this connection. Reply: ServerAck, then ServerUnlinkIdentityResponse. Field tables: Remove a sign-in method (ClientUnlinkIdentityRequest).
Rules for link and unlink:
- A user can hold several Device and several Custom sign-in methods.
- Linking a credential that belongs to another user fails with
IdentityAlreadyLinkedand changes nothing. Users are never merged. - Linking a credential that already belongs to this user succeeds and changes nothing. For Custom, the stored secret stays the original one.
- Unlink removes all of the user's sign-in methods of that provider: every linked device, or every Custom id.
- Unlink fails with
LastIdentitywhen it would leave the user with no sign-in method, and withNotLinkedwhen the user has none of that provider. - Neither link nor unlink ends sessions. A device whose Device method was unlinked stays logged in for as long as it holds a working refresh token; its next Device sign-in with
AuthCreatetruecreates a new user. - Send
Providerexactly asDeviceorCustom. The contracts library refuses any other value. The server compares link and unlink provider names without regard to letter case, but sign-in comparesAuthProviderexactly, so always use the exact spelling. - Linking never adopts a legacy id.
- No message lists a user's sign-in methods. Track what the user has linked in your app, or look at the user's page on the dashboard.
| ErrorCode | FailureReason (exact text) | Returned by | Meaning | Retry? |
|---|---|---|---|---|
NotAuthenticated |
No authenticated player session is bound to this connection. |
link, unlink | The connection logged in the legacy way, logged out, or its access token has expired. | Reconnect with the refresh token (or the credential), then send the request again. |
NotAuthenticated |
The player session bound to this connection is no longer valid. |
link, unlink | The session was revoked (a logout with this session's refresh token, or the dashboard) or its token is no longer accepted. | Reconnect. The refresh token is probably revoked as well; then sign in with the credential. |
IdentityAlreadyLinked |
The credential is already linked to a different player. |
link | The device or CustomId belongs to another user. |
No. |
LastIdentity |
The player's last identity cannot be unlinked. |
unlink | Unlinking would leave the user without a sign-in method. | No. Link another method first. |
UnknownProvider |
The provider is not supported. |
link, unlink | Provider is not Device or Custom in any letter case. An empty Provider is not recognised as a provider at all and answers ServiceUnavailable instead. |
No. Fix the request. |
NotLinked |
The provider is not linked to this player. |
unlink | The user has no sign-in method of that provider. | No. |
InvalidRequest |
the server's text, for example The provider's credential fields are required. |
link, unlink | The server rejected the request. For link, the text above means DeviceId, or CustomId and Secret, are missing. |
No. Fix the request. |
ServiceUnavailable |
The identity could not be linked: the authentication service is unavailable., The identity could not be unlinked: the authentication service is unavailable. or The authentication service answered <status>. |
link, unlink | Temporary failure. | Yes, with bounded backoff and a new MessageId each time. If the first attempt did go through, a repeated link answers success and a repeated unlink answers NotLinked. |
On success, Success is true and ErrorCode and FailureReason are empty.
With the npm packages 2.0, send link and unlink with request. It resolves on Success = true and rejects with a RequestFailedError otherwise; its response holds the ErrorCode. isSignedIn() tells whether the connection holds a session. The client checks these requests before sending them: one with another provider name, or without its credential fields, rejects at once with a MessageValidationError and is not sent (see Checks before sending).
import { RequestFailedError } from "@fiveminutes-io/chat-client";
import type { ServerLinkIdentityResponse } from "@fiveminutes-io/contracts";
try {
await client.request("ClientLinkIdentityRequest", { Provider: "Custom", CustomId: customId, Secret: secret });
} catch (error) {
if (error instanceof RequestFailedError) {
const code = (error.response as ServerLinkIdentityResponse).ErrorCode; // for example "IdentityAlreadyLinked"
}
}
When a login is refused
A refused login ends with a ServerGoodbye after the client has sent ClientUserInfoResponse. AllowAutoReconnect is always false for these refusals, and the server closes the connection shortly after the goodbye, so every further attempt needs a new connection. A server error during the login, the sign-in included, and a sign-in the server could not decide yet are no refusal: the server decided nothing about the credential, and the login ends with ReasonType InternalServerError and AllowAutoReconnect true (the last row of the table); log in again with the same input and the same UniqueUserId after a backoff. An outage is no refusal either: while a service the sign-in needs does not answer, the server saves nothing and exchanges no refresh token, and ends the login the same way, so keep the stored tokens and the credential. The same credential signs in the same user, also while the first attempt is still running on the server. A RefreshToken that login presented may already have been exchanged when the server failed after exchanging it, so the next login can refuse it; then go on with the credential, as for any refused refresh token. The full catalogue of goodbyes is in the Five Minute wire protocol.
| ReasonType | Reason (exact text) | Meaning | Retry? |
|---|---|---|---|
UserAuthenticationFailed |
Your session could not be authenticated. Please sign in again. |
The SessionToken was refused (expired, revoked or issued for another application); or the RefreshToken was refused (already used, revoked, expired, issued for another application, or the user is banned); or the login presented nothing while Require authenticated sessions is on. |
After a SessionToken: reconnect once with the refresh token. After a RefreshToken: drop the stored pair and reconnect once with the credential. After a login that presented nothing: sign in with a credential (a client below protocol 2.0 cannot). |
UserAuthenticationFailed |
Player authentication failed: credential rejected. |
A wrong Custom secret; an unknown device or CustomId with AuthCreate false; or a banned user. |
No automatic retry. Let the user try again. |
UserAuthenticationFailed |
Player authentication failed: the user name is longer than 64 characters. |
A Device sign-in whose AuthUsername is longer than 64 characters. |
No. Fix the client. |
UserAuthenticationFailed |
Player authentication failed: '<AuthProvider>' is not a supported provider or its credential fields are missing. |
AuthProvider is not exactly Device or Custom, or the fields it needs are empty. |
No. Fix the client. |
UserAuthenticationFailed |
Player authentication failed: the issued session is not valid for this application. |
The new session was refused for this application. Rare. | No automatic retry. Let the user try again later. |
UserAuthenticationFailed |
any other text starting with Player authentication failed:, for example Player authentication failed: the credential could not be verified. |
The credential was refused for a reason the table does not list. | No automatic retry. |
InvalidCredentials |
Credentials could not be validated. |
At this step: the user is banned (on a SessionToken or legacy login). The same goodbye before ServerUserInfoRequest means the Application ID or application credential was refused instead. |
No. |
RequestFormatViolation |
The text of the rule the message broke, for example Invalid username 'a b'. Username must conform to regular expression: ^([\p{L}\-\d]){3,64}$ |
ClientUserInfoResponse broke one of its rules, such as a Username that is not 3-64 letters, digits or -, or a UniqueUserId longer than 256 characters. The contracts library checks these before sending. |
No. Fix the client. |
InternalServerError |
Internal Server Error: The user could not be signed in. Please contact support if this persists., or another text that starts with Internal Server Error: |
No refusal: the sign-in or the rest of the login failed on the server's side, for example because a service it needs did not answer or a token could not be checked; or the server could not decide the sign-in yet, typically whether an old user can be adopted or whether the user may sign in, and saved nothing. Nothing was decided about the credential, and a refresh token the server could not check was not exchanged. AllowAutoReconnect is true. |
Yes. Log in again with the same input and the same UniqueUserId after a bounded backoff; an adoption offer still stands. If the next login refuses the RefreshToken, go on with the credential. |
A banned user is refused on every branch, with a different goodbye depending on what the client presented:
| Login presented | Goodbye a banned user gets |
|---|---|
SessionToken |
InvalidCredentials, Credentials could not be validated. |
| Nothing (legacy) | InvalidCredentials, Credentials could not be validated. |
RefreshToken |
UserAuthenticationFailed, Your session could not be authenticated. Please sign in again. |
AuthProvider |
UserAuthenticationFailed, Player authentication failed: credential rejected. |
Recommended client behaviour
Present one input per login, fall back at most twice (access token, then refresh token, then credential), repeat an input only after a goodbye that allows a reconnect and only a bounded number of times, and always adopt the unique user id the server returns:
stored : per Application ID { playerId, accessToken, accessTokenExpiresAt, refreshToken }
credential : Device(deviceId) or Custom(customId, secret)
legacyId : the id your app used before it signed users in (empty for new installs)
choose_input():
if stored.accessToken and now < stored.accessTokenExpiresAt - 60 seconds: return SESSION
if stored.refreshToken: return REFRESH
return CREDENTIAL
sign_in():
input = choose_input()
network_attempts = 0
loop:
open a new connection and complete the application step
(with a new ephemeral app key if you use them)
send ClientUserInfoResponse with a new MessageId and exactly one input:
SESSION : SessionToken = stored.accessToken
REFRESH : RefreshToken = stored.refreshToken
CREDENTIAL : AuthProvider and its fields, AuthCreate
UniqueUserId = stored.playerId, else legacyId, else a new random GUID
Username = a new name, or empty to keep the current one
on ServerWelcome w:
stored.playerId = w.UniqueUserId # always adopt
if w.AccessToken is not empty: # replace the pair only then
stored.accessToken = w.AccessToken
stored.refreshToken = w.RefreshToken
stored.accessTokenExpiresAt = now + w.ExpiresIn seconds
return SIGNED_IN
on ServerGoodbye g with g.ReasonType == UserAuthenticationFailed:
if input == SESSION:
forget stored.accessToken
input = REFRESH if stored.refreshToken else CREDENTIAL
continue
if input == REFRESH:
forget stored.accessToken and stored.refreshToken
input = CREDENTIAL
continue
return FAILED(g.Reason) # stop; offer the user a retry button
on ServerGoodbye g with g.AllowAutoReconnect: # InternalServerError: no refusal, nothing decided
if network_attempts == 5: return FAILED(g.Reason)
network_attempts = network_attempts + 1
wait min(2 ^ network_attempts, 30) seconds plus random jitter
input = choose_input() # the same input and UniqueUserId; a refused refresh falls through later
continue
on ServerGoodbye g with any other ReasonType:
return FAILED(g.Reason) # see the wire protocol guide
on connection lost without ServerGoodbye:
if network_attempts == 5: return FAILED("offline")
network_attempts = network_attempts + 1
wait min(2 ^ network_attempts, 30) seconds plus random jitter
input = choose_input() # a lost refresh falls through to CREDENTIAL later
continue
Why each rule exists:
- A new connection, new MessageIds and a new ephemeral app key per attempt: the server closes the connection after a refusal, ignores a message whose MessageId it has already seen, and accepts each ephemeral app key once.
- Replace the stored pair only when
AccessTokenis non-empty: SessionToken and legacy logins return empty token fields, and storing them would throw away a working refresh token. - Fall back to the credential after a refused refresh: this also covers a refresh whose
ServerWelcomewas lost (see Refresh tokens rotate). - Repeat the same input after
InternalServerError, never after a refusal: the server decided nothing, and the same credential signs in the same user (with the sameUniqueUserId, an adoption offer still stands), while repeating a refusal only repeats it. - Keep the stored tokens and the credential after
InternalServerError: it is what the server answers during an outage, which exchanges no refresh token and refuses nothing. Drop a stored refresh token only after aUserAuthenticationFailedgoodbye to the login that presented it.
The npm packages 2.0 behave this way on every connection, reconnects included (see Signing users in): one input per login, one fallback each after a refused access token and a refused refresh token, a new connection, new MessageIds and a new ephemeral app key from your applicationSecret function for every attempt, and the unique user id the server returns. A lost connection during the first connect() fails it instead of being retried; after a login, the client reconnects with its own backoff, up to 10 attempts. A goodbye that allows a reconnect, such as the InternalServerError of a server error during the login or of a sign-in the server could not decide yet, is retried with that backoff too, with the same input, also during the first connect(). Any other refusal stops the client: connect() rejects with a ConnectionClosedError that holds the goodbye, and onDisconnected reports it.
Requiring authenticated sessions
When Require authenticated sessions is on for an application, every login must present a SessionToken, a RefreshToken or an AuthProvider credential. A login that presents none of the three is refused with UserAuthenticationFailed and the Reason Your session could not be authenticated. Please sign in again. A presented token or credential is checked in exactly the same way whether the setting is on or off, and an invalid one is refused either way.
The setting also changes how users address each other. With it on, a user names another user by DisplayId, by one of their personal groups, or by answering a whisper, never by that user's unique user id: in ClientWhisperMessage.Recipient, the Subject of ClientSubscribeEventRequest and ClientUnsubscribeEventRequest, the OtherUserIds of ClientAddUsersToPersonalGroupRequest and ClientRemoveUsersFromPersonalGroupRequest, and the Recipient of the P2P requests, another user's unique user id is answered exactly like a name that belongs to nobody. A user's own unique user id still names that user, and calls from your server through the Server API are not affected. With the setting off, another user's unique user id is still accepted wherever a name is no user's DisplayId, because older clients rely on it; it is discouraged, so name users by DisplayId.
signIn or a stored session; without them it logs in the legacy way and is refused too.| Plan | Default | Can you switch it? |
|---|---|---|
| Free | Off | No |
| Subscription | Off | Yes, per application |
| Custom | Off | As agreed for your plan; by default no |
Change it on the dashboard: open the application, go to Features and use the "Require authenticated sessions" switch. The switch is shown only on plans that let you change the setting.
Behaviour:
- A change applies to new logins and to users addressing each other within seconds, in every region; at most 10 minutes if a region misses the change notice.
- Users who are already connected stay connected: the login rule reaches them on their next login. The addressing rule applies to them from the change on.
- There is no warn-only or report-only mode. On means refuse.
- Switching the setting off accepts legacy logins again from the next login on.
- If the server cannot read the application's setting during an incident, it treats the setting as off for that login or request.
Rollout checklist:
- Ship a protocol 2.0 client that signs in on every login (Device, Custom or both), follows the recommended client behaviour and names other users only by DisplayId. With the npm packages 2.0, that is a client with
signInthat stores and resumes its session. - Make that client send each user's old id as
UniqueUserIdon their first authenticated sign-in, so existing users are adopted (see Keeping existing users). - Make your server use the right unique user id for every server call: the id it chose for the account, or the id the client got in
ServerWelcome.UniqueUserId(see Knowing which user a client is). - Test with the setting on in a second application, for example your staging application: first sign-in, reconnect within the hour, reconnect after the hour, logout, and a banned user.
- Move users off older builds: force an update of every client below protocol 2.0, including builds with the Unity asset or the npm packages 1.8, and of every client that does not sign in, and use your own analytics to confirm that almost all active users run the new build.
- Turn the setting on for the production application and watch your client error reports for
UserAuthenticationFailed. - To back out, turn it off again.
Your server and user identity
Your server never signs users in, never sees their tokens and never needs them. It works with unique user ids.
- The unique user id is what your server uses. Your server sends the unique user id as
uniqueUserIdin REST calls and asownerIdfor the user's leaderboard record. For a user on an older client it is the self-declared id, for an adopted user it is the old id, and for everyone else who signs in it is a new 64-character hex id, unless your server chose the id in advance (see below). - Five Minutes cannot tell your server who a client is. No REST endpoint verifies an access token, looks up the user behind a
CustomId, lists or revokes sessions, or lists a user's sign-in methods, and there is no public JWKS. Do not send access or refresh tokens to your server. A unique user id that a client reports to your server is only a claim: a modified client can report any id, including someone else's. - Unique user ids stay between your server and the user's own client. Never send a user's unique user id to another user's client, and never write it into anything other users receive: a user's name or metadata, chat or whisper metadata, leaderboard metadata or a channel name. Users know each other by display id.
- Bans keep a user out; revocation does not. To keep a user out, ban them through the Server API or the dashboard; a banned user's logins are refused (see When a login is refused). Signing a user out of their devices only ends sessions.
Knowing which user a client is
When your server acts for a user (writes their scores, reports their events, reads their configuration), it must know the unique user id of the account it authenticated. There are two ways; only the first is safe against a modified client.
Your server chooses the unique user id (recommended). Your server decides each account's unique user id before the account's first sign-in and makes Five Minutes adopt it. It then never has to trust an id reported by a client. This works with the Custom method:
- Your server derives the account's unique user id from its verified subject with a key only your server holds, for example
"p_" + lowercase_hex( HMAC_SHA256(key, "playerid|" + subject) )(66 characters; keep it to 128 characters or fewer so that it can own leaderboard records). Nobody else can compute it. - Before the account's first sign-in, your server calls
POST /api/v1/{applicationId}/user/registerwith thatuniqueUserId(a key withApplication.Users.Registeris enough; see Pre-creating users). The call is harmless to repeat. - Your server returns the
CustomId, theSecretand the unique user id to the client over your own authenticated connection. - The client signs in with
AuthProvider=Custom, the pair,AuthCreate=true, and the unique user id inUniqueUserId. The first sign-in creates the user and adopts the id (see Keeping existing users); every later sign-in with the pair logs in as that user. ServerWelcome.UniqueUserIdequals the id your server chose. If it does not, the adoption failed (for example because the pair had already signed in once without the id): the client should tell your server, and your server should treat the account as needing attention rather than accept the other id.
From then on your server uses the id it derived for every call about that account. Two conditions keep this safe: the id must stay secret, above all until the first sign-in has adopted it (derive it with your own key and hand it only to the account's own client), and Require authenticated sessions must be on (see Requiring authenticated sessions), because while it is off a legacy login that declares an adopted id acts as that user. Give guests a Custom pair too: let your server create an anonymous account for a new install and issue its pair, instead of using the Device method, so that every user your server deals with has an id it chose.
The client reports its unique user id (trust on first report). After each login the client reports ServerWelcome.UniqueUserId to your server over your own authenticated connection. Your server records the first id reported for each account, refuses a different id for the same account later, and refuses an id that is already recorded for another account. This is simple and works with the Device method, but a modified client can report a unique user id that no account has recorded yet, and your server cannot check it.
Pre-creating users
POST /api/v1/{applicationId}/user/register (scope Application.Users.Register, or Application.Admin.FullAccess, "Full access") creates a user with the uniqueUserId you choose, or returns the existing user with that id. A user created this way is a legacy user: the first credential sign-in that sends the same id in UniqueUserId and creates a new user adopts it (see Keeping existing users). This lets your server decide a user's unique user id before the user first signs in. Use ids nobody can guess, because adoption goes to the first sign-in that offers the id. A key with only Application.Users.Register can register Standard users and nothing else, so your account server does not need a Full access key; registering any other userType needs Full access and otherwise gets 403.
POST /api/v1/<application id>/user/register HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"uniqueUserId":"user-42","username":"Alice"}
The full request and response reference is in the Five Minute Server API.
Sign-in methods and devices on the dashboard
A user's page on the dashboard has a "Sign-in & devices" section:
- Sign-in methods: the provider (Device or Custom id), the
CustomIdas you issued it or a keyed hash for a device (the raw device id is never stored), when it was linked and when it was last used. - Devices: one row per session, with the time of its last sign-in or refresh, its expiry or sign-out time, and its status (Active, Signed out, Expired).
- Sign out ends one session and Sign out of all devices ends every active session of the user. Both work like a logout (see Revocation) and are available to the Owner, organization admin and application admin roles; every role that can open the user's page can view the section.
- Signing a user out is not a ban. The user can sign in again at once.
- A user who has only ever logged in the legacy way has no sign-in methods and no devices.
Ephemeral app keys and user identity
An ephemeral app key lets the client pass application admission without shipping the static application secret: your server mints a short-lived ecs_... key (scope Application.ClientCredentials.Mint, "Mint ephemeral app keys") and hands it to the client for one connection. It admits the application only and carries no user identity, and each key is accepted once (single use across all regions, best effort), so every reconnect needs a new key; details in the Five Minute Server API.
Calling the Server API
Your server calls https://signalr.fiveminutes.cloud/api/v1/... with Authorization: Bearer <server token>. Get the token with POST /api/v1/api-clients/authenticate and the body {"applicationId":"<application id>","clientSecret":"<API key secret>"}; it is valid for expiresInSeconds (3600) seconds and there is no refresh token. A missing, invalid or expired token gets 401 with an empty body; a token without the needed scope gets 403 with an empty body. Error bodies, rate limits and idempotency rules: Five Minute Server API.
Scopes used in this document:
Application.Users.Register:POST /api/v1/{applicationId}/user/registerfor Standard users.Application.Admin.FullAccess("Full access"): every route, including the ban endpoints.Application.ClientCredentials.Mint("Mint ephemeral app keys"): minting ephemeral app keys.
Reference: Five Minute Server API.
Message reference
Client prerequisites
These messages need a completed login: send them only after ServerWelcome. Anything sent earlier makes the server close the connection. Give every message a new MessageId. Each request gets a ServerAck as soon as the server has received it, when IsAckRequested is true, then one typed response whose ResponseToMessageId is your MessageId. The ServerAck says only that the request arrived; the typed response says whether it succeeded. Failures arrive in the typed response as Success=false with a FailureReason. The server never sends these responses to a connection below protocol 2.0.
This applies to ClientLogoutRequest, ClientLinkIdentityRequest and ClientUnlinkIdentityRequest. ClientUserInfoResponse is the login step itself: send it once per connection, in reply to ServerUserInfoRequest (see the login handshake). Conventions: Message conventions. The tables below give each message's fields and what they mean; the client library sends and reads the messages.
Present an identity at login (ClientUserInfoResponse)
Client -> server. Sent once per connection in reply to ServerUserInfoRequest. Reply: ServerWelcome (then ServerAck when IsAckRequested is true), or a ServerGoodbye (see When a login is refused).
| Field | Type | Rules | Meaning |
|---|---|---|---|
UniqueUserId |
text | Required, 1-256 characters. | The stored unique user id, the old legacy id on a first sign-in, or any new value. Trusted only on a legacy login. |
Username |
text | Empty, or 3-64 letters, digits or -. |
Renames the user on this login when non-empty. |
AssetVersion |
text | Optional. | Your client's version, for troubleshooting. |
RuntimePlatform |
text | Optional. | Platform, for troubleshooting. |
RuntimeVersion |
text | Optional. | Runtime version, for troubleshooting. |
RuntimeMode |
text | Optional. | Runtime mode, for troubleshooting. |
SystemLanguage |
text | Optional. | System language, for troubleshooting. |
SessionToken |
text | Empty, or an unexpired access token of this application. | Branch 1: log in on an existing session. |
RefreshToken |
text | Used only when SessionToken is empty. |
Branch 2: rotate the session's tokens and log in. |
AuthProvider |
text | Empty, Device or Custom, exact spelling. Used only when both token fields are empty. |
Branch 3: sign in with a credential. |
AuthDeviceId |
text | Required for Device, at most 256 characters. |
The device credential. |
AuthCustomId |
text | Required for Custom, at most 256 characters. |
The Custom id your backend issued. |
AuthSecret |
text | Required for Custom, at most 1024 characters. |
The Custom secret your backend issued. |
AuthCreate |
true/false | The contracts library defaults to true. |
Create the user when the credential is unknown. |
AuthUsername |
text | Device only; follow the Username rules. |
Names or renames the user on a Device sign-in. |
MessageId |
GUID | New for every message, also for every login attempt. | Duplicate detection. |
IsAckRequested |
true/false | Ask for a ServerAck. |
using FiveMinutes.Model.Messages.Common;
using FiveMinutes.Model.Messages.Client;
// First sign-in on this install: exactly one input, a Device credential.
var signIn = new ClientUserInfoResponse
{
UniqueUserId = storedPlayerId ?? legacyId ?? Guid.NewGuid().ToString(),
Username = null, // keep the current name
AuthProvider = AuthProviderNames.Device, // "Device"
AuthDeviceId = deviceId,
AuthCreate = true,
AuthUsername = "Alice"
};
// Sign-in through your backend with the pair it issued.
var customSignIn = new ClientUserInfoResponse
{
UniqueUserId = storedPlayerId ?? legacyId ?? Guid.NewGuid().ToString(),
AuthProvider = AuthProviderNames.Custom, // "Custom"
AuthCustomId = customId,
AuthSecret = secret,
AuthCreate = true
};
// Reconnect while the access token is valid.
var resume = new ClientUserInfoResponse { UniqueUserId = storedPlayerId, SessionToken = storedAccessToken };
// Reconnect after the access token expired.
var refresh = new ClientUserInfoResponse { UniqueUserId = storedPlayerId, RefreshToken = storedRefreshToken };
The contracts library checks the message before it sends it and throws ArgumentException instead of sending:
| Exception message | Cause | Client action |
|---|---|---|
UniqueUserId must be set. |
UniqueUserId is empty. |
Always send the stored id, the legacy id or a new GUID. |
Unique user id must not exceed 256 characters |
UniqueUserId is too long. |
Shorten it. |
Invalid username '<name>'. ... |
Username breaks the name rules. |
Send a valid name or none. |
AuthDeviceId must be set when AuthProvider is 'Device'. |
Missing device id. | Set AuthDeviceId. |
AuthCustomId and AuthSecret must be set when AuthProvider is 'Custom'. |
Missing Custom field. | Set both. |
Unknown AuthProvider '<value>'. Supported values: 'Device', 'Custom'. |
Wrong provider name. | Use AuthProviderNames.Device or AuthProviderNames.Custom. |
The npm packages 2.0 build this message themselves on every login, from their uniqueUserId, username, signIn and session options and the stored tokens, and check the same rules before connecting, and the message itself before sending it: connect() rejects with a ClientConfigError whose field names the option, such as signIn.customId (see Checks before connecting).
Receive the resolved identity (ServerWelcome)
Server -> client. Answers ClientUserInfoResponse when the login succeeds. It is not a typed response: it has no ResponseToMessageId, Success or FailureReason.
| Field | Type | Rules | Meaning |
|---|---|---|---|
AvailableChannels |
list of ChannelInfo |
Deprecated. See Channel information. | Channels the user can see. |
DisplayId |
text | Always set. | The user's display id. |
AccessToken |
text | Non-empty only on RefreshToken and AuthProvider logins. | The new access token. |
RefreshToken |
text | Non-empty only on RefreshToken and AuthProvider logins. | The new refresh token; the previous one of this session no longer works. |
ExpiresIn |
whole number | 3600 when tokens were issued, otherwise 0. | Lifetime of AccessToken in seconds from receipt. |
UniqueUserId |
text | Always set, on every branch. | The unique user id. Always adopt it. |
using FiveMinutes.Model.Messages.Server;
void OnWelcome( ServerWelcome welcome )
{
store.PlayerId = welcome.UniqueUserId; // always
if( !string.IsNullOrEmpty( welcome.AccessToken ) ) // new pair only on refresh and credential logins
{
store.AccessToken = welcome.AccessToken;
store.RefreshToken = welcome.RefreshToken;
store.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds( welcome.ExpiresIn );
}
store.DisplayId = welcome.DisplayId;
}
End the session (ClientLogoutRequest)
Client -> server, only after ServerWelcome. Reply: ServerAck, then ServerLogoutResponse. Behaviour: Signing out.
| Field | Type | Rules | Meaning |
|---|---|---|---|
RefreshToken |
text | Required; the contracts library refuses an empty one. | The refresh token of the session to end. |
MessageId |
GUID | New for every message. | Correlates the response. |
IsAckRequested |
true/false | Ask for a ServerAck. |
ServerLogoutResponse fields
| Field | Type | Rules | Meaning |
|---|---|---|---|
ResponseToMessageId |
GUID | The MessageId of your request. |
|
Success |
true/false | true when the session has ended, now or earlier. |
|
FailureReason |
text | Empty on success. | See the table in Signing out. |
var logout = new ClientLogoutRequest { RefreshToken = store.RefreshToken };
// send it, then on ServerLogoutResponse r with r.ResponseToMessageId == logout.MessageId:
// delete the stored tokens whatever r.Success says, then disconnect
The contracts library throws ArgumentException with RefreshToken must be set. instead of sending when RefreshToken is empty or only spaces.
Add a sign-in method (ClientLinkIdentityRequest)
Client -> server, only after ServerWelcome, on a connection that holds a session. Reply: ServerAck, then ServerLinkIdentityResponse. Behaviour and error codes: Link a sign-in method.
| Field | Type | Rules | Meaning |
|---|---|---|---|
Provider |
text | Device or Custom, exact spelling. |
The provider of the credential to add. |
DeviceId |
text | Required for Device, at most 256 characters. |
The device credential. |
CustomId |
text | Required for Custom, at most 256 characters. |
The Custom id your backend issued. |
Secret |
text | Required for Custom, at most 1024 characters. |
The Custom secret your backend issued. |
MessageId |
GUID | New for every message. | Correlates the response. |
IsAckRequested |
true/false | Ask for a ServerAck. |
ServerLinkIdentityResponse fields
| Field | Type | Rules | Meaning |
|---|---|---|---|
ErrorCode |
text | Empty on success. | Machine-readable failure code. |
ResponseToMessageId |
GUID | The MessageId of your request. |
|
Success |
true/false | true when the credential is linked to this user. |
|
FailureReason |
text | Empty on success. | Description of the failure. |
var link = new ClientLinkIdentityRequest
{
Provider = AuthProviderNames.Custom,
CustomId = customId,
Secret = secret
};
// on ServerLinkIdentityResponse r with r.ResponseToMessageId == link.MessageId:
// r.Success, or switch on r.ErrorCode ("NotAuthenticated", "IdentityAlreadyLinked", ...)
The contracts library throws ArgumentException, and the npm packages 2.0 reject with a MessageValidationError, instead of sending when a field is missing or the provider name is wrong: DeviceId must be set when Provider is 'Device'., CustomId and Secret must be set when Provider is 'Custom'. or Unknown Provider '<value>'. Supported values: 'Device', 'Custom'.
Remove a sign-in method (ClientUnlinkIdentityRequest)
Client -> server, only after ServerWelcome, on a connection that holds a session. Reply: ServerAck, then ServerUnlinkIdentityResponse. Behaviour and error codes: Unlink a sign-in method.
| Field | Type | Rules | Meaning |
|---|---|---|---|
Provider |
text | Device or Custom, exact spelling. |
Every sign-in method of this provider is removed. |
MessageId |
GUID | New for every message. | Correlates the response. |
IsAckRequested |
true/false | Ask for a ServerAck. |
ServerUnlinkIdentityResponse fields
| Field | Type | Rules | Meaning |
|---|---|---|---|
ErrorCode |
text | Empty on success. | Machine-readable failure code. |
ResponseToMessageId |
GUID | The MessageId of your request. |
|
Success |
true/false | true when the provider's sign-in methods were removed. |
|
FailureReason |
text | Empty on success. | Description of the failure. |
var unlink = new ClientUnlinkIdentityRequest { Provider = AuthProviderNames.Device };
// on ServerUnlinkIdentityResponse r: "LastIdentity" means link another method first
The contracts library throws ArgumentException, and the npm packages 2.0 reject with a MessageValidationError, with Unknown Provider '<value>'. Supported values: 'Device', 'Custom'. instead of sending any other provider name.
End-to-end walkthrough
An app adds sign-in: guests sign in with Device, and users who create an account get a Custom sign-in method that works on every device.
- Your server (once): creates a random 32-byte key for deriving Custom pairs and keeps it only on the server.
- Client (first launch): generates a random GUID as the device id and persists it. It connects, completes the application step, and on
ServerUserInfoRequestsendsClientUserInfoResponsewithUniqueUserId= the old legacy id if the app had one (otherwise a new GUID),AuthProvider=Device,AuthDeviceId,AuthCreate=true. Success:ServerWelcomewith a non-emptyAccessToken. It stores the pair, the expiry andUniqueUserId. - Client: reports
ServerWelcome.UniqueUserIdto your server over your own connection. Your server: records it for the user's account (trust on first report, see Knowing which user a client is) and uses it asuniqueUserIdandownerIdin its calls from now on. - Client (restart 20 minutes later): sends only
SessionToken. Success:ServerWelcomewith empty token fields and the sameUniqueUserId; the stored pair stays. - Client (next day): sends only
RefreshToken. Success:ServerWelcomewith a new pair; it replaces the stored pair at once. - The user creates an account. Client: signs in to your system and calls your server. Your server: verifies the user and returns the derived
CustomIdandSecret. Client: sendsClientLinkIdentityRequestwithProvider=Custom. Success:ServerLinkIdentityResponsewithSuccess=true. OnNotAuthenticated, it reconnects with the refresh token first and sends the link again. - Second device: the client signs in to your system, gets the same pair from your server, and sends
ClientUserInfoResponsewithAuthProvider=Custom,AuthCreate=false. Success:ServerWelcome.UniqueUserIdequals the id from step 2. - The user signs out on the second device. Client: sends
ClientLogoutRequestwith its refresh token. Success:ServerLogoutResponsewithSuccess=true; it deletes its tokens and disconnects. The first device stays signed in. - Your organization: once nearly all active users run this client, turns on Require authenticated sessions for the application (see the rollout checklist).
Walkthrough: your backend signs every user in
An app whose own backend has accounts: the backend decides who may connect and which unique user id each account has, and it acts for users over the Server API. It uses an ephemeral app key per connection, the Custom method, and unique user ids the backend chooses (see Knowing which user a client is).
- Your server (once): creates a random 32-byte key for deriving Custom pairs and unique user ids, and keeps it only on the server. In the dashboard, your team creates an API key with "Mint ephemeral app keys" and the other scopes your server needs, and turns on Require authenticated sessions once no older client is in use.
- Client: signs the user in to your system and asks your backend for a connection ticket over your own authenticated connection.
- Your server: verifies the user and derives the
CustomId, theSecretand the unique user id from the account's subject. The first time it sees the account, it callsPOST /api/v1/{applicationId}/user/registerwith that unique user id (Full access). It mints an ephemeral app key withPOST /api/v1/{applicationId}/client-credentials/ephemeraland{"ttlSeconds":120}. It answers the ticket request with the application ID, the host, the ephemeral app key, theCustomId, theSecretand the unique user id. Rate-limit this endpoint per account and per IP address (see Protecting your mint endpoint). - Client: connects to the host and, on
ServerCredentialsRequest, sendsClientCredentialsResponsewith the application ID and the ephemeral app key asApplicationSecret. Success:ServerUserInfoRequest. - Client: sends
ClientUserInfoResponsewithUniqueUserId= the unique user id from the ticket,AuthProvider=Custom,AuthCustomId,AuthSecret,AuthCreate=true, both token fields empty. Success:ServerWelcomewithUniqueUserIdequal to the unique user id and a non-emptyAccessToken. The client stores the token pair and its expiry; it can forget theSecret. - Client (every later connection): asks your backend for a new ticket, which now only needs a new ephemeral app key, and logs in with
SessionTokenorRefreshTokenas in Reconnecting. When both tokens are refused, it asks for a ticket with the pair again and signs in with Custom (AuthCreate=false). - Your server: acts for the user with the unique user id it derived:
POST /api/v1/eventswithuniqueUserId,POST /api/v1/leaderboards/{leaderboardId}/recordswithownerId,GET /api/v1/remote-config?uniqueUserId=. It never needs an id reported by the client.
With the npm packages 2.0, steps 4 to 6 are the client's configuration. The applicationSecret function asks your backend for a new ephemeral app key before every connection; the signIn function gives the pair only when the client holds no usable token; uniqueUserId offers the id your server chose for adoption:
import { ChatClient } from "@fiveminutes-io/chat-client";
// From your backend's ticket (steps 2 and 3): the host, the pair and the unique user id it chose
const client = new ChatClient({
serverUrl: account.host, // for example https://signalr.fiveminutes.cloud
applicationId: "<application id>",
applicationSecret: () => fetchEphemeralKey(), // a new key for every connection, reconnects included
signIn: () => ({ provider: "Custom", customId: account.customId, secret: account.secret }),
uniqueUserId: account.uniqueUserId,
session: storedSession, // the stored token pair, if any
});
client.onSessionChanged((session) => storeSession(session));
await client.connect();
Check after the first sign-in that client.getSession()?.uniqueUserId equals the id from the ticket (step 5).
Limits
| Limit | Value | What happens when exceeded |
|---|---|---|
UniqueUserId |
1-256 characters | The contracts library refuses to send the message; the npm packages 2.0 refuse it before connecting (ClientConfigError); the server ends such a login with a RequestFormatViolation goodbye. |
Username |
3-64 characters, each a letter, a digit or - |
The contracts library refuses to send the message; the npm packages 2.0 refuse it before connecting (ClientConfigError); the server ends such a login with a RequestFormatViolation goodbye. |
AuthUsername |
Follow the Username rules |
Not checked by the contracts library; the npm packages 2.0 refuse an invalid signIn.username before connecting. The server refuses a Device sign-in whose AuthUsername is longer than 64 characters with UserAuthenticationFailed (Player authentication failed: the user name is longer than 64 characters.). |
AuthDeviceId / DeviceId |
At most 256 characters | Not supported. The npm packages 2.0 refuse a longer signIn.deviceId before connecting. |
AuthCustomId / CustomId |
At most 256 characters | Not supported. The npm packages 2.0 refuse a longer signIn.customId before connecting. |
AuthSecret / Secret |
At most 1024 characters | Not supported. The npm packages 2.0 refuse a longer signIn.secret before connecting. |
| Access token lifetime | 3600 seconds (the server allows up to 1 minute past expiry for clock differences) | A login presenting it is refused with UserAuthenticationFailed. |
| Refresh token lifetime | 30 days after issue or last use | A login presenting it is refused; sign in with the credential. |
| Refresh token uses | 1 | A second use is refused. |
| Access token revocation spread | Usually seconds, allow up to 2 minutes; if it cannot be distributed, until the token expires (at most 3600 seconds after issue, plus the 1-minute clock allowance) | The token is still accepted on new logins until then. |
| Require authenticated sessions change | Seconds; at most 10 minutes | New logins, and users addressing each other, follow the old value until then. |
| Automatic retries of a sign-in | None after a refusal; after a goodbye that allows a reconnect, bounded (for example 5 attempts, backoff capped at 30 seconds) | Set by your client. Retrying a refusal only repeats it. |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| All users are refused right after turning on Require authenticated sessions. | Clients below protocol 2.0, including builds with the Unity asset or the npm packages 1.8, or clients that log in without a token or credential, such as a build with the npm packages 2.0 without signIn. |
Turn the setting off. Ship a protocol 2.0 client that signs in, then follow the rollout checklist. |
| Whispers, event subscriptions, personal-group changes or P2P requests that name a user by unique user id stop reaching them after turning on Require authenticated sessions. | With the setting on, another user's unique user id names nobody (see Requiring authenticated sessions). | Name users by DisplayId. |
Reconnects fail with UserAuthenticationFailed about an hour after sign-in. |
The client sends an expired access token as SessionToken, possibly together with the refresh token. |
Send SessionToken only before its expiry minus a margin; after that send only RefreshToken. |
| A refresh is refused although the token is less than 30 days old. | It was already used (by another process, or the ServerWelcome after a refresh was lost), or the session was revoked. |
Sign in with the credential. Keep one token pair per install. |
A sign-in ends with InternalServerError and AllowAutoReconnect true. |
The server could not sign the user in, or could not decide the sign-in yet, for example because a service it needs did not answer. Nothing was decided about the credential: an outage is no refusal. | Keep the stored tokens and the credential. Log in again with the same input and the same UniqueUserId after a bounded backoff; the same credential signs in the same user, the same refresh token refreshes, and an adoption offer still stands. Contact support if it persists. |
Link or unlink answers NotAuthenticated. |
The connection logged in the legacy way, logged out, its access token expired (after a SessionToken login: the presented token's expiry), or the session was revoked. | Reconnect with the refresh token (or the credential), then send the request again. |
| One person now has two user accounts after an unlink. | The device's Device method was unlinked, and its next Device sign-in with AuthCreate true created a new user. That device now belongs to the new user and cannot be linked back, because users are never merged. |
On that device, sign in with the remaining method (for example Custom) from now on. Before unlinking Device, switch the device to that method. |
| A new user appeared instead of the old one on the first sign-in. | An adoption condition failed: the old id was not sent, the credential already belonged to a user, AuthCreate was false, no user had that id, or another sign-in adopted it first. |
Users are never merged. Fix the client to send the old id in UniqueUserId before most users update. |
| A Custom sign-in that used to work is refused with "Player authentication failed: credential rejected." | The secret changed (a third-party token was used as the secret, or your derivation key changed), or the user is banned. | Derive the secret from a stable subject with a key that never changes. To store a new secret for a user who also has another sign-in method: log in (with a token or that other method), unlink Custom (this removes every Custom id of the user), then link the same CustomId with the new secret. |
The server ignores the UniqueUserId the client sends. |
On token and credential logins the server decides the user; the id you send is at most an adoption hint. | Always use ServerWelcome.UniqueUserId. |
The contracts library throws ArgumentException when sending. |
Empty or too long UniqueUserId, invalid Username, wrong provider name, missing credential fields, or an empty logout RefreshToken. |
Fix the field named in the exception message. |
With the npm packages 2.0, connect() rejects at once with ClientConfigError. |
A sign-in or login value breaks a rule, for example an e-mail address as username, an empty signIn.deviceId or a provider name in lower case. |
Fix the option named in error.field. |
A banned user is refused with InvalidCredentials instead of UserAuthenticationFailed. |
Bans on SessionToken and legacy logins surface as InvalidCredentials. |
Treat InvalidCredentials after ClientUserInfoResponse as final. |
Related documents
- Five Minute wire protocol: client libraries, the login handshake, message conventions and every reason the server closes a connection.
- Five Minute API Contracts: the field-level reference of every protocol 2.0 message and type.
- Five Minute Server API: server tokens, scopes, ephemeral app keys, the register and ban endpoints, errors and rate limits.
- Five Minute Leaderboards: boards whose records are owned by the unique user id.
- Five Minute Remote Config: remote config, experiments and metric events, assigned and reported per unique user id.
- npm packages 2.0: the JavaScript client, which signs users in, keeps their session and logs them out.
- Unity Asset: the Unity asset, which speaks protocol 1.7 or earlier (asset 1.20 speaks 1.6, asset 1.23 speaks 1.7) and does not sign users in.
