# Use your app's login (/docs/build/app-login)



If your community site already has its own login, members don't need a second account for Domino. Your server tells Domino who is signed in, and their quests and progress follow the login they already use.

* **What members see.** They sign in to your site as usual. There is no Domino sign-in screen. They can still link wallets and connect X or Discord accounts.
* **What you do in Console.** An organization admin creates the server key under **Settings → App login keys** and keeps it working there. Reviewers can unlink a member's app identity on the member's page.
* **What your developer does.** Adds a small handler to your site's server and points the browser at it.

Signing users in with Privy, Dynamic or Para? Their recipes give the whole server route: [Privy](/docs/build/privy), [Dynamic](/docs/build/dynamic), [Para](/docs/build/para). For Reown AppKit, which has no login token, see [Use Domino with Reown AppKit](/docs/build/reown).

Fresh Domino-hosted apps can use the [member sign-in](/docs/build/hosted-app#add-domino-participant-sign-in) supplied with their setup instead, including [wallet sign-in](/docs/build/wallets#sign-in-with-a-wallet) where the project enables it. Domino's own wallet sign-in works only in apps Domino hosts; an app you host signs wallets in with its own login, as [wallet sign-in in your own Next.js app](/docs/build/self-hosted-wallet-sign-in) shows.

> [!DEV] Connecting accounts
> Members of an app with its own login link wallets and connect X or Discord accounts with `relay.auth.start({ provider, mode: "connect" })`. The integration needs your app to have a server route.

```mermaid
sequenceDiagram
    actor User
    participant Browser as Browser client
    participant Server as Your server
    participant Domino
    User->>Browser: Sign in with your app's login
    Browser->>Server: Participant request with the app session
    Server->>Server: authenticate(request) verifies the session
    Server->>Domino: Stable user ID and registered project scope
    Domino-->>Server: Quests and progress
    Server-->>Browser: Participant response
```

## Connect your app's login [#connect-your-apps-login]

### 1. Create an integration key [#1-create-an-integration-key]

An organization administrator opens **Settings → App login keys** in Console, selects the project and environment, and registers the app's exact HTTPS origin. A **Test** key can also name a loopback origin, such as `http://localhost:3000`, for [local development](#develop-against-domino-on-localhost). To let tracker records name your app's user IDs, also fill in **Identity kind**; see [link your app's user IDs](#link-your-apps-user-ids). **Expiry** is **Never expires** unless you choose 30 days, 90 days or 1 year.

For a Domino-hosted app, leave **Also save it as an app secret** checked. Console stores the new key as the app secret `APP_IDENTITY_KEY` in the key's environment, and nobody copies it. Change **App secret name** if your server reads another name. The app reads the key from its next build: the next staged build in Test, or the next build promoted to live. The box needs publish permission and appears only for a project whose code Domino hosts.

For any other app, or with the box cleared, Console shows the key once. Save it directly in the application's server secrets as `APP_IDENTITY_KEY`.

A key belongs to one project and environment and signs members in only there. A Domino-hosted app's staging build runs in **Test**, so it needs a **Test** key registered for its staging address, `https://<project-slug>--staging.domino.page`. The live app needs a **Live** key for its live address or custom domain. Console refuses a key for a Domino address in the environment that address doesn't serve, and names the right one; see [pick the environment](#pick-the-environment-for-the-address).

> [!WARNING] A key that isn't saved as an app secret is shown once
> Copy it straight into your server secrets. CLI login credentials are for management operations and cannot replace this key, and this key signs members in only. [Access](/docs/concepts/access#kinds-of-credentials) lists every kind of credential.

> [!DEV] Handing the key to your developer or agent
> Creating, rotating and revoking keys stays in Console. An organization admin's CLI session can list a project's keys for one environment, without their secrets.
>
> 1. The admin creates the key in Console. For a Domino-hosted app, Console saves it as the app secret in the same step, so step 2 is done.
> 2. Otherwise the admin stores it without pasting it into chat: `printf %s "$KEY" | domino secrets set APP_IDENTITY_KEY` for a Domino-hosted app (add `--environment live` for the live app's secret), or the app's own server secret settings.
> 3. `domino secrets set` checks a key before storing it and names the key it belongs to (`identityIntegration` in its `--json` result). It stores nothing and exits with `INVALID_ARGUMENT` when the key is unknown, expired or revoked, or belongs to another project or environment, such as a Live key set as Test's secret. When the key's origin is not the app's own address for that environment, it stores the key and warns with both origins. When it cannot reach Domino to check, it warns and stores the key unchecked.
> 4. The developer or agent confirms it with `domino identity-integrations list --json`, which shows each key's origin, identity kind and the expiry and last use of its secrets, and `domino secrets list`.

### 2. Connect your server [#2-connect-your-server]

Use `createParticipantBackend` from `@domino-sdk/relay/portal-proxy`. Its `authenticate(request)` function must verify the app's existing session and return a stable user ID. Mount the handler at `/relay/*`.

### 3. Connect the browser [#3-connect-the-browser]

Use `createBrowserRelayClient({ baseUrl: "/relay" })` in the browser. The adapter supplies the registered project scope. You do not need to forward browser project headers or write your own identity exchange.

The [complete server example](/docs/reference/participant-api#existing-application-login) explains routing, cookies, and error handling.

If your app signs members in or out without loading a new page, tell the browser client each time:

* **After your app's sign-in succeeds**, read the Domino session again before showing member pages. The page read it when it loaded, while nobody was signed in, and keeps that answer until it is asked again. Otherwise the member looks signed out for up to 30 seconds, or until the window regains focus.
* **After your app's sign-out succeeds**, read the session again too. With your app's session gone, Domino finds nobody signed in and the member's data disappears as soon as that read answers. Sign out through your app only: Domino's own sign-out passes through your server's check of the app session, so while that session lasts, the page's next read signs the member straight back in.

> [!DEV] Refresh the session in React
> Under `RelayProvider`, call `useSession().refresh()` after your sign-in route answers and again after your logout route answers. `refresh()` never rejects. After sign-in it switches the member hooks to the new member and reads their data; after sign-out they report `signed-out` once a read answers `null`. When Domino refused the sign-in, `useSession()` reports `status: "error"` with the reason in `error`: show it instead of the member pages. Don't call `useSignOut()` with app login.
>
> ```tsx
> import { useSession } from "@domino-sdk/relay/react"
>
> // `navigate` is your router's.
> export function useAppLogin(navigate: (to: string) => void) {
>   const { refresh } = useSession()
>
>   return {
>     async signIn(form: FormData) {
>       const response = await fetch("/api/login", { method: "POST", body: form })
>       if (!response.ok) throw new Error("Sign-in failed")
>       // RelayProvider still holds the signed-out session it read on load.
>       await refresh()
>       navigate("/members")
>     },
>     async signOut() {
>       await fetch("/api/logout", { method: "POST" })
>       // With the app session gone, the read finds nobody and drops the member's data.
>       await refresh()
>       navigate("/")
>     },
>   }
> }
> ```

### 4. Verify through the app [#4-verify-through-the-app]

* Sign in normally and check that Domino recognizes the member.
* Sign in without reloading the page and check that member pages show the member at once.
* Complete one quest and refresh.
* Sign out of your app and confirm that member access is removed.
* Sign out without reloading the page and check that the member's data is gone without waiting for a poll.
* If the app supports account switching, check that too.

> [!NOTE] The remote doctor cannot check your session code
> A successful `doctor --remote` result checks management configuration. It cannot verify the app's session implementation or integration secret.

## Troubleshoot sign-in [#troubleshoot-sign-in]

### Pick the environment for the address [#pick-the-environment-for-the-address]

Create the key in the environment whose members the app signs in:

| The app's address                                                             | Create the key in                                   |
| ----------------------------------------------------------------------------- | --------------------------------------------------- |
| Staging build, `https://<project-slug>--staging.domino.page`                  | **Test**                                            |
| Development preview, `https://<project-slug>--<session>.domino.page`          | **Test**                                            |
| Live app, `https://<project-slug>.domino.page`                                | **Live**                                            |
| Your custom domain, which serves the live app                                 | **Live**                                            |
| Your own server's address                                                     | Either. Pick the environment its members belong to. |
| `http://localhost:<port>`, `http://127.0.0.1:<port>` or `http://[::1]:<port>` | **Test**                                            |

For a staging, preview or live `domino.page` address in the other environment, **Create server key** refuses with a message that names the right environment, such as "…is a staging app, which signs members in to Test. Create this app login key in Test." Keys created before this check are left as they are; **Disconnect** one and create it again in the right environment.

The address must be an exact origin: `https://` and the host, with an optional port, and nothing after it. When it has a path, a query or a trailing slash, Console names the origin it accepts, such as "Use the exact origin [https://app.example.com](https://app.example.com), without a path, query or trailing slash." Plain `http://` works only on a loopback host and only in **Test**: elsewhere Console answers "Use an HTTPS origin, or a loopback origin such as [http://localhost:3000](http://localhost:3000) for a Test key.", and on a **Live** key "Live app login keys need an HTTPS origin. Loopback origins such as [http://localhost:3000](http://localhost:3000) work only on Test keys."

### Why Domino refused a sign-in [#why-domino-refused-a-sign-in]

When Domino refuses the sign-in exchange, `createParticipantBackend` answers the browser with Domino's status and code. Only a 409 and `ACCOUNT_VOUCHING_OFF` carry Domino's own message; every other refusal shows a general one, so your users never read instructions meant for your developer.

| Status | `code`                               | Meaning                                                                                   | Who fixes it                                                                           |
| ------ | ------------------------------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| 401    | `IDENTITY_INTEGRATION_ACCESS_DENIED` | The key is unknown, expired or revoked, or isn't an app login key.                        | Your developer sets a working key from Console as `APP_IDENTITY_KEY`.                  |
| 400    | `INVALID_IDENTITY_EXCHANGE`          | The server sent something besides a user ID, `previousToken` and up to 20 `accounts`.     | Your developer.                                                                        |
| 403    | `ACCOUNT_VOUCHING_OFF`               | The server sent `accounts`, but the key doesn't vouch for accounts.                       | An admin [turns on vouching](#turn-it-on), or your developer stops sending `accounts`. |
| 403    | `live-offline`                       | The project's live app is offline because it has no plan.                                 | The organization [chooses a plan](/docs/concepts/promotion#going-live-needs-a-plan).   |
| 409    | `APP_IDENTITY_UNLINKED`              | An organizer unlinked this user ID.                                                       | An organizer [relinks it](#unlink-an-app-identity).                                    |
| 409    | `APP_IDENTITY_CONFLICT`              | The key already signs this user ID in as another member.                                  | An organizer.                                                                          |
| 429    | `IDENTITY_INTEGRATION_ACCESS_DENIED` | Too many sign-ins for this key. The answer's `Retry-After` says how many seconds to wait. | Nobody: retry after the wait.                                                          |

After sign-in, a participant request answers 403 `IDENTITY_INTEGRATION_ACCESS_DENIED`, "Application origin does not match the identity integration", when the `origin` your server passes to `createParticipantBackend` is not the key's origin. Create a key for the address the app runs at.

The key alone picks the project and environment for these requests. On the private staging address, they don't need the staging pass that a hosted app's own `/relay/*` proxy forwards, so app login works there as it does on the live address. A 403 `staging-access-required` comes from a request without the key.

> [!DEV] Log refusals on your server
> Pass `onExchangeError(error, request)` to `createParticipantBackend` to see each refused exchange as Domino answered it. `error` is a `ParticipantExchangeError` with `status`, `code`, Domino's `message` and `retryAfter` in milliseconds. The browser's answer stays the same, and an error thrown in the callback doesn't change it.
>
> ```ts
> import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
> import { verifyAppSession } from "./your-existing-server-auth"
>
> export const participant = createParticipantBackend({
>   baseUrl: "https://relay.domino.run",
>   origin: "https://app.example.com",
>   integrationKey: process.env.APP_IDENTITY_KEY!,
>   authenticate: async (request) => {
>     const user = await verifyAppSession(request)
>     return user ? { subject: user.id } : null
>   },
>   onExchangeError: (error) => {
>     console.error("Domino sign-in refused", error.status, error.code, error.message)
>   },
> })
> ```

## Test the login in a browser [#test-the-login-in-a-browser]

A browser test can run your real server handler, identity exchange included, against [the fake participant API](/docs/build/testing#test-your-apps-ui) instead of Domino. Your app can then run at a local address such as `http://127.0.0.1:4173` or `vite` dev's `http://localhost:5173`.

> [!DEV] Run the real adapter against the fake
> In test mode, give `createParticipantBackend` the local origin, any HTTPS `baseUrl`, and the fake's `fetch`. Here an `E2E` environment variable, set by the command your Playwright `webServer` runs, selects test mode:
>
> ```ts
> import { createParticipantBackend } from "@domino-sdk/relay/portal-proxy"
> import { createFakeRelay } from "@domino-sdk/relay/testing"
> import { verifyAppSession } from "./your-existing-server-auth"
>
> const authenticate = async (request: Request) => {
>   const user = await verifyAppSession(request)
>   return user ? { subject: user.id } : null
> }
>
> export const participant = process.env.E2E
>   ? createParticipantBackend({
>       baseUrl: "https://relay.test",
>       origin: "http://127.0.0.1:4173",
>       integrationKey: "di_test",
>       authenticate,
>       fetch: createFakeRelay({ member: "member_ada" }).fetch,
>     })
>   : createParticipantBackend({
>       baseUrl: "https://relay.domino.run",
>       origin: "https://app.example.com",
>       integrationKey: process.env.APP_IDENTITY_KEY!,
>       authenticate,
>     })
> ```
>
> The test then signs in through your app's own login and checks the page, with no `page.route` for `/relay`. The fake runs in the server process, so seed it there; the test cannot read its `requests`. It also keeps one state for every test that server serves: after a test signs out, later sign-ins become the member whose ID is your app's user ID, or fail with a `TestSetupError` when that user ID is not a valid member ID, so run sign-out tests last or give them their own server. Set `E2E` only in the `webServer` command.
>
> `origin` may be `http://localhost`, `http://127.0.0.1` or `http://[::1]`, with any port; any other `http:` origin throws. On these origins the `domino_app_session` cookie is not `Secure`, because WebKit drops `Secure` cookies on `http://localhost`. The origin and `X-Relay-CSRF` checks are the same as in production. `baseUrl` must still be HTTPS.
>
> A real key works on a local origin only when it was created for that exact origin, port included: against `https://relay.domino.run`, a key for another origin signs the user in through the exchange, then every participant request is refused with 403 `IDENTITY_INTEGRATION_ACCESS_DENIED`. The exchange signs the user in as a real member and, for a key with an identity kind, links the user ID, so keep real keys out of automated tests.

## Develop against Domino on localhost [#develop-against-domino-on-localhost]

To run your app on your machine against real Domino, create a **Test** key for its local address, such as `http://localhost:3000`, and give `createParticipantBackend` the same `origin` with `baseUrl: "https://relay.domino.run"`. Members sign in to the project's **Test** environment. A **Live** key needs an HTTPS origin, so create a separate **Live** key for the deployed address.

Each port is its own origin: a key for `http://localhost:3000` refuses requests from `http://localhost:3001`. [Wallet sign-in in your own Next.js app](/docs/build/self-hosted-wallet-sign-in#develop-on-localhost) walks through it.

## Link your app's user IDs [#link-your-apps-user-ids]

Your servers can report activity for your own users, such as a game reporting a win for user `42`, and a quest can pay the member who signs in as that user. To allow it, give the key an identity kind, such as `app:game`, when you create it.

The key then links each user ID your server signs in to its member, as an identity of that kind in the project and environment. Your servers can send [tracker records](/docs/build/trackers#your-apps-user-ids) that name those user IDs, and Console lists them on the member under **Identities**.

An identity kind is for your app's own user IDs. To pay a member's Telegram, X or Discord account, [vouch for it](#vouch-for-your-members-accounts) instead: X quests and Telegram and Discord trackers then pay it as an account the member linked.

* **A kind belongs to the key that first claimed it.** In a project and environment, a kind names the user IDs of one app. While its key is connected, no other key can be created with it. Another project, or the other environment, has its own.
* **The kind is set when the key is created.** It cannot be added to or changed on an existing key. Rotating the key keeps it and every link.
* **A user ID is linked when the user signs in.** Records for a user ID nobody has signed in with yet are skipped. A linked user ID always signs in as its member.
* **Name the kind** `app:` and up to 40 lowercase letters, digits, `_` or `-`.

Leave the kind empty for an integration that only signs users in.

> [!DEV]
> Tracker records name the kind with `identities: ["app:game"]`. A record for a user ID nobody has signed in with yet is skipped with `unlinked-account`. Evaluators read the member's identities with `ctx.member.identities()`; see [Evaluator readers: null, errors and pinning](/docs/reference/verification#evaluator-readers-null-errors-and-pinning).

### Map members to your user IDs [#map-members-to-your-user-ids]

Your server can look up the member a user ID is linked to, and list the user IDs linked to each member, with the management client. Both read only linked user IDs: one an organizer [unlinked](#unlink-an-app-identity) is not found until it is relinked.

> [!DEV]
> `management.members.lookup({ provider: "app:game", subject: "42" })` answers the member holding user `42`, with `id` and `appIdentities`, or fails with 404 `member-not-found`. The lookup is exact, including letter case, so send a user ID in the form your server signed the user in with or the import used. Going the other way, `members.people(ids)` (up to 100 member IDs) and `members.list()` list each member's `appIdentities` as `{ kind, subject }`, as does `member` in `members.get(id)`, and `members.list({ q })` also matches part of a user ID. All need `read` permission.

### Reuse a kind after disconnecting its key [#reuse-a-kind-after-disconnecting-its-key]

**Disconnect** keeps the kind's links: tracker records that name those user IDs still pay their members. A new key can take the kind only by adopting its identities, because each linked user ID then signs in as its member, and another app's user `42` would sign in as this app's user `42`.

When you type a kind whose disconnected key left identities, **Create server key** shows how many user IDs it links. Adopt them only if the new key's user IDs mean the same users: check **This app's user IDs mean the same users** and give a reason, which the audit log keeps with your identity. Without adoption the key is refused; use a new kind for a different app. A kind with no identities left is reused freely.

> [!DEV]
> In code, pass `adoptIdentitiesFrom` with the old key's ID, from `management.identityIntegrations.kinds()`, and a `reason`. Without adoption the key is refused with 409.

### Unlink an app identity [#unlink-an-app-identity]

A reviewer can unlink a member's app identity in Console, on the member under **Identities**. Unlinking:

* stops tracker records that name the user ID from paying anyone, and removes it from the member's identities that new attempts read;
* ends the member's sessions that the kind's key started for that user ID;
* refuses that user ID's sign-in until it is relinked, so the user cannot reach the member through your app.

**Relink** restores it for the same member and rechecks the tracker records that were skipped while the user ID was unlinked. The quest's [historical activity](/docs/concepts/trackers#historical-activity) policy decides what happens to earlier activity: it is admitted automatically, or kept for the member or staff to release. Attempts already admitted keep their original attribution. Unlink and relink both need a reason and **Review** permission, and the audit log keeps both. See [operator identity changes](/docs/concepts/participants#operator-identity-changes).

> [!DEV] Unlink and relink in code
> Call `management.members.identityStates.unlink(member, { identity: { kind: "app", appKind, subject }, expectedRevision, reason })`, where `appKind` is the kind, such as `app:game`, and `expectedRevision` is the `revision` that `identityStates.list(member)` returned. Unlinking removes the user ID from `ctx.member.identities()` for new attempts.
>
> `management.members.identityStates.relink` takes the same input. It schedules re-decisions of records skipped with `unlinked-account`. Under the quest's `historical: "automatic"` policy, eligible earlier activity is admitted; `"claim"` or `"staff"` keeps earlier activity as a historical skip for the member or staff to release.
>
> Every unlink and relink raises the identity's `revision`, and a relink sets a new `linkedAt`. `management.members.unlinkIdentity` and `relinkIdentity(member, { kind, subject, reason })` are deprecated forms of the same commands, without the revision check.

When Domino refuses a sign-in, your site can show the member why. `createParticipantBackend` answers the browser with status 409 and one of these codes:

| `code`                  | Meaning                                                                                                                      |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `APP_IDENTITY_UNLINKED` | An organizer unlinked this user ID. Ask them to relink it.                                                                   |
| `APP_IDENTITY_CONFLICT` | This key already signs the user ID in as another member than the one the kind links it to. Only an organizer can resolve it. |

## Vouch for your members' accounts [#vouch-for-your-members-accounts]

If your app already knows its users' X, Telegram or Discord accounts or wallets, its server can attach them when it signs each user in. Members then don't have to connect them again. X quests and Telegram and Discord trackers pay them from that moment, as if they had linked the accounts themselves. This is the same trust as a [member import](/docs/use/imports#what-an-import-records), for members who join after it.

### Turn it on [#turn-it-on]

Vouching is off for every key. An organization admin who also has **Publish** permission turns it on in **Settings → App login keys**: under **Connected apps**, choose **Allow vouching** on the key, give a reason, and confirm. The audit log keeps your identity and reason. **Stop vouching** turns it off the same way. Accounts already vouched for stay.

Allow it only when your app verifies the accounts it sends, because Domino trusts them like accounts the member proved.

### What Domino does with each account [#what-domino-does-with-each-account]

* **Attached.** The account is linked to the member. Console marks it **Vouched by** and your key's name, under the member's **Identities** or **Wallets**.
* **Already linked.** The member has it already. Nothing changes, so your server can send the same accounts every time it signs a user in.
* **Refused.** It belongs to another member, or the member already has a different X, Telegram or Discord account. An account an organizer unlinked from this member, or the member disconnected, is refused too, so your app never undoes an unlink. A wallet is also refused when the member already has 5 wallets of that chain besides the one they sign in with.

A refused account never stops the sign-in. Domino tells your server which accounts it refused and why.

Vouched accounts follow the rules of imported ones:

* A vouched account never replaces an account the member linked, nor one vouched for earlier.
* When the member links an account of that kind themselves, their link replaces the vouched one.
* Nobody signs in with a vouched account.
* When an organizer unlinks a vouched account the member never signed in with or proved, Domino releases it: its owner can link it and sign in with it, and your app's next exchange can't vouch it back for that member. See [operator identity changes](/docs/concepts/participants#operator-identity-changes).
* A leaderboard's [payout list](/docs/build/leaderboards#export-the-payout-list) leaves out vouched wallets. When the member links the wallet through Domino, signing Domino's own message in your app with `relay.wallets.link`, it becomes a wallet they proved and the payout list includes it. See [pay prizes to the wallet](/docs/build/self-hosted-wallet-sign-in#pay-prizes-to-the-wallet).

> [!DEV] Sending accounts
> Return `accounts` from `authenticate` in `createParticipantBackend`, next to `subject`: up to 20 entries, wallets as `{ kind: "wallet", chain, address }` (plus `network` for Cosmos), and at most one each of `{ kind: "x" | "telegram" | "discord", subject }` with the numeric user ID. Pass `onAccounts(results, request)` to read each account's `status`: `attached`, `already-linked` or `refused`, with a `code` such as `account-linked-elsewhere`, `identity-unlinked`, `x-account-selected`, `telegram-account-selected`, `discord-account-selected` or `limit-reached`.
>
> A server that signs users in without a browser calls `exchangeParticipant({ baseUrl, integrationKey, subject, accounts })` from `@domino-sdk/relay/portal-proxy`. It answers the session `token` and the same `accounts` results, and throws `ParticipantExchangeError` when the exchange is refused: a `RequestError` of kind `http` with the answer's `status`, Domino's `code`, and `retryAfter`, the milliseconds its `Retry-After` asks to wait. `isParticipantExchangeError(error)` recognizes it. Pass the previous `token` as `previousToken` to keep using that session while it lasts.
>
> `inspectIntegrationKey({ baseUrl, integrationKey })` from the same entry answers which key an app login key is: its `id`, `name`, `organization`, `project`, `environment`, `origin`, `identityKind`, `vouchesAccounts`, `secretExpiresAt` (null when it never expires) and `appOrigins`, the addresses Domino serves the project's app from in that environment. It never returns a secret, and checking a key does not count as using it. A key that is unknown, expired or revoked, or whose project was deleted, throws `ParticipantExchangeError` with status 401 and `invalid-credential`.
>
> With vouching off, an exchange that sends accounts is refused with status 403 and `ACCOUNT_VOUCHING_OFF`, and nobody is signed in. Domino never drops the accounts silently. Operators read `vouchedBy` (`{ integration, integrationName }`) next to `imported: true` on the member's accounts, identity states and wallets. `management.identityIntegrations.setAccountVouching(id, { enabled, reason })` changes the setting from a Console session; a retry with the same `actionId` answers with the first result.

## Maintain the connection [#maintain-the-connection]

A key has one or more secrets, and each works until it is revoked or reaches its own expiry. Each key in **Connected apps** lists its secrets with their last four characters, when they expire and when they were last used. A key created before secrets could be rotated keeps its original expiry until you remove it.

| Action                                  | Effect                                                                                                                                                                                                                                                                                                                    |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Add secret**                          | Issues a second secret that never expires. Both secrets work. With **Also save it as an app secret** checked, Console replaces the app secret with the new secret; deploy to use it. Otherwise copy the new one with **Copy key**, update `APP_IDENTITY_KEY` on every server, and deploy. Member sessions stay signed in. |
| **Revoke**, then **Revoke secret**      | Stops one secret at once. Wait until its **Last used** time stops changing first. A key's only secret has no **Revoke**.                                                                                                                                                                                                  |
| **Remove expiry**                       | Makes an expiring secret never expire.                                                                                                                                                                                                                                                                                    |
| **Disconnect**, then **Disconnect now** | Stops every secret and ends the integration's member sessions.                                                                                                                                                                                                                                                            |

> [!WARNING] An expired key signs everyone out
> When a key's last secret expires, the integration stops and every member session it started ends. Add a secret before an expiry, or remove the expiry.

> [!DEV]
> In code, `management.identityIntegrations.addSecret(id)` returns the new secret as `token`; `createIntoAppSecret(input, name)` and `addSecretIntoAppSecret(id, name)` store it as the app secret `name` in the key's environment instead, answer `appSecret` and no `token`, need publish permission, and leave no key or secret when the app secret is refused. Like every key change, they work only from an administrator's Console session. `revokeSecret(id, secret)` revokes one, and `removeSecretExpiry(id, secret)` removes an expiry. The deprecated `rotate(id)` replaces every secret at once, so the old secret stops immediately.

> [!CAUTION] A new integration means new identities
> Creating a new integration creates a new identity namespace. Rotate the existing integration when you want to keep identity continuity. An integration with an identity kind is the exception: a new integration that [adopts](#reuse-a-kind-after-disconnecting-its-key) a disconnected key's kind signs each user ID it linked in as the same member.
