This is the full developer documentation for Sudomimus # Sudomimus Documentation > An authentication and authorization platform built on passkeys, email OTP, signed token exchange, and OIDC. ## Or — hand it to your AI [Section titled “Or — hand it to your AI”](#or--hand-it-to-your-ai) Paste this prompt into Claude, Cursor, ChatGPT, or any AI tool with web access: ```text Read https://docs.sudomimus.com/llms-full.txt and integrate Sudomimus authentication into my application. Stack: Auth methods: Application anchor: your-application Callback URL: ``` The entire documentation is published in a machine-readable form — your AI will read it and write the integration in one shot. See [Build with AI](/en-us/ai/overview/) for the endpoints and tool-specific tips. When the assistant needs to operate Sudomimus directly, give it the [Sudomimus CLI](/en-us/ai/cli/) as a shell-friendly control surface. ## What’s in these docs [Section titled “What’s in these docs”](#whats-in-these-docs) * **Getting Started** — what Sudomimus is and how to choose an integration path. * **Concepts** — accounts, organizations, pairwise identity, claim sharing, and token verification. * **Application Rules** — the three-layer allowlist model that decides which auth methods, which identities, and which return paths your application accepts. * **Connect / OIDC / Native** — three separate, peer integration paths with their own protocol flows. * **Common Operations** — session management and account deletion. * **Reference** — the SDKs, the CLI guidance, and the AI-assistant endpoints. These docs are also available as raw Markdown for AI agents: append `.md` to any page URL (for example, [`/en-us/connect/three-key-model.md`](/en-us/connect/three-key-model.md)), or fetch [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt) for the full corpus. # Driving your AI assistant > Tool-specific tips for Claude, Cursor, ChatGPT and other assistants, plus how to get better integration code. The general pattern is the same everywhere: give the assistant the [docs corpus](/en-us/ai/endpoints/), then describe what you want it to build. The details differ a little per tool. ## Claude Code / claude.ai [Section titled “Claude Code / claude.ai”](#claude-code--claudeai) Claude can fetch URLs directly — just include the URL in your message: ```text Read https://docs.sudomimus.com/llms-full.txt then implement a complete Sudomimus integration for my Next.js app. The application anchor is your-application. Support passkeys and email OTP. Add routes for /api/auth/establish, /api/auth/callback, and /api/auth/refresh. ``` ## Cursor / Continue / similar editor tools [Section titled “Cursor / Continue / similar editor tools”](#cursor--continue--similar-editor-tools) Use your editor’s URL-attach feature (e.g. Cursor’s `@web`, Continue’s `@docs`) to attach `https://docs.sudomimus.com/llms-full.txt` to the conversation, then ask the same question. ## ChatGPT with web browsing [Section titled “ChatGPT with web browsing”](#chatgpt-with-web-browsing) ```text Fetch https://docs.sudomimus.com/llms-full.txt and write a Python Flask backend that integrates Sudomimus. Use application anchor your-app and store pending sessions in PostgreSQL. ``` ## Let the assistant use the CLI [Section titled “Let the assistant use the CLI”](#let-the-assistant-use-the-cli) When the task involves your Sudomimus account or developer resources, the assistant should use the CLI instead of trying to drive the With portal through a browser. Give it a narrow instruction like: ```text Use the Sudomimus CLI for Sudomimus account operations. Prefer JSON output, for example `sudomimus whoami --json`. If the CLI is not logged in, stop and ask me to run `sudomimus login --no-open`; do not read or print the local credentials file. ``` See [Sudomimus CLI for AI agents](/en-us/ai/cli/) for the command surface, endpoint overrides, and security boundaries. ## Tips for better output [Section titled “Tips for better output”](#tips-for-better-output) * **Be specific about your stack.** “Node.js + Express” produces different code than “Bun + Hono”. The docs cover the protocol; you tell the assistant the surrounding context. * **Name the authentication methods you need.** Sudomimus supports passkeys, email OTP, Steam, AccessKey, and OIDC — the assistant has no way to guess which apply to your product. * **Give it your application anchor, callback URL, and any env vars you have.** The assistant cannot make these up. * **Point it at the [OpenAPI reference](/en-us/ai/endpoints/#the-openapi-reference) for exact contracts.** When you want strongly-typed client code, the generated API pages beat prose-derived guesses. * **Keep the conversation open for follow-ups.** The docs are already in context, so debugging or asking for variants is one more message away. # Sudomimus CLI for AI agents > Use the Sudomimus CLI as a shell-friendly control surface for AI coding assistants, local automation, and account checks. The Sudomimus CLI is the best control surface when an AI assistant needs to operate Sudomimus directly. It gives the assistant normal shell commands instead of a browser session, while keeping authentication in the user’s browser through the device authorization flow. Use it when the assistant needs to: * confirm which Sudomimus account is active; * refresh an expired CLI session without asking the user to sign in again; * call With account or developer operations as those commands become available; * work in an editor, terminal, or CI-like agent environment where browser cookies are not available. The CLI is for operating Sudomimus itself. If you are building your own CLI application that authenticates your users, choose the [Native integration path](/en-us/native/overview/) instead. ## First login [Section titled “First login”](#first-login) Installed CLI examples use the `sudomimus` binary: ```bash sudomimus login ``` The CLI prints a verification URL and user code, opens the browser when possible, and waits for the device grant to complete. For a remote agent or terminal-only environment, keep the browser step manual: ```bash sudomimus login --no-open ``` In a source checkout before the CLI is published, run the workspace package directly: The local session is stored under `$SUDOMIMUS_CLI_HOME/credentials.json` when that environment variable is set, otherwise under `~/.sudomimus/credentials.json`. The file contains bearer credentials; an assistant should use the CLI, not read or print this file. ## Machine-readable output [Section titled “Machine-readable output”](#machine-readable-output) Commands that expose data to agents should support JSON output. The first available read command is: ```bash sudomimus whoami --json ``` It returns the current With account identity as JSON. If the access token is near expiry, the CLI refreshes it through Connect before calling With. For prompts, tell the assistant to prefer CLI JSON output over scraping terminal prose: ```text Use the Sudomimus CLI for Sudomimus account operations. If a command supports --json, use it. If the CLI is not logged in, ask me to run `sudomimus login --no-open` and paste the verification URL/code into my browser. Do not read or print ~/.sudomimus/credentials.json. ``` ## Endpoint overrides [Section titled “Endpoint overrides”](#endpoint-overrides) For local development, the CLI accepts explicit endpoint overrides: ```bash sudomimus login \ --device-api-base-url http://localhost:7701 \ --connect-api-base-url http://localhost:7101 \ --with-api-base-url http://localhost:7301 ``` The same values can be supplied with environment variables: ```bash SUDOMIMUS_DEVICE_API_BASE_URL=http://localhost:7701 SUDOMIMUS_CONNECT_API_BASE_URL=http://localhost:7101 SUDOMIMUS_WITH_API_BASE_URL=http://localhost:7301 SUDOMIMUS_WITH_APPLICATION_ANCHOR=sudomimus-with SUDOMIMUS_CLI_HOME=.sudomimus-cli ``` Endpoint overrides are useful for agents running inside a repository checkout, because the assistant can point the CLI at a local Serverless process without changing stored production credentials. ## Security model [Section titled “Security model”](#security-model) The CLI never needs the `sudomimus-with` client-auth private key. Login uses the generic device authorization flow: the CLI asks `device-api` for a device code, the user completes normal Sudomimus authentication in the browser, and the CLI receives an access/refresh token pair only after approval. This keeps the high-risk step human-confirmed while still making the post-login surface easy for agents: * the browser owns passkeys, email OTP, OAuth, and enterprise federation; * the CLI owns token refresh and API calls; * the assistant owns command selection and JSON parsing; * credentials stay in the local CLI store instead of being copied into prompts. # What to feed your AI > The machine-readable endpoints — llms.txt, the abridged and full corpora, per-page Markdown, and the OpenAPI reference. Sudomimus exposes its documentation in several machine-readable forms. Which one you hand your assistant depends on how much context window you can spare and whether you want prose or a formal API contract. ## The llms.txt endpoints [Section titled “The llms.txt endpoints”](#the-llmstxt-endpoints) Three plain-text endpoints follow the [llmstxt.org](https://llmstxt.org) convention: | URL | What it is | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `https://docs.sudomimus.com/llms.txt` | **Index** — a short manifest linking to the two corpora below. Good for assistants that fetch pages selectively rather than all at once. | | `https://docs.sudomimus.com/llms-full.txt` | **Full corpus** — every documentation page concatenated into one file. | | `https://docs.sudomimus.com/llms-small.txt` | **Abridged corpus** — the same documentation with the heaviest reference material trimmed out. | ### Abridged or full? [Section titled “Abridged or full?”](#abridged-or-full) Both corpora cover the same ground — shared platform concepts, the separate Connect/OIDC/native flows, and the three-layer rule model. They differ only in depth: * **`llms-full.txt`** includes everything, including the long per-method and per-payload reference tables (every OAuth provider, every rule method, every edge case). It is roughly **three times the size** of the abridged version. Use it when your assistant has a large context window and you want a complete, one-shot integration. * **`llms-small.txt`** drops those exhaustive tables and the edge-case asides, keeping the conceptual model and the core flows. Use it when context is tight, or when you want the assistant to grasp the model quickly and you’ll point it at specific pages for the details. When in doubt, start with `llms-full.txt` — most modern assistants can hold it comfortably. ## Per-page Markdown [Section titled “Per-page Markdown”](#per-page-markdown) Append `.md` to any documentation page URL to fetch just that page as raw Markdown — for example, [`/en-us/connect/three-key-model.md`](/en-us/connect/three-key-model.md). This is the precise way to give an assistant one page rather than the whole corpus. ## The OpenAPI reference [Section titled “The OpenAPI reference”](#the-openapi-reference) For the formal request/response contract of the public APIs, point your assistant at the **OpenAPI 3.1 reference**, generated directly from the published specifications: * **[Connect API](/en-us/api/connect/)** — the token-exchange endpoints (`/establish`, `/redeem`, `/refresh`), each with its own operation page. * **[Native API](/en-us/api/native/)** — the direct-issue endpoints for native clients (`/direct-issue/steam-ticket`, `/direct-issue/access-key`). * **[Device API](/en-us/api/device/)** — the public-client device authorization endpoints (`/device-authorize`, `/device-token`). These pages are the source of truth for exact paths, parameters, request bodies, and status codes — feed them to your assistant when you want strongly-typed client code rather than prose-derived guesses. ## Staying current [Section titled “Staying current”](#staying-current) The `llms*.txt` endpoints and the OpenAPI reference rebuild every time the documentation does — there is no separate “AI version” that lags behind. If your assistant has cached an older fetch, ask it to re-fetch. # Build with AI > Hand the entire Sudomimus documentation to your AI coding assistant and let it write the integration for your stack. Sudomimus’s documentation is published in a **machine-readable form** alongside the human-facing pages. If your editor or chat tool can fetch a URL, you can hand the whole docs corpus to your AI assistant in one shot and let it write the integration code for your stack — in one message, not a copy-paste marathon. ## The one-shot prompt [Section titled “The one-shot prompt”](#the-one-shot-prompt) Paste this into Claude, Cursor, ChatGPT, or any AI tool with web access, filling in the angle-bracketed parts: ```text Read https://docs.sudomimus.com/llms-full.txt and integrate Sudomimus authentication into my application. Stack: Auth methods: Application anchor: Callback URL: ``` The assistant reads the full corpus and writes the integration. The docs describe the protocol; you supply the surrounding context — your framework, your auth methods, your anchor. If the assistant needs to operate your Sudomimus account or developer settings while it works, give it the [Sudomimus CLI](/en-us/ai/cli/) instead of browser instructions. The CLI exposes login, session, and account commands in a form an agent can call directly. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * **[What to feed your AI](/en-us/ai/endpoints/)** — the machine-readable endpoints (`llms.txt`, the abridged and full corpora, per-page Markdown) and the OpenAPI reference, with guidance on which to use when. * **[Driving your AI assistant](/en-us/ai/assistants/)** — tool-specific tips for Claude, Cursor, ChatGPT and others, and how to get better integration code out of them. * **[Sudomimus CLI for AI agents](/en-us/ai/cli/)** — the command-line control surface for account checks, local automation, and future developer operations. ## Why this works [Section titled “Why this works”](#why-this-works) Everything an integrator needs is in the docs: the choice between Connect, OIDC, and native direct-issue; their protocol flows; the shared identity model; and the three-layer rule model. An assistant with the corpus in context has the same material a human integrator reads — so it can both write the code and answer follow-up questions without re-fetching. # Layer 1 — Authentication rules > Configure which authentication methods an application accepts, and how to narrow the choice on a per-inquiry basis. Part of the three-layer rules model Layer 1 is one of three rule layers. The overview explains allowlist + default-deny, evaluation order, and how the layers compose. [Read the overview ](/en-us/application-rules/overview/) Layer 1 controls **which authentication methods** are usable for an application. It is checked both when the user is offered a list of methods (via `/reason/email`) and again at every actual authentication attempt. ## Supported methods [Section titled “Supported methods”](#supported-methods) | Method | Payload | What it is | | ------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PASSKEY_USERNAMELESS` | `{}` | WebAuthn / FIDO2 passkey via the **discoverable-credential / usernameless** flow. Gates the single “Sign in with a passkey” button shown *before* any email is entered. Both passkey methods sign in with the same passkey the user registered — they differ only in which button surfaces it. See [Usernameless passkey](#usernameless-passkey) below. | | `PASSKEY_REASONED` | `{}` | WebAuthn / FIDO2 passkey via the **email-first** flow. Gates the passkey option offered *after* the user enters their email. | | `EMAIL_VERIFICATION` | `{}` | One-time code sent to the user’s email address. | | `STEAM_TICKET` | `{ "allowedSteamAppIds": number[] }` | One-shot Steam ticket exchange from inside a Steam-distributed game, consumed by `native-api POST /direct-issue/steam-ticket`. The `allowedSteamAppIds` list scopes the rule to specific Steam App IDs. See [Native clients](/en-us/native/overview/) for the end-to-end flow. | | `STEAM_OPENID` | `{}` | Browser-side “Sign in with Steam” button using Steam’s OpenID 2.0 OP. The Steam identity resolved here lives in the **same** per-user identity row as `STEAM_TICKET`, so a user who first signed in through a game can subsequently sign in through the web button (and vice versa). Sudomimus requires no client ID, client secret, or Web API key for this path — verification is keyless via `openid.mode=check_authentication`. | | `ACCESS_KEY_DIRECT` | `{}` | One-shot AccessKey credential login, consumed by `native-api POST /direct-issue/access-key`. See [Native clients](/en-us/native/overview/) for the credential format and end-to-end flow. | | `GOOGLE_OAUTH` | `{}` | Sign-in via Google as an upstream OIDC provider. Sudomimus acts as the OIDC Relying Party. Payload is empty in Phase 1; a future phase will add `allowedHostedDomains` for Google Workspace gating. | | `GITHUB_OAUTH` | `{ "allowedGitHubOrgs": string[] }` | Sign-in via GitHub as an upstream OAuth 2.0 provider (no id\_token; profile + verified-email list fetched from the REST API). `allowedGitHubOrgs` is an exact-match list of GitHub Organization `login` strings (case-insensitive); empty array = no org gating (any GitHub account); non-empty = the user must belong to at least one listed org. The `read:org` scope is requested only when at least one matching rule has a non-empty allowlist — apps without org gating keep the minimal `read:user user:email` consent screen. | | `DISCORD_OAUTH` | `{}` | Sign-in via Discord as an upstream OAuth 2.0 provider (no id\_token; profile + email fetched from `GET /users/@me`). Scopes are `identify email`. An email is treated as verified only when Discord returns both a non-empty `email` and `verified: true` — otherwise the account is created without a verified email on file, so Layer 2 `EMAIL` rules fail-closed (intentional, mirroring Google and GitHub). A future phase will add `allowedDiscordGuilds` for guild gating. | | `BATTLENET_OAUTH` | `{}` | Sign-in via Battle.net as an upstream OIDC-shaped OAuth provider. Battle.net’s `/userinfo` carries only a subject and a BattleTag — **no email** — so the account is created without a verified email on file and Layer 2 `EMAIL` rules fail-closed against a Battle.net-only account (intentional). Battle.net has no per-application gating concept, so the payload is always empty. | | `X_OAUTH` | `{}` | Sign-in via X (formerly Twitter) as an upstream OAuth 2.0 provider. X’s v2 `/2/users/me` exposes **no email**, so — like Battle.net and Steam — the account is created without a verified email on file and Layer 2 `EMAIL` rules fail-closed (intentional). Empty payload; no per-application gating. | | `ENTERPRISE_FEDERATION_APPLICATION_MANAGED` | `{ "connectorAnchor": string }` | ”Sign in with …” via an OIDC identity provider your **own organization** registered as a [federation connector](/en-us/domains-federation/federation-connectors/). The `connectorAnchor` names a connector owned by the application’s organization; one rule renders one button. Login runs through the standard authentication and realize pipeline. See [Sign in with your IdP](/en-us/domains-federation/sign-in-with-your-idp/). | | `ENTERPRISE_FEDERATION_DOMAIN_MANAGED` | `{}` | Opt the application in to accepting **forced-SSO** logins. Empty payload — the connector is resolved at login from the user’s email domain (a verified domain whose owner set an `SSO_ONLY` [login policy](/en-us/domains-federation/domain-login-policy/)), never named in the rule. An application without this rule rejects an SSO-gated user. See [Sign in with your IdP](/en-us/domains-federation/sign-in-with-your-idp/). | See [Native clients](/en-us/native/overview/) for how `STEAM_TICKET` and `ACCESS_KEY_DIRECT` are consumed end-to-end, and the [Domains & federation](/en-us/domains-federation/overview/) section for the two enterprise-federation methods. ## Application rule shape [Section titled “Application rule shape”](#application-rule-shape) Every Layer 1 rule on an application records one method. To allow multiple methods, create multiple rules. ```json { "method": "PASSKEY_REASONED", "payload": {}, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` For `STEAM_TICKET`, the payload carries the App ID allowlist: ```json { "method": "STEAM_TICKET", "payload": { "allowedSteamAppIds": [480, 730] }, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` The two TTL fields are optional. When present they participate in the [MIN fold](/en-us/application-rules/overview/#token-ttls) at token-issuance time. ## Narrowing on `/establish` [Section titled “Narrowing on /establish”](#narrowing-on-establish) The `authenticationConstraints` field on `/establish` carries the same shape and narrows the choice for a single inquiry: ```json { "applicationAnchor": "my-app", "authenticationConstraints": [ { "method": "PASSKEY_REASONED", "payload": {} } ] } ``` * Field absent → no narrowing; the application’s rules alone decide. * Field present and empty array → rejected. * Field present and non-empty → AND-combined with the application’s rules. ## Worked example [Section titled “Worked example”](#worked-example) An application has two Layer 1 rules: `PASSKEY_REASONED` and `EMAIL_VERIFICATION`. A particular admin inquiry passes `authenticationConstraints: [{ "method": "PASSKEY_REASONED", "payload": {} }]`. | Method | App allows? | Inquiry allows? | Result | | -------------------- | ----------- | --------------- | ------- | | `PASSKEY_REASONED` | yes | yes | offered | | `EMAIL_VERIFICATION` | yes | no | hidden | The user only sees passkey as an option for this session, even though the application itself would normally accept email too. ## Usernameless passkey [Section titled “Usernameless passkey”](#usernameless-passkey) Passkey sign-in splits into two **separate, independent** Layer-1 methods rather than a single rule with a flag: * `PASSKEY_REASONED` is the **email-first** flow: the user types their email, `/reason/email` resolves their account, and they then prove possession of a registered credential. * `PASSKEY_USERNAMELESS` is the **discoverable-credential** flow: a single “Sign in with a passkey” button rendered at the top of the auth UI, before the email field. The user taps it, the browser shows its native passkey picker, the user picks a credential and verifies (biometric or PIN), and they are signed in without ever typing an email. A “usernameless-only” application — one with **no** email box offering a passkey — is expressed simply by allowing only `PASSKEY_USERNAMELESS`: ```json { "method": "PASSKEY_USERNAMELESS", "payload": {} } ``` Notes: * The two methods are independent rules with empty payloads. List `PASSKEY_REASONED` for the email-first option, `PASSKEY_USERNAMELESS` for the standalone button, or both. There is no longer an `allowUsernameless` flag. * Both methods resolve to the same shared `PASSKEY` credential row, so a credential registered through one flow is usable by the other. * Per-inquiry `authenticationConstraints` can carry `PASSKEY_USERNAMELESS` to narrow a single inquiry to the discoverable-credential button (AND-combined with the application’s rules, like any other method). * The discoverable-credential flow requires the authenticator to set the User Verified (UV) flag (biometric / PIN). Usernameless login without user verification is rejected — there is no typed email to act as a second factor. * Passkey registration itself is unaffected by these rules: users still register a passkey through the normal post-email-verification flow. `PASSKEY_USERNAMELESS` only controls whether the standalone button is offered for **sign-in**. ## Related [Section titled “Related”](#related) [The three-layer rules model ](/en-us/application-rules/overview/)The overall picture — allowlist + default-deny, evaluation order, and how the three layers compose. [Layer 2 — Realize rules ](/en-us/application-rules/realize-rules/)The post-authentication identity check, using email allowlists. [Layer 3 — Return rules ](/en-us/application-rules/return-rules/)How the realized session is delivered back to the application. # The three-layer rules model > How Sudomimus splits application authorization into three orthogonal layers — which methods, which identities, which return paths — each allowlist-only with default-deny. Every application on Sudomimus is gated by three independent layers of rules. Each layer answers a different question, lives in its own configuration, and is evaluated at a different point in the authentication flow. None of them have implicit defaults: a layer with zero rules allows nothing. ## The three layers [Section titled “The three layers”](#the-three-layers) | Layer | Question it answers | Checked when | | ---------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------- | | **Layer 1 — Authentication** | Which authentication methods may be used? | A method is offered to the user, and again at every attempt. | | **Layer 2 — Realize** | Which identities may complete authentication? | Post-authentication, before the inquiry is marked realized. | | **Layer 3 — Return** | How the result is delivered back to the application? | At `/establish` (against declared return methods) and at runtime (e.g. `/status-poll`). | Splitting the decision this way means each axis can change independently — opening a new authentication method does not silently widen who can sign in, and tightening the allowed callers does not require touching authentication configuration. ## Allowlist with default-deny [Section titled “Allowlist with default-deny”](#allowlist-with-default-deny) Every layer is **allowlist-only**. A new application starts with zero rules in all three layers and cannot be used until rules are explicitly created. There is no implicit “allow everything” mode, and removing the last rule from a layer disables the application — it does not fall back to a default. This is deliberate: an authentication system that defaults to *allow* tends to leak access whenever the surrounding configuration is misunderstood. Defaulting to *deny* means a misconfiguration produces a visible failure rather than a silent breach. ## Per-inquiry narrowing [Section titled “Per-inquiry narrowing”](#per-inquiry-narrowing) Application-level rules describe what the application *as a whole* is willing to allow. A single login session is often more specific — a sensitive admin flow may want passkey only; a tenant-scoped flow may want a single email allowlist. The `/establish` request therefore accepts three optional narrowing fields: | Field on `/establish` | Narrows | | --------------------------- | ------- | | `authenticationConstraints` | Layer 1 | | `realizeConstraints` | Layer 2 | | `returnMethods` | Layer 3 | Each field’s shape mirrors the corresponding rule shape, so the same vocabulary is used in both places. * **Field absent** — no narrowing for that layer; the application’s rules alone decide. * **Field present and empty array** — rejected. An empty narrowing would mean “allow nothing”, which is what removing the rules from the application already expresses. * **Field present and non-empty** — every entry is structurally validated and stored on the inquiry. At evaluation time it is **AND**-combined with the application’s rules. ## OR within, AND across [Section titled “OR within, AND across”](#or-within-and-across) When multiple records could apply at the same evaluation point, the combiner is: * **OR within a single layer and a single source** — multiple matching application rules in Layer 1, for example, all stack: any match passes. * **AND across layers, and AND across (application rules, inquiry constraints)** — Layer 1, Layer 2, and Layer 3 must each pass, and within each layer both the application rules and the (optional) inquiry constraints must allow it. The result is that narrowing on an inquiry can only further restrict — it can never grant something the application itself did not allow. ## Token TTLs [Section titled “Token TTLs”](#token-ttls) Every rule and every per-inquiry constraint can optionally carry `accessTokenTtlSeconds` and `refreshTokenTtlSeconds`. The Connect API computes the final TTLs by collecting all matching values across every layer and source and taking the **minimum** — the strictest setting wins. Defaults are **3 hours** for access and **30 days** for refresh. The refresh TTL is always raised to be at least the access TTL. The full bounds (60 seconds – 7 days for access; 1 day – 365 days for refresh) are in [Tokens and verification](/en-us/concepts/tokens-and-verification/#ttl-bounds). ## Where to go next [Section titled “Where to go next”](#where-to-go-next) Each layer has its own page with the per-layer schema, glob/match semantics, and a worked example: [Layer 1 — Authentication rules ](/en-us/application-rules/authentication-rules/)Which authentication methods an application accepts, and how to narrow them per inquiry. [Layer 2 — Realize rules ](/en-us/application-rules/realize-rules/)Which identities may complete authentication — by email (glob), Steam ID, account alias (exact), or sector subject (exact). [Layer 3 — Return rules ](/en-us/application-rules/return-rules/)How the result is delivered — callbacks, status polling, REVEAL, DIRECT\_ISSUE, and OIDC. [Start with Layer 1 ](/en-us/application-rules/authentication-rules/) # Layer 2 — Realize rules > Configure which identities are allowed to complete authentication for an application — by email (with `*` glob), Steam ID, account alias, or sector subject. Part of the three-layer rules model Layer 2 is one of three rule layers. The overview explains allowlist + default-deny, evaluation order, and how the layers compose. [Read the overview ](/en-us/application-rules/overview/) Layer 2 controls **which identities** may complete authentication for an application. It runs **after** the user has successfully proven they own an identity but **before** the inquiry is marked realized. The reason this is a separate layer: authenticating that a user is `alice@example.com` and *deciding whether the application is willing to let `alice@example.com` in* are different questions. Layer 1 answers the first, Layer 2 answers the second. ## Supported constraint types [Section titled “Supported constraint types”](#supported-constraint-types) Layer 2 has five constraint types. An application may mix them — within the layer, rules are OR’d, so any matching rule allows the realize. | Type | Payload | Match semantics | | ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `EMAIL` | `{ "allowedEmails": string[] }` | Case-insensitive glob with `*` | | `STEAM_ID` | `{ "allowedSteamIds": (string \| "*")[] }` | Exact match on decimal SteamID64, or literal `"*"` wildcard | | `ACCOUNT_ALIAS` | `{ "allowedAccountAliases": string[] }` | Exact match on the realizing account’s **account alias** (the user-visible, application-invisible, rotatable handle) — **no wildcard** | | `SECTOR_SUBJECT` | `{ "allowedSectorSubjects": string[] }` | Exact match on the realizing account’s **sector subject** for the application’s sector (the application-visible token `sub`) — **no wildcard** | | `EVERYONE` | `{}` | Unconditional allow — matches every authenticated account, including one with no verified email. Empty payload. | In every case, the matched value comes from the realizing account/session: * `EMAIL` is checked against **every verified email the account owns** — the full set of `EmailIdentity` rows, not just the email used at this login. The rule matches if **any one** of those emails satisfies the allowlist. For email-OTP register where the account does not yet exist, the matching is against the single email being registered. * `STEAM_ID` is checked against the verified SteamID64 for **either** Steam path — the native `STEAM_TICKET` direct-issue from inside a game, or the browser `STEAM_OPENID` “Sign in with Steam” button. Both paths resolve to the same per-user Steam identity. Steam-first accounts that have never linked an email need a `STEAM_ID` (or `ACCOUNT_ALIAS` / `SECTOR_SUBJECT`) rule to pass; an `EMAIL`-only Layer 2 will reject them. * `ACCOUNT_ALIAS` is checked against the account’s **account alias** — the rotatable, user-facing handle a user sees and manages in the With portal (e.g. `quiet-meadow-7h2k-9m4p-3fnp-falcon`). It is never exposed to applications, so it is the value a rule author allow-lists to pin a specific human regardless of which application they realize through. * `SECTOR_SUBJECT` is checked against the account’s **sector subject** — the per-(account × sector) opaque token that an application actually sees as the token `sub` (e.g. `sub_9SQ5535CRWNDDM2T`). Applications sharing a sector see the same subject for a given user; applications in different sectors see different subjects. Use it to allow-list users by the exact identifier this application receives. Both `ACCOUNT_ALIAS` and `SECTOR_SUBJECT` are opaque values matched **exactly** (never format-validated) and have **no wildcard**. Because both are minted only after the account (and its per-sector subject) exists, a fresh registration produces values nobody could have pre-allowlisted — an application whose Layer 2 contains **only** `ACCOUNT_ALIAS` or `SECTOR_SUBJECT` rules cannot accept new sign-ups. They are meant for locking access to a fixed set of already-existing accounts. Both values are rotatable; rotating one invalidates any rule that allow-listed the old value. ## EMAIL glob semantics [Section titled “EMAIL glob semantics”](#email-glob-semantics) `allowedEmails` entries are matched as case-insensitive globs where **only `*` is special** (it matches zero or more characters). All other characters, including `@`, `.`, and `+`, are literal. | Pattern | Matches | | --------------------- | ---------------------------------------------- | | `alice@example.com` | exact email | | `*@example.com` | any address on the `example.com` domain | | `alice+*@example.com` | any plus-tagged variant of `alice@example.com` | | `*` | any email | Inputs are trimmed and lowercased before comparison. ## STEAM\_ID semantics [Section titled “STEAM\_ID semantics”](#steam_id-semantics) Each entry is either the literal `"*"` (matches any verified SteamID64) or a decimal SteamID64 string (exact case-sensitive match). The `[0-9]{1,20}` shape is enforced at write time. ```json { "constraintType": "STEAM_ID", "payload": { "allowedSteamIds": ["76561198000000000", "76561198000000001"] } } ``` ## ACCOUNT\_ALIAS semantics [Section titled “ACCOUNT\_ALIAS semantics”](#account_alias-semantics) Each entry is an exact string equal to the target account’s **account alias** — the rotatable, user-facing handle (e.g. `quiet-meadow-7h2k-9m4p-3fnp-falcon`). There is no wildcard and no glob; the value is opaque and compared literally. ```json { "constraintType": "ACCOUNT_ALIAS", "payload": { "allowedAccountAliases": [ "quiet-meadow-7h2k-9m4p-3fnp-falcon", "bold-harbor-2x4q-8m1p-5kna-otter" ] } } ``` The alias is the With-portal handle a user manages for themselves — it is never disclosed to applications, so it is the right key when you want to pin a specific human across every application they realize through. A user who rotates their alias drops out of any rule that allow-listed the old value. ## SECTOR\_SUBJECT semantics [Section titled “SECTOR\_SUBJECT semantics”](#sector_subject-semantics) Each entry is an exact string equal to the target account’s **sector subject** for this application’s sector — the application-visible token `sub` (e.g. `sub_9SQ5535CRWNDDM2T`). There is no wildcard and no glob; the value is opaque and compared literally. ```json { "constraintType": "SECTOR_SUBJECT", "payload": { "allowedSectorSubjects": [ "sub_9SQ5535CRWNDDM2T", "sub_4K2P8M1N7QRWXY3Z" ] } } ``` The sector subject is the exact identifier this application (and any sibling application sharing its sector) receives for a user; an application in a different sector sees a different subject for the same human. Use it to allow-list by the value you already key your users on. Rotating a user’s sector subject drops them out of any rule that allow-listed the old value. Both are literal allowlists with **no wildcard**, so combine either with an `EMAIL` or `STEAM_ID` rule when the flow should also let new users sign up. ## EVERYONE semantics [Section titled “EVERYONE semantics”](#everyone-semantics) `EVERYONE` is the explicit “this application is open to anyone” rule. It carries an empty payload and matches **every** authenticated account unconditionally. ```json { "constraintType": "EVERYONE", "payload": {} } ``` This does not weaken the allowlist-with-default-deny model — it is an explicit opt-in. An application with no Layer 2 rules still rejects everyone; `EVERYONE` is how you say “any account that cleared Layer 1 may realize.” It is the **only** Layer 2 type that admits an account with **no verified email** (a Steam-only or Battle.net-only user, for example), so it is the right choice for a genuinely public application that should not gate on identity at all. Add it alongside other rules and the OR semantics make it dominant — any account matches — so use it deliberately. ## Application rule shape [Section titled “Application rule shape”](#application-rule-shape) Every Layer 2 rule has the same envelope; only the `constraintType` + `payload` shape differs. ```json { "constraintType": "EMAIL", "payload": { "allowedEmails": ["*@example.com"] }, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` Multiple rules on an application are OR’d: an identity that matches any rule is allowed through. ## Narrowing on `/establish` [Section titled “Narrowing on /establish”](#narrowing-on-establish) The `realizeConstraints` field on `/establish` carries the same shape and narrows the allowed identities for a single inquiry: ```json { "applicationAnchor": "my-app", "realizeConstraints": [ { "constraintType": "EMAIL", "payload": { "allowedEmails": ["admin@example.com"] } } ] } ``` * Field absent → no narrowing; the application’s rules alone decide. * Field present and empty array → rejected. * Field present and non-empty → AND-combined with the application’s rules. The inner payload list must itself be non-empty. ## Worked example [Section titled “Worked example”](#worked-example) The application has one Layer 2 rule: `EMAIL` with `allowedEmails: ["*@example.com"]`. A particular admin inquiry passes `realizeConstraints: [{ constraintType: "EMAIL", payload: { allowedEmails: ["admin@example.com"] } }]`. | User authenticates as | App allows? | Inquiry allows? | Result | | --------------------- | ----------------------------- | --------------- | ------------------ | | `admin@example.com` | yes (matches `*@example.com`) | yes | realized | | `alice@example.com` | yes (matches `*@example.com`) | no | rejected post-auth | | `attacker@other.com` | no | n/a | rejected post-auth | Note that Layer 2 runs **after** Layer 1 — the user has already proven they own the address. Layer 2 then decides whether the application is willing to accept that specific identity for this session. ## Related [Section titled “Related”](#related) [The three-layer rules model ](/en-us/application-rules/overview/)The overall picture — allowlist + default-deny, evaluation order, and how the three layers compose. [Layer 1 — Authentication rules ](/en-us/application-rules/authentication-rules/)Which authentication methods are offered to the user. [Layer 3 — Return rules ](/en-us/application-rules/return-rules/)How the realized session is delivered back to the application. # Layer 3 — Return rules > Configure how authentication results reach the application — browser callbacks, native status polling, one-time REVEAL, native direct-issue, or OIDC. Part of the three-layer rules model Layer 3 is one of three rule layers. The overview explains allowlist + default-deny, evaluation order, and how the layers compose. [Read the overview ](/en-us/application-rules/overview/) Layer 3 controls **how an authentication result reaches the application**. It is checked twice: once at `/establish` (against each return method declared on the request) and again at runtime when the chosen method actually runs (for example on `/status-poll`, or inside the OIDC `/token` exchange). ## Supported return methods [Section titled “Supported return methods”](#supported-return-methods) | Method | Payload (per inquiry) | Used for | | -------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CALLBACK` | `{ "callbackUrl": "https://..." }` | Web applications — the browser is redirected to a URL on the application after authentication. | | `STATUS_POLL` | `{}` | Native clients — the client polls `connect POST /status-poll` and is told when the inquiry is realized. | | `REVEAL` | `{}` | Developer / CLI / manual integration — the access and/or refresh tokens are shown directly on `via.sudomimus.com` after login (masked, with a “Reveal” button) so the user can copy them out by hand. No application sits on the other end. | | `DIRECT_ISSUE` | `{}` | Opts the application in to one-shot `native-api` issuance — Steam ticket (`POST /direct-issue/steam-ticket`) or AccessKey (`POST /direct-issue/access-key`). Tokens are minted in the same request that authenticates; no `/establish` or `/redeem` is involved. | | `OIDC` | *(not declared per inquiry)* | Enables this application to be used as an OIDC relying party via `oidc.sudomimus.com`. Required for the OIDC `authorization_code` + PKCE flow. | ## CALLBACK — application rule shape [Section titled “CALLBACK — application rule shape”](#callback--application-rule-shape) A `CALLBACK` rule on the application carries the **allowed hostnames** for the callback URL — not the URL itself. The actual URL is supplied per-inquiry on `/establish`; the rule decides whether that URL’s hostname is acceptable. ```json { "returnMethod": "CALLBACK", "payload": { "allowedCallbackDomains": ["client.example.com", "admin.example.com"] }, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` Hostname comparison is **exact, case-insensitive** — `client.example.com` does not implicitly cover `sub.client.example.com`. To allow both, list both. ## STATUS\_POLL — application rule shape [Section titled “STATUS\_POLL — application rule shape”](#status_poll--application-rule-shape) `STATUS_POLL` rules have no payload — having the rule on the application is the whole configuration. ```json { "returnMethod": "STATUS_POLL", "payload": {}, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` ## REVEAL — application rule shape [Section titled “REVEAL — application rule shape”](#reveal--application-rule-shape) `REVEAL` rules configure **which tokens are shown to the user** after a successful login. At least one of `includeAccessToken` and `includeRefreshToken` must be `true`. ```json { "returnMethod": "REVEAL", "payload": { "includeAccessToken": true, "includeRefreshToken": true }, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` When an inquiry declares `REVEAL`, Sudomimus signs the tokens at realize time and returns them inline; the inquiry is marked redeemed immediately, so any subsequent `/redeem` call for the same login fails with `InquiryAlreadyRedeemed`. Multiple matching `REVEAL` rules are combined with **OR semantics** — if any matching rule allows the access token, it is included; same for the refresh token. One-shot, no recovery Tokens shown via REVEAL are not stored anywhere visible to the user; they’re displayed once on the post-login screen and never re-sent. The user must copy them before navigating away. REVEAL is intended for developer tooling, CLI integration, or one-off manual binding — not for production end-user authentication. ### Combining REVEAL with other methods [Section titled “Combining REVEAL with other methods”](#combining-reveal-with-other-methods) REVEAL takes precedence on the `via.sudomimus.com` page: it suppresses the automatic CALLBACK redirect so the user can copy the tokens first. The user then sees a “Continue to app” button if a CALLBACK was also declared. STATUS\_POLL still signals `realized` to a polling client, but the subsequent `/redeem` will fail because REVEAL has already redeemed the inquiry. ## DIRECT\_ISSUE — application rule shape [Section titled “DIRECT\_ISSUE — application rule shape”](#direct_issue--application-rule-shape) `DIRECT_ISSUE` rules have no payload — the rule’s presence is the entire configuration. Add this rule to opt an application in to the `native-api` direct-issue endpoints (Steam ticket and AccessKey). ```json { "returnMethod": "DIRECT_ISSUE", "payload": {}, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` `DIRECT_ISSUE` does not appear in `/establish` `returnMethods` declarations — those endpoints have no `/establish` step. Layer 3 is still checked at runtime inside the `native-api` handlers. ## OIDC — application rule shape [Section titled “OIDC — application rule shape”](#oidc--application-rule-shape) `OIDC` rules opt the application in to being used as an OpenID Connect relying party via `oidc.sudomimus.com`. The payload carries the standard OIDC client configuration: ```json { "returnMethod": "OIDC", "payload": { "redirectUris": ["https://app.example.com/oidc/callback"], "postLogoutRedirectUris": ["https://app.example.com/"], "allowedScopes": ["openid", "email", "profile", "offline_access"], "tokenEndpointAuthMethod": "private_key_jwt" }, "accessTokenTtlSeconds": null, "refreshTokenTtlSeconds": null } ``` * **`redirectUris`** — full-URI **exact match**. The `redirect_uri` parameter the RP sends to `/authorize` must equal one of these strings byte-for-byte. No prefix or wildcard matching. * **`postLogoutRedirectUris`** — full-URI exact match, used by `/end-session`. * **`allowedScopes`** — the set of OIDC scopes this client is permitted to request. `openid` is always required; `email`, `profile`, and `offline_access` are supported. Requesting a scope outside this list fails at `/authorize`. * **`tokenEndpointAuthMethod`** — one of `"private_key_jwt"` (confidential client; signs a JWT assertion to authenticate at `/token`), `"client_secret_basic"` (confidential client; presents a shared secret in the HTTP `Authorization: Basic` header), `"client_secret_post"` (confidential client; sends a shared secret in the `/token` form body), or `"none"` (public client; **PKCE is required** for these). OIDC rules are not declared per-inquiry on `/establish` — the OIDC flow has its own `/authorize` endpoint instead. End-to-end usage and library setup is in [OIDC relying parties](/en-us/oidc/flow/). ## Declaring on `/establish` [Section titled “Declaring on /establish”](#declaring-on-establish) The `returnMethods` field on `/establish` plays two roles at once: it declares which methods will be used for this inquiry **and** it carries the concrete `callbackUrl` (which the application’s rules cannot know in advance). ```json { "applicationAnchor": "my-app", "returnMethods": [ { "type": "CALLBACK", "payload": { "callbackUrl": "https://client.example.com/auth/return" } }, { "type": "STATUS_POLL", "payload": {} }, { "type": "REVEAL", "payload": {} } ] } ``` `DIRECT_ISSUE` and `OIDC` are not declared on `/establish` — those flows do not pass through it. * Field absent → no Layer 3 narrowing; the application’s rules still apply at runtime. * Field present and empty array → rejected. * Field present and non-empty → every entry is validated against the application’s Layer 3 rules at `/establish` time. For `CALLBACK`, the URL’s hostname must match an entry in some Layer 3 `CALLBACK` rule’s `allowedCallbackDomains`. For `STATUS_POLL` and `REVEAL`, a Layer 3 rule of the same method must exist on the application. ## Worked example [Section titled “Worked example”](#worked-example) The application has one Layer 3 rule: `CALLBACK` with `allowedCallbackDomains: ["client.example.com"]`. | Inquiry `callbackUrl` | Result | | --------------------------------------------------- | -------------------------------------- | | `https://client.example.com/return` | accepted | | `https://Client.Example.Com/return` | accepted (case-insensitive) | | `https://sub.client.example.com/return` | rejected (no implicit subdomain match) | | `https://attacker.com/?redirect=client.example.com` | rejected (hostname is `attacker.com`) | For `STATUS_POLL`, the runtime check on `/status-poll` re-verifies both that the application still has a `STATUS_POLL` rule and that the inquiry’s per-inquiry narrowing (if any) still allows it. ## Why hostname-only (for CALLBACK) [Section titled “Why hostname-only (for CALLBACK)”](#why-hostname-only-for-callback) The check matches only the hostname, not the path or query string. Sudomimus does not own the application’s URL structure and would create a maintenance burden by requiring exact-path allowlists. Application owners are expected to keep their callback handlers safe at the routing level — Sudomimus restricts the *origin* the browser is sent to. (OIDC `redirectUris` are a different model — they require full-URI exact match because the OIDC standard demands it and because most OIDC libraries already produce a single fixed redirect URI per client.) ## Related [Section titled “Related”](#related) [The three-layer rules model ](/en-us/application-rules/overview/)The overall picture — allowlist + default-deny, evaluation order, and how the three layers compose. [Layer 1 — Authentication rules ](/en-us/application-rules/authentication-rules/)Which authentication methods are offered to the user. [Layer 2 — Realize rules ](/en-us/application-rules/realize-rules/)The post-authentication identity check, using email allowlists. [OIDC relying parties ](/en-us/oidc/flow/)End-to-end OIDC flow that uses the OIDC return rule. # Configuration templates > Copy-and-adapt starting points for the three-layer rules of common application types — web apps, internal tools, Steam games, CLIs, OIDC relying parties, and public apps. The three layers are flexible, but most applications start from one of a handful of shapes. Pick the closest template below, drop the rules into your application in the [With portal](https://with.sudomimus.com), then adjust. Every layer needs at least one rule The model is **allowlist with default-deny**: an application with an empty layer lets **no one** through. A working application needs at least one rule in **each** of the three layers — Authentication, Realize, and Return. If logins are rejected with everything “configured”, check that all three layers are non-empty. See the [overview](/en-us/application-rules/overview/) for how the layers compose. In the tables below, `*.example.com` style values are placeholders — replace them with your own. Each layer can hold more than one rule; within a layer, rules are OR’d. ## Standard web app (passwordless) [Section titled “Standard web app (passwordless)”](#standard-web-app-passwordless) Passkeys plus email one-time codes, open sign-up, redirect back to your site. The most common starting point. | Layer | Rules | | ---------------------- | ---------------------------------------------------------------- | | **1 — Authentication** | `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, `EMAIL_VERIFICATION` | | **2 — Realize** | `EMAIL` → `{ "allowedEmails": ["*"] }` | | **3 — Return** | `CALLBACK` → `{ "allowedCallbackDomains": ["app.example.com"] }` | ## Web app with social sign-in [Section titled “Web app with social sign-in”](#web-app-with-social-sign-in) The passwordless template plus “Sign in with …” buttons. Add only the providers you want; each is one Layer 1 rule. | Layer | Rules | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | **1 — Authentication** | `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, `EMAIL_VERIFICATION`, `GOOGLE_OAUTH`, `GITHUB_OAUTH`, `DISCORD_OAUTH` | | **2 — Realize** | `EMAIL` → `{ "allowedEmails": ["*"] }` | | **3 — Return** | `CALLBACK` → `{ "allowedCallbackDomains": ["app.example.com"] }` | Note that Steam-, Battle.net-, and X-only accounts have no verified email, so an `EMAIL`-only Layer 2 rejects them. If you offer those providers, pair the `EMAIL` rule with `STEAM_ID` / `EVERYONE` as appropriate (see the public-app template below). ## Internal / team tool (domain-restricted) [Section titled “Internal / team tool (domain-restricted)”](#internal--team-tool-domain-restricted) Only people with an email on your company’s domain may sign in. | Layer | Rules | | ---------------------- | --------------------------------------------------------------------- | | **1 — Authentication** | `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, `EMAIL_VERIFICATION` | | **2 — Realize** | `EMAIL` → `{ "allowedEmails": ["*@yourcompany.com"] }` | | **3 — Return** | `CALLBACK` → `{ "allowedCallbackDomains": ["tool.yourcompany.com"] }` | To force those users through your own identity provider instead of (or in addition to) the gate above, see [Sign in with your IdP](/en-us/domains-federation/sign-in-with-your-idp/) and [Domain login policy](/en-us/domains-federation/domain-login-policy/). ## Steam game (silent, in-game login) [Section titled “Steam game (silent, in-game login)”](#steam-game-silent-in-game-login) A game shipped through Steam that logs the player in without a browser. | Layer | Rules | | ---------------------- | -------------------------------------------------- | | **1 — Authentication** | `STEAM_TICKET` → `{ "allowedSteamAppIds": [480] }` | | **2 — Realize** | `STEAM_ID` → `{ "allowedSteamIds": ["*"] }` | | **3 — Return** | `DIRECT_ISSUE` | Replace `480` with your real Steam App ID. To let the same players also sign in through a browser “Sign in with Steam” button, add a `STEAM_OPENID` Layer 1 rule — it resolves to the same Steam identity. End-to-end flow: [Native clients](/en-us/native/overview/). ## CLI / headless service (AccessKey) [Section titled “CLI / headless service (AccessKey)”](#cli--headless-service-accesskey) A command-line tool or service that authenticates as a known, already-existing account with a pre-issued credential. | Layer | Rules | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | **1 — Authentication** | `ACCESS_KEY_DIRECT` | | **2 — Realize** | `SECTOR_SUBJECT` → `{ "allowedSectorSubjects": ["sub_..."] }` (or an `EMAIL` rule that matches the bound account) | | **3 — Return** | `DIRECT_ISSUE` | AccessKeys cannot create new accounts — they always act as a specific existing account, so Layer 2 pins that account. See [Native clients](/en-us/native/overview/). ## OIDC relying party [Section titled “OIDC relying party”](#oidc-relying-party) Expose the application to a standard OpenID Connect client (`authorization_code` + PKCE). | Layer | Rules | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **1 — Authentication** | `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, `EMAIL_VERIFICATION` (plus any social providers) | | **2 — Realize** | `EMAIL` → `{ "allowedEmails": ["*"] }` | | **3 — Return** | `OIDC` → `{ "redirectUris": ["https://app.example.com/oidc/callback"], "allowedScopes": ["openid", "email", "profile", "offline_access"], "tokenEndpointAuthMethod": "private_key_jwt" }` | Use `"tokenEndpointAuthMethod": "none"` for a public client (PKCE is required either way). End-to-end setup: [OIDC relying parties](/en-us/oidc/flow/). ## Desktop app (browser polling) [Section titled “Desktop app (browser polling)”](#desktop-app-browser-polling) A desktop or Electron app that opens the system browser for login, then polls for the result. | Layer | Rules | | ---------------------- | ---------------------------------------------------------------- | | **1 — Authentication** | `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, `EMAIL_VERIFICATION` | | **2 — Realize** | `EMAIL` → `{ "allowedEmails": ["*"] }` | | **3 — Return** | `STATUS_POLL` | See the [browser-polling flow](/en-us/native/overview/) for the `/establish` → `/status-poll` → `/redeem` sequence. ## Genuinely public app (no identity gate) [Section titled “Genuinely public app (no identity gate)”](#genuinely-public-app-no-identity-gate) Anyone who can clear Layer 1 is allowed in — including accounts with no verified email (Steam-only, Battle.net-only). Use this when you do not want to restrict *who* can sign in at all. | Layer | Rules | | ---------------------- | -------------------------------------------------------------------------------------------------- | | **1 — Authentication** | whichever methods you offer (e.g. `PASSKEY_USERNAMELESS`, `EMAIL_VERIFICATION`, `STEAM_TICKET`, …) | | **2 — Realize** | `EVERYONE` | | **3 — Return** | `CALLBACK` / `STATUS_POLL` / `DIRECT_ISSUE` / `OIDC` — whatever your client uses | `EVERYONE` is unconditional, so it dominates any other Layer 2 rule via the OR semantics. Use it deliberately — see [Realize rules](/en-us/application-rules/realize-rules/). ## After you pick a template [Section titled “After you pick a template”](#after-you-pick-a-template) * **Narrow per request.** `/establish` can tighten any layer for a single inquiry via `authenticationConstraints` / `realizeConstraints` / `returnMethods` — useful for, say, an admin-only login link. See the [overview](/en-us/application-rules/overview/). * **Decide what profile data you request.** Whether the app receives the user’s email or name is a separate setting — see [Identity claims and sharing](/en-us/concepts/identity-claims/). * **Set token lifetimes.** Each rule can override the access- and refresh-token TTLs; leave them `null` to inherit the platform defaults. # Accounts and credentials > How Sudomimus separates the person, their sign-in credentials, and their verified email ownership. Sudomimus keeps account identity, authentication credentials, and email ownership in separate records. This model is shared by Connect, OIDC, and native direct-issue. | Record | What it represents | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | **Account** | The person: a stable internal record with profile data. It does not carry an email field. | | **Authentication** | One credential the account can use, such as an email OTP login, passkey, Steam identity, OAuth identity, or enterprise federation identity. | | **EmailIdentity** | A verified email the account owns. Each row records how ownership was verified, and at most one is primary. | ## Why they are separate [Section titled “Why they are separate”](#why-they-are-separate) A credential proves **how this person can sign in**. An email identity proves **which email this person owns**. Those are related, but they are not the same fact. For example: * An email-OTP registration creates both an email credential and a verified email identity. * A Google, GitHub, or Discord login can establish verified email ownership without enrolling email OTP as a login method. * A Steam-only account can sign in without owning any verified email. * Removing a login method does not, by itself, rewrite the account’s email ownership history. This separation lets the platform evaluate login methods, email-based access rules, and token claims without treating one credential table as the source of truth for everything. ## What applications see [Section titled “What applications see”](#what-applications-see) The internal account identifier never leaves Sudomimus. Applications receive a purpose-scoped [sector subject](/en-us/concepts/pairwise-identity/) and only the [identity claims](/en-us/concepts/identity-claims/) that policy and user consent allow. # Identity claims and sharing > How Sudomimus shares a user's email, first name, and last name with an application — the developer-set claim policy, the user-controlled grant, and how scopes gate what ends up in a token. Beyond a stable identifier, an application often wants a little profile data — the user’s **email**, **first name**, or **last name**. Sudomimus treats each of these as a **claim** that is shared only with explicit agreement on both sides. Each of the three claims is gated independently, and sharing has two halves: * **The claim policy** — set by the *developer*, per application: which claims the application requests, and how strongly. * **The claim grant** — set by the *user*, per application: which claims they have agreed to share. A claim lands in a token only when the policy requests it **and** the user has granted it. For OIDC, the relevant scope must also be requested. The user stays in control, and a revoked claim stops being shared on the very next token. ## The claim policy (developer) [Section titled “The claim policy (developer)”](#the-claim-policy-developer) On your application’s detail page in the [With portal](https://with.sudomimus.com), you set each of the three claims to one of: | Policy | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Off** | Never requested. The claim is never shared, regardless of what the user would allow. | | **Optional** | Requested, but the user may decline. If they decline, the application simply does not receive it. | | **Required** | The application needs it. The user must grant it to finish logging in. | | **Synthetic** | Always provided — but the value may be a generated placeholder. The user sees it like Optional; if they decline (or the account holds no real data), the application receives a stand-in (a placeholder name, a proxy `…@proxy.sudomimus.email` address) instead of nothing. It never blocks login and never raises an errand. | An application created before you configure a policy requests **nothing** — there is no silent backfill. You opt into each claim deliberately. ## The user’s grant [Section titled “The user’s grant”](#the-users-grant) The first time a user logs in to an application that requests claims, Sudomimus shows a **consent screen**: * **Required** claims are shown as locked-on — granting them is part of completing the login. * **Optional** claims are shown as checkboxes, **unchecked by default** — the user opts in. * **Synthetic** claims are shown like optional checkboxes too, with one difference disclosed in the copy: leaving one unchecked sends the application a placeholder value rather than nothing. Their decision is remembered as one of three states — **granted**, **denied**, or **not yet decided** — so a declined optional claim is not nagged about again, while a claim they have never seen is asked about on the next interactive login. A user can review and change every decision in the **Data sharing** view of their account portal: it lists each application they share claims with, whether each claim is currently shared, and whether the application requires it, with a **Revoke** action per application. Because grants are read live at every token issue, revoking takes effect immediately — the next token simply omits the claim. ## When a claim actually appears in a token [Section titled “When a claim actually appears in a token”](#when-a-claim-actually-appears-in-a-token) For a given claim, the rule is: > the policy is **not Off**, **and** the user has **granted** it — **and**, for OIDC, the matching scope was requested. The OIDC scope gate maps as follows: * `email` scope → the **email** claim * `profile` scope → **first name** and **last name** Non-OIDC flows (Connect redeem/refresh, native direct-issue) have no scope gate — policy + grant alone decide. A **synthetic** claim is the exception to “granted or omitted”: it is *always* present. When the user has granted real data the token carries that; otherwise it carries a stable, per-application placeholder (a generated name, a `…@proxy.sudomimus.email` proxy address). So a synthetic claim never blocks a login and is never omitted — the application is just told, for an OIDC email, that the address is unverified. ## Required claims and non-interactive logins [Section titled “Required claims and non-interactive logins”](#required-claims-and-non-interactive-logins) A **required** claim can only be satisfied by an interactive grant — and, where it is a data claim like email, by the account actually *having* that data. So the **non-interactive** issue points reject any login that would mint a token missing a required claim, with one of two reasons: * `ClaimConsentRequired` — the user has not granted a required claim. * `RequiredClaimDataMissing` — the user *has* granted it, but the account lacks the underlying data (e.g. a Steam account with no email). This guarantee — that a required claim is **always present** in a minted token, never silently dropped — is what the rejection protects. It covers native direct-issue, token refresh, and the OIDC token endpoint. How the user clears it depends on the client: * **Native clients** (Steam / AccessKey direct-issue) have no interactive login *to the application*, so the `403` carries an **[Errand](/en-us/native/claims-and-errand/)** — a browser side-trip where the user signs in (if data is being written), supplies the missing data, and grants consent. The client then retries. For an [AccessKey](/en-us/native/overview/), the same consent can also be collected up front, at the moment the user creates the key in the portal. * **Browser / OIDC clients** clear it on the next ordinary interactive login to that application, where the consent screen is shown inline. An ungranted **optional** claim never blocks anything — it is just omitted. A **synthetic** claim never blocks either — it falls back to a placeholder, so it is the way to *guarantee a value is present* without ever forcing a user through a browser side-trip. ## The `claims` block [Section titled “The claims block”](#the-claims-block) Whenever Sudomimus issues or refreshes a token through Connect or native direct-issue (`/redeem`, `/refresh`, `/direct-issue/*`), the response carries a top-level `claims` block alongside the tokens. It answers the question a missing claim otherwise leaves open: *why* isn’t this claim in my token? ```json { "email": { "requirement": "REQUIRED", "state": "GRANTED" }, "firstName": { "requirement": "OPTIONAL", "state": "DENIED" }, "lastName": { "requirement": "OFF", "state": "UNKNOWN" } } ``` For each of the three claims you get its `requirement` (the developer’s policy: `OFF` / `OPTIONAL` / `REQUIRED` / `SYNTHETIC`) joined with its `state` (the user’s standing decision: `UNKNOWN` / `GRANTED` / `DENIED`). The distinction between `UNKNOWN` (“never asked”) and `DENIED` (“explicitly declined”) is the reason this is three states and not a nullable boolean. Read together with [the inclusion rule above](#when-a-claim-actually-appears-in-a-token), the block tells you exactly why a claim is present or absent — policy `OFF`, never asked, declined, or granted but with no data behind it. On the claim-gate `403` from direct-issue, the same block is what lists what is still owed before tokens can be minted. ## What the application receives [Section titled “What the application receives”](#what-the-application-receives) * **Connect / native access tokens** — `emailAddress`, `firstName`, and `lastName` each appear only when their claim is shared. * **OIDC `id_token` / `/userinfo`** — gated by scope **and** grant: the **email** claim becomes `email` (plus `email_verified`); **first name** becomes `given_name`; **last name** becomes `family_name`; `name` is composed from the granted parts. A **synthetic** email is sent with `email_verified: false` — it is a proxy address, not a verified mailbox, so do not treat it as one. See [Tokens and verification](/en-us/concepts/tokens-and-verification/) for where these sit in the token payloads. ## Where to manage it [Section titled “Where to manage it”](#where-to-manage-it) * **Developer** — your application’s detail page in the [With portal](https://with.sudomimus.com): set each claim to Off / Optional / Required / Synthetic. * **User** — the **Data sharing** view in the account portal: see and revoke what each application receives. # Organizations and sectors > The organization-based multi-tenant model behind the developer portal — how organizations own applications and sectors, the Viewer/Admin/Owner role hierarchy, and how resources are retired. When you build on Sudomimus, your resources live inside an **organization**. The developer portal at [`with.sudomimus.com`](https://with.sudomimus.com) is organization-based and multi-tenant: an organization owns your applications, sectors, adopted domains, and federation connectors, and you collaborate with teammates by giving them a role in that organization. This page defines those pieces and how they relate. ```plaintext Account ──member-of──> Organization ──owns──> Application │ (one per sector) └────────owns────────> Sector ``` ## Organizations [Section titled “Organizations”](#organizations) An **organization** is the multi-tenant container that owns everything you create as a developer — applications, sectors, [adopted domains](/en-us/domains-federation/overview/), and [federation connectors](/en-us/domains-federation/federation-connectors/). * You become a developer simply by **creating an organization** from the With portal; that makes you its first **Owner**. “Developer” is not a separate kind of account — it is a capability any Sudomimus user picks up by belonging to an organization. * Every account holder always has the personal **Account** surface (Profile, sign-in methods, data sharing). The **Developer** surface — Organizations, Applications, Sectors — simply lists the organizations you belong to. Belonging to none is a normal state, not an error. * An organization has limits on how many applications and sectors it can hold, and each account has a limit on how many organizations it can own. ## Applications [Section titled “Applications”](#applications) An **application** is a single integration — a web app, game, CLI, or OIDC relying party. It carries a permanent [`applicationAnchor`](/en-us/connect/three-key-model/), its signing and client-auth keys, and the [three layers of rules](/en-us/application-rules/overview/) that decide who can log in and how. Every application belongs to exactly one organization and exactly one sector. ## Sectors [Section titled “Sectors”](#sectors) A **sector** is the identity-isolation boundary. Applications in the same sector see the **same** opaque identifier for a given user; applications in different sectors see **unrelated** identifiers for the same person. By default each application gets its own fresh sector (maximum isolation); you opt in to shared identity by placing two applications in one sector. The privacy contract behind sectors — the sector subject, account alias, and why the raw account ID never leaves the system — is covered in [Privacy & pairwise identity](/en-us/concepts/pairwise-identity/). ## Roles and membership [Section titled “Roles and membership”](#roles-and-membership) Collaboration is managed at the **organization** level — there is no per-application or per-sector membership. Each member holds one role, and the three roles form a rank: **Viewer < Admin < Owner** | What you can do | Minimum role | | -------------------------------------------------------------------------------------------------------------------- | -------------- | | View applications, sectors, rules, and organization settings | **Viewer** | | Create and edit applications, configure rules, rotate keys, set the [claim policy](/en-us/concepts/identity-claims/) | **Admin** | | Manage members — invite, change a role, remove | **Owner** | | Retire (disable) an application, a sector, or the organization | **Sole Owner** | Members are invited by their **account alias** (the user-visible handle, never the internal account ID). An organization can have more than one Owner; the portal refuses to remove or demote the **last** remaining Owner, so a developer cannot accidentally orphan their own organization. ## Retiring resources [Section titled “Retiring resources”](#retiring-resources) There is **no hard delete** on the developer surface. To take a resource out of service you **retire** it by disabling it: * Retiring an **application** immediately makes every login to it fail, but already-issued tokens keep working until they expire. * Retiring a **sector** requires that every application in it is already disabled. * Retiring an **organization** requires that every application and sector it owns is already disabled. A retired organization is **frozen** — no member, rename, or create operation works on it until you re-enable it. Each of these retire operations requires you to be the organization’s **sole** Owner, so one co-owner cannot unilaterally pull a shared resource out from under the others. ## How this interacts with account deletion [Section titled “How this interacts with account deletion”](#how-this-interacts-with-account-deletion) Because retiring is the only way to wind a resource down, account deletion has a guard rail: you **cannot erase your account while you are the sole Owner of an organization that still holds a live resource** (an enabled application or a non-disabled sector). Retire those first. A co-owned organization never blocks deletion — another Owner remains. See [Account deletion](/en-us/guides/account-deletion/) for the full erasure flow. ## Where this lives [Section titled “Where this lives”](#where-this-lives) Everything above is managed in the With portal at [`with.sudomimus.com`](https://with.sudomimus.com). A resource that belongs to no organization is platform-managed and never appears in a developer’s dashboard. # Privacy & pairwise identity > How Sudomimus stops applications from correlating the same user across products — purpose-scoped pairwise identifiers, an internal account ID that never leaves the system, user-controlled rotation, and per-application claim sharing. Sudomimus is built so that **the applications a user signs in to cannot quietly link that person across products**, and so that the user — not the application — stays in control of the identity they hand out. This page explains the identity model that makes that true. ## The core promise [Section titled “The core promise”](#the-core-promise) When a user authenticates, an application never receives a global, stable account ID. Instead it receives a **purpose-scoped identifier** that is meaningful only to that application — or to that one developer’s family of applications. Three principles hold: * **The real account identifier never leaves the system.** Internally every account has a primary key, but it is a pure internal foreign key: it is never minted into a token, never shown to an application, and never shown to the signing-in user. There is nothing to leak. * **No cross-application correlation by default.** Two unrelated applications receive *different* identifiers for the same person. Two owners who compare notes cannot tell that their two users are the same human. * **The user can rotate their identifiers.** Identity continuity is a choice the user can revoke. ## Sectors: the unit of isolation [Section titled “Sectors: the unit of isolation”](#sectors-the-unit-of-isolation) Every application belongs to exactly one **sector**. A sector is the boundary that decides who shares a user’s identity: * Within a sector, a user has exactly **one** identifier, so two applications placed in the same sector see the **same** identifier for that user. This is deliberate — it lets one developer run a family of related products as a single identity (a game launcher and its companion app, say). * Applications in **different** sectors see **unrelated** identifiers for the same user. No amount of comparing those identifiers reveals the shared person behind them. By default each application gets its own fresh sector — **maximum isolation**. A developer who genuinely wants two of their applications to share a user’s identity opts in by placing both in one sector; nothing is shared until they ask for it. Sectors live inside an organization alongside your applications — see [Organizations and sectors](/en-us/concepts/organizations-and-sectors/). Note Because the per-user identifier is a function of *(user, sector)*, moving an application between sectors changes the identifier every one of its users presents. It is a deliberate, identity-severing operation — not a routine config tweak. ## The identifiers a user actually has [Section titled “The identifiers a user actually has”](#the-identifiers-a-user-actually-has) | Identifier | Who sees it | Rotatable | What it’s for | | ------------------ | ------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Account alias** | The user, in their account portal. **Never the application.** | ✅ | A handle the user shares out of band with whoever configures an allow-list — so an operator can permit “this specific person” without the application ever learning the handle. | | **Sector subject** | The application — it **is** the `sub` claim in the token. | ✅ | The application’s key for that user, and the value a developer allow-lists against in their own rules. Unique per *(user, sector)*. | Both are **opaque, human-readable tokens** — a sector subject looks like `sub_9SQ5535CRWNDDM2T`, an account alias like `quiet-meadow-7h2k-9m4p-3fnp-falcon`. Applications are expected to treat them as opaque strings: never parse them, never assume a format. That is exactly what lets the format evolve later without breaking anyone. ### Rotation, and what it means [Section titled “Rotation, and what it means”](#rotation-and-what-it-means) * **Rotating the account alias** changes the handle the user hands out for allow-listing. The application never saw the old one and never sees the new one — only out-of-band allow-list configuration is affected. * **Rotating the sector subject** changes the `sub` an application sees for that user. The previous subject stops resolving, so the application now sees what looks like a brand-new user. This is the user’s “forget my continuity with this product” control, exercised from their account portal. ## The other half: claim sharing [Section titled “The other half: claim sharing”](#the-other-half-claim-sharing) A pairwise identifier only buys privacy up to the **strongest stable claim** an application still receives. If every application is also handed the same real email address, it can correlate on the email instead — and the pseudonymous `sub` was beside the point. So identity claims are a **separate, user-controlled layer**. Per application, the user decides whether their **email**, **first name**, and **last name** are minted into that application’s tokens at all: * A developer declares, per claim, whether the application requests it: **off**, **optional**, or **required**. * The user makes the final call on the optional ones at login, and can **revoke a previously granted claim at any time** from their account portal. Revocation takes effect on the very next token issued — the decision is read live at each issue, never from a cached copy baked into an old token. * An application that was never granted a claim simply never receives it; the field is absent from the token. Together these two layers — pairwise identifiers plus per-application claim control — mean an application receives exactly the identity surface the user chose to give it, and nothing it can use to quietly find that same user somewhere else. See [Identity claims and sharing](/en-us/concepts/identity-claims/) for the full model — the policy levels, the consent screen, and how OIDC scopes gate what reaches a token. # Authentication philosophy > The threat model and design principles that underpin every other concept in Sudomimus. Sudomimus treats every protocol boundary as independently exposed: browsers, application servers, native clients, relying parties, and the network between them may each be compromised. A flow should therefore avoid giving any one participant enough material to impersonate every other participant. These principles apply across Connect, OIDC, and native direct-issue. Some sections use Connect as the concrete example; the protocol-specific mechanics live in the corresponding integration section. ## 1. Redemption proof is scoped to one session [Section titled “1. Redemption proof is scoped to one session”](#1-redemption-proof-is-scoped-to-one-session) Most auth systems rely on a long-lived shared secret — OAuth’s `client_secret`, an API key, a static signing secret in the application’s environment. Leak it once, and every past and future exchange is compromised. Connect avoids using a long-lived shared secret as sufficient proof to redeem every login. Each round-trip mints a **fresh `hiddenKey`** at `/establish`, uses it exactly once at `/redeem`, and then permanently consumes it. Leaking one hidden key compromises that redemption, not every login the application has ever handled. This is proof-of-possession thinking: short, narrowly-scoped secrets beat long-lived shared ones. > **What this rejects:** trusting a single long-lived `client_secret` to authenticate every token exchange forever. ## 2. Identity is separate from credentials [Section titled “2. Identity is separate from credentials”](#2-identity-is-separate-from-credentials) A Sudomimus **account** stores who someone is — a stable identity with a name. An **authentication method** stores how they prove it — a passkey, an email address, a Steam identity, an AccessKey credential. The two live in different records, and one account can hold many methods. Consequences: * **Account enumeration is harder.** The account record carries no email. An attacker probing whether `alice@example.com` exists has nowhere to query it from. * **Switching auth methods doesn’t touch identity.** A user adding a passkey to their email-OTP account is just a new authentication record. The account stays the same, and so does anything tied to it. * **There is no password column anywhere.** Sudomimus does not store passwords. It cannot leak passwords because it has none to leak. The supported methods are passkeys, email OTP, social sign-in (Google, GitHub, Discord, Battle.net, X), Steam, and AccessKey credentials, with more being added. > **What this rejects:** treating email *as* identity, and the legacy of password databases. ## 3. Identity is opaque, and different for every application [Section titled “3. Identity is opaque, and different for every application”](#3-identity-is-opaque-and-different-for-every-application) Sudomimus never hands your application its internal account identifier. What you receive is a **pairwise identifier** — a stable, opaque `sub` that is unique to *your* application. The same person signing into two different applications presents two unrelated identifiers, and neither can be reversed into the underlying account. Consequences: * **No cross-application correlation.** Two applications cannot collude to link “their” users into one real person by comparing identifiers. * **The identifier is opaque by contract.** It is an exact-match token, not a structured value to parse. Sudomimus can change its internal format without breaking you, precisely because you were never meant to read anything out of it. * **A leaked token leaks one application’s view** of a user — not a platform-wide identity. > **What this rejects:** a single global user id sprayed across every relying party, turning every integration into a tracking vector. ## 4. Users consent to what each application learns [Section titled “4. Users consent to what each application learns”](#4-users-consent-to-what-each-application-learns) An access token carries only the identity claims the **user has agreed to share with that specific application**. Email, first name, and last name are each granted or withheld independently, and a grant can be revoked at any time. Inclusion is re-evaluated **live at every issue**, so a revocation takes effect on the next token — not “eventually”. Consequences: * **A claim you’d like may simply not be there.** Design for its absence. If your application genuinely needs a claim, declare it required — Sudomimus then gates non-interactive issue until the user consents, rather than silently handing you nothing. * **You store less.** Claims you never receive are claims you never have to protect. > **What this rejects:** the assumption that an application is entitled to a user’s full profile the moment they sign in. ## 5. Verification is cryptographic, not relational [Section titled “5. Verification is cryptographic, not relational”](#5-verification-is-cryptographic-not-relational) When your application receives an access token, it is a **signed JWT**. To trust it, your application verifies the signature against its own **token-signing public key**, which it fetches once from `POST /info` (the Connect surface) and caches. You do not call Sudomimus on every request to ask “is this user still logged in?”. Consequences: * **No latency tax** on every authenticated request — verification is local. * **No availability dependency** between your service and Sudomimus once the user is signed in. * **Tokens are deliberately short-lived** (access tokens default to a few hours, not days). When they expire, one HTTPS call against `/refresh` gets a new one — far cheaper than re-authenticating the user. OIDC ID tokens are a separate case: relying parties verify them against the JWKS at `oidc.sudomimus.com/.well-known/jwks.json`. See [Tokens and verification](/en-us/concepts/tokens-and-verification/) for the full picture. > **What this rejects:** the stateful session-on-IdP model that turns every authenticated request into a remote lookup. ## 6. Access is allow-listed and default-deny [Section titled “6. Access is allow-listed and default-deny”](#6-access-is-allow-listed-and-default-deny) Who may complete an authentication, by which method, and how the result is returned are all governed by explicit allow-lists. An application that has configured nothing authenticates **nobody**. The safe state is the default: access is something you deliberately open up, never something you have to remember to lock down. > **What this rejects:** systems that are wide open until an administrator remembers to restrict them. ## 7. Failure is scoped, not amplified [Section titled “7. Failure is scoped, not amplified”](#7-failure-is-scoped-not-amplified) A common anti-pattern is the **account lockout**: too many wrong attempts and the account is frozen for an hour. It makes brute force harder, but it also gives anyone who knows your email a free denial-of-service vector. Sudomimus does it differently. Each authentication session carries its own *life* counter. Wrong attempts decrement that session’s life — not the account’s. When a session runs out of life, that session dies; the user simply starts another one. The account itself is never locked. > **What this rejects:** punishing the legitimate user for the attacker’s behaviour. ## 8. Three keys, three vantage points [Section titled “8. Three keys, three vantage points”](#8-three-keys-three-vantage-points) To forge a successful `/redeem` against Sudomimus, an attacker must simultaneously hold: * A secret that lives only on the application server (the **hidden key**) * A reference that was only ever sent to one specific browser (the **exposure key**) * A proof that Sudomimus only mints after a real challenge succeeds (the **confirmation key**) No single point of failure produces all three. A leaked URL does not compromise the server’s secret. A compromised server does not compromise other users’ sessions. A phished user does not compromise the server. The mechanics are in [the three-key model](/en-us/connect/three-key-model/). The principle is general: split a proof three ways across three trust domains. > **What this rejects:** monolithic session tokens whose theft is full compromise. ## 9. Trust boundaries are enforced, not documented [Section titled “9. Trust boundaries are enforced, not documented”](#9-trust-boundaries-are-enforced-not-documented) Sudomimus has exactly five public surfaces — `connect-api.sudomimus.com`, `via.sudomimus.com`, `device-api.sudomimus.com`, `native-api.sudomimus.com`, `oidc.sudomimus.com`. Everything an integration can reach, it reaches through one of these; anything else is internal, and *internal* here means unreachable from outside the platform — enforced at the platform edge, not asserted in a document and left one misconfiguration away from exposure. The practical consequence: there are a small number of well-defined paths through which an authentication can be completed, and integrations cannot bypass them. > **What this rejects:** soft conventions like “this is an internal API, please don’t call it.” ## What this means for you [Section titled “What this means for you”](#what-this-means-for-you) When you integrate with Sudomimus, you inherit these properties for free: * You never see a password, so you have nothing to store securely. * You receive an opaque, per-application identifier — you cannot accidentally become a cross-site tracking vector, because you were never given a global id to leak. * You only ever hold the identity claims a user agreed to share with you. * Your application verifies tokens offline; Sudomimus availability doesn’t gate access to your own backend. * A leak of any single secret in your application is bounded to one session. * You don’t need to build account lockout logic. * You don’t need to write account-enumeration defences. Next, [choose an integration path](/en-us/getting-started/choose-integration/) or inspect the concrete [Connect flow](/en-us/connect/flow/). # Tokens and verification > The full JWT claim reference for access tokens, refresh tokens, and OIDC ID tokens — what each carries, how to verify, TTL bounds, and how the email claim is selected. Sudomimus issues three kinds of token, signed by different keys and verified through different mechanisms. This page is the single reference for verification and token contents — what the claims mean and the rules that decide their values. ## At a glance [Section titled “At a glance”](#at-a-glance) | Token | Issued by | Signed with | Verified via | Carries | | ----------------- | ------------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------- | | **Access token** | Connect (`/redeem`, `/refresh`, `/direct-issue/*`, `/token`) | The **application’s** token-signing private key | `POST /info` → `applicationPublicKey` | `subject`, `firstName`, `lastName?`, `emailAddress?` | | **Refresh token** | Connect (`/redeem`, `/direct-issue/*`, `/token`) | The **application’s** token-signing private key | Same as access token | `subject` | | **OIDC ID token** | OIDC (`/token`) | A **platform-wide** OIDC signing key | `oidc.sudomimus.com/.well-known/jwks.json` | `sub`, `iss`, `aud`, `exp`, `iat`, `nonce?`, `auth_time?`, `email?`, `name?` | All three are JWTs signed with **RS256** (RSA-2048). The response envelope is more than the token This page is about what lives *inside* a token. The `/redeem`, `/refresh`, and `/direct-issue/*` responses that deliver those tokens also carry a top-level **`claims` block** beside them — a per-claim view of what the application requests and what the user has decided, which is how you tell *why* an optional claim like `emailAddress` is absent. See [the `claims` block](/en-us/concepts/identity-claims/#the-claims-block). ## Access and refresh tokens [Section titled “Access and refresh tokens”](#access-and-refresh-tokens) These are the tokens your application backend deals with day to day. Both are signed by Sudomimus using a keypair that is **specific to your application** — every application has its own pair, and rotating one application’s key has no effect on any other. ### Verification [Section titled “Verification”](#verification) Your application verifies signatures against its own **token-signing public key**, fetched from `POST /info`. The recommended flow is: 1. On startup, call `POST /info` with your `applicationAnchor` and cache the returned `applicationPublicKey`. 2. For each incoming request, parse the JWT, check `kty === "Access"` in the header (or `"Refresh"` for refresh tokens), check `exp`, and verify the signature against the cached key. 3. After the application key is rotated (via the developer portal), clear the cache and refetch. The official SDKs do all of this for you — see [`verifyAccessToken` in the SDK reference](/en-us/reference/sdks/). There is **no JWKS for Connect tokens.** Verification is per-application by design: it means a relying party only needs to know one key (the one tied to its own `applicationAnchor`), and a compromise of one application’s key cannot affect any other. ### The `kty` claim [Section titled “The kty claim”](#the-kty-claim) Sudomimus access and refresh JWTs carry a custom `kty` field in the JWT header to make the token type explicit: * Access tokens: `kty: "Access"` (exact case) * Refresh tokens: `kty: "Refresh"` (exact case) Reject any access-token check that finds `kty: "Refresh"` (or vice versa) — the official SDKs do this for you, but if you verify manually, this is one of the easier mistakes to miss. ### TTL bounds [Section titled “TTL bounds”](#ttl-bounds) | | Default | Minimum | Maximum | | ------------- | ------------------ | -------------- | -------------------- | | Access token | 3 hours (10800s) | 60 seconds | 7 days (604800s) | | Refresh token | 30 days (2592000s) | 1 day (86400s) | 365 days (31536000s) | Per-rule and per-inquiry overrides are subject to these bounds. When multiple TTLs apply (e.g. one from a Layer 1 rule and another from a Layer 3 inquiry constraint), Sudomimus folds them by taking the **minimum**. The refresh TTL is then raised, if necessary, so that it is always at least as long as the access TTL. ### Access token claims [Section titled “Access token claims”](#access-token-claims) Sudomimus access and refresh tokens carry every **standard JWT claim** (`kty`, `iss`, `aud`, `sub`, `iat`, `exp`) in the **JWT header**, and only the **application-specific claims** (`subject`, `firstName`, `lastName`, `emailAddress`) in the **body**. If you verify by hand, read `iss` / `aud` / `exp` from the header, not the payload — the official SDKs do this for you. ```json // JWT header { "alg": "RS256", "kty": "Access", "iss": "sudomimus.com", "aud": "", "sub": "", "iat": , "exp": } ``` ```json // JWT body { "subject": "", "firstName": "", "lastName": "", "emailAddress": "" } ``` | Claim | Meaning | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kty` | Always `"Access"`. Reject any token where this differs. | | `subject` | The application-visible **sector subject** — a per-(account × sector) opaque identifier (e.g. `sub_9SQ5535CRWNDDM2T`). This is the value to key your users on. Stable for a given user within your sector, but rotatable by the user, and different across sectors. Treat it as opaque — do not parse it. The raw account UUID is never put in a token. | | `firstName`, `lastName` | The user’s display name. `lastName` is optional. | | `emailAddress` | Verified email associated with this login. Selection rule below. Omitted when the account owns no verified email (e.g. Steam-only or AccessKey-only accounts). | | `iss` | Always `"sudomimus.com"`. | | `aud` | The `applicationAnchor` of the application this token was issued for. | | `sub` | The identifier of the **refresh token** this access token was minted from. Useful for revocation correlation; not a user identifier. | | `iat`, `exp` | Standard JWT issued-at / expiration, in seconds since epoch. | ### Refresh token claims [Section titled “Refresh token claims”](#refresh-token-claims) The same header/body split applies — standard claims in the header, the sector subject in the body. ```json // JWT header { "alg": "RS256", "kty": "Refresh", "iss": "sudomimus.com", "aud": "", "iat": , "exp": } ``` ```json // JWT body { "subject": "" } ``` The `subject` here is the same sector subject carried on the access token. It is **informational only** — Sudomimus identifies a refresh token by its internal handle, not by reading this body — so applications should not key revocation or storage off it. The refresh token body deliberately omits display name and email — those are looked up fresh from the account row each time a new access token is minted, so the values in your access tokens stay current as the user updates their profile. ### How `emailAddress` is selected [Section titled “How emailAddress is selected”](#how-emailaddress-is-selected) The `emailAddress` claim on access tokens is chosen at issuance time by a single deterministic rule: 1. **If the user logged in with email-OTP**, the claim is exactly the email they typed (and proved they own by entering the correct verification code). 2. **Otherwise** — passkey, Steam ticket, AccessKey, or any other login method — the claim is the account’s **primary email** from the `EmailIdentity` table. 3. **If the account owns no verified email at all** (e.g. a Steam-only account with no linked address), the claim is omitted entirely. Your verification code should treat `emailAddress` as optional. The “primary email” defaults to the first verified email the account acquired, and the user can change it from Sign-in methods in the With portal. Once an access token has been minted, refreshing it (via `POST /refresh`) preserves the original `emailAddress` value, so applications can safely cache it across refreshes within a single session. ## OIDC ID tokens [Section titled “OIDC ID tokens”](#oidc-id-tokens) If your application is integrated as an **OIDC relying party**, the `/token` endpoint additionally returns an `id_token` alongside `access_token`. The ID token is signed by a platform-wide OIDC signing key (not your per-application key) and is verified against the JWKS at `https://oidc.sudomimus.com/.well-known/jwks.json`. ID token claims follow the OpenID Connect standard: ```json { "iss": "https://oidc.sudomimus.com", "sub": "", "aud": "", "exp": , "iat": , "nonce": "", "auth_time": , "email": "", "name": "" } ``` | Claim | Meaning | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iss` | Always `"https://oidc.sudomimus.com"`. | | `sub` | The **sector subject** — the same per-(account × sector) value carried as `subject` on the paired access token, **not** the raw account UUID. Stable for a user within your sector, rotatable, and different across sectors. | | `aud` | The OIDC `client_id` of the relying party. | | `exp`, `iat` | Standard JWT lifetimes, in seconds since epoch. | | `nonce` | Echoed from the relying party’s `/authorize` request on initial issuance. **Not** echoed on refresh-token grants, per [OIDC core 1.0 §12.1](https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken). | | `auth_time` | When the user actually authenticated, in seconds since epoch. Preserved across refresh-token grants. | | `email` | The user’s email — same value as on the paired access token. Only present when the `email` scope was granted. | | `name` | The user’s full name, constructed as `firstName lastName`. Only present when the `profile` scope was granted. | OIDC signing keys are rotated periodically; the JWKS always publishes the keys for both the currently-active and the recently-retired key so verification continues to work during rotation. ### OIDC access tokens [Section titled “OIDC access tokens”](#oidc-access-tokens) OIDC `/token` also returns an `access_token` whose shape is **identical** to the Connect access token described above — same `kty`, same per-application signing key, same claims. The difference is only in the issuance path; once issued, your application verifies it exactly the same way (`POST /info` → cached public key). See [OIDC relying parties](/en-us/oidc/flow/) for the full RP flow, including `/userinfo` and `/end-session`. ## When to use which [Section titled “When to use which”](#when-to-use-which) * **Connect protocol** (access / refresh tokens via `POST /info`): your own application backend talks to Sudomimus directly. Lowest overhead, no extra hop. * **OIDC** (ID tokens via JWKS): your application uses an off-the-shelf OIDC library, or you want to integrate with a third party that already speaks OIDC. Sudomimus acts as the IdP. You generally pick one or the other per application — there is no requirement to use both. ## Related [Section titled “Related”](#related) [OIDC relying parties ](/en-us/oidc/flow/)The full RP integration — discovery, /authorize, /token, /userinfo, /end-session, scopes. [Managing sessions ](/en-us/guides/managing-sessions/)The session lifecycle after the initial login — /refresh, /introspect, /logout, /revoke-all. [SDK reference ](/en-us/reference/sdks/)Official SDKs that do \`verifyAccessToken\` (signature, kty, exp) for you. # Connect flow > The Connect protocol from Establish through Authenticate, Redeem, and Refresh, with end-to-end examples for web applications. This page covers the **Connect protocol**: the browser-mediated Sudomimus flow used when your application wants direct control over the login round-trip. Connect speaks JSON over HTTPS, so any backend language with an HTTP client works; examples below are in curl, Node.js, Python, and Go. If you’re building a native client (desktop, game, CLI), see [Native clients](/en-us/native/overview/). For OIDC, see [OIDC relying parties](/en-us/oidc/flow/). Tabs are synchronised across the page: pick your language once and every block below switches with it. ## Protocol at a glance [Section titled “Protocol at a glance”](#protocol-at-a-glance) | Phase | Initiator | Endpoint | Result | | ------------------- | ------------------- | ----------------------------- | -------------------------------------------- | | **1. Establish** | Application backend | `connect-api POST /establish` | `{ exposureKey, hiddenKey }` | | **2. Authenticate** | Browser | `via.sudomimus.com` | The user completes an allowed challenge | | **3. Redeem** | Application backend | `connect-api POST /redeem` | `{ accessToken, refreshToken }` | | **4. Refresh** | Application backend | `connect-api POST /refresh` | A new access token and rotated refresh token | Three parties split responsibility: * **Your backend** signs `/establish`, stores `hiddenKey`, redeems the completed inquiry, and verifies the resulting tokens. * **The browser** carries `exposureKey` to the hosted authentication UI but never sees `hiddenKey`. * **`via.sudomimus.com`** runs the passkey, email OTP, OAuth, or federation challenge and creates `confirmationKey` only after authentication succeeds. This lifecycle is specific to Connect. OIDC uses authorization code + PKCE, while native direct-issue exchanges a Steam ticket or AccessKey in one request. ## 1. Establish — start a session [Section titled “1. Establish — start a session”](#1-establish--start-a-session) Your backend asks Connect to open an authentication session. The response gives you an **exposure key** (passed to the browser) and a **hidden key** (kept on the server). `/establish` requires a signed client-auth JWT Every `/establish` request must carry `Authorization: SudomimusClientJWT `, where `` is an RS256 JWT signed with your application’s client-auth private key. Required claims: `iss = applicationAnchor`, `aud = "sudomimus-connect"`, `iat`, `exp` (≤ 60s from `iat`), `jti` (UUID, replay-protected), `body_sha256` (base64 SHA-256 of the raw HTTP body). Hand-rolling this in 4 lines of curl is impractical. The examples below assume `$SUDOMIMUS_CLIENT_AUTH_JWT` is produced by your signing code; in real integrations, use [`@sudomimus/connect`](/en-us/reference/sdks/) (TypeScript) which signs internally. Unsigned requests are rejected with HTTP 401. `applicationAnchor` format The anchor is the stable identifier you chose when creating the application at [`with.sudomimus.com`](https://with.sudomimus.com). It is lowercase kebab-case (`[a-z][a-z0-9-]*`, 3–64 characters, no leading/trailing/consecutive hyphens) and globally unique — for example `my-app` or `acme-checkout`. * curl ```bash curl -X POST https://connect-api.sudomimus.com/establish \ -H "Content-Type: application/json" \ -H "Authorization: SudomimusClientJWT $SUDOMIMUS_CLIENT_AUTH_JWT" \ -d '{ "applicationAnchor": "your-application", "returnMethods": [ { "type": "CALLBACK", "payload": { "callbackUrl": "https://your-app.com/auth/callback" } } ] }' ``` * Node.js ```js const res = await fetch("https://connect-api.sudomimus.com/establish", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `SudomimusClientJWT ${await signEstablishJwt(body)}`, }, body: JSON.stringify({ applicationAnchor: process.env.SUDOMIMUS_APPLICATION_ANCHOR, returnMethods: [ { type: "CALLBACK", payload: { callbackUrl: "https://your-app.com/auth/callback" }, }, ], }), }); const { exposureKey, hiddenKey } = await res.json(); ``` * Python ```python import os, requests res = requests.post( "https://connect-api.sudomimus.com/establish", headers={ "Authorization": f"SudomimusClientJWT {sign_establish_jwt(body)}", }, json={ "applicationAnchor": os.environ["SUDOMIMUS_APPLICATION_ANCHOR"], "returnMethods": [ { "type": "CALLBACK", "payload": {"callbackUrl": "https://your-app.com/auth/callback"}, }, ], }, ) data = res.json() exposure_key = data["exposureKey"] hidden_key = data["hiddenKey"] ``` * Go ```go body, _ := json.Marshal(map[string]any{ "applicationAnchor": os.Getenv("SUDOMIMUS_APPLICATION_ANCHOR"), "returnMethods": []map[string]any{{ "type": "CALLBACK", "payload": map[string]any{ "callbackUrl": "https://your-app.com/auth/callback", }, }}, }) req, _ := http.NewRequest( "POST", "https://connect-api.sudomimus.com/establish", bytes.NewReader(body), ) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "SudomimusClientJWT "+signEstablishJwt(body)) res, err := http.DefaultClient.Do(req) var data struct { ExposureKey string `json:"exposureKey"` HiddenKey string `json:"hiddenKey"` } json.NewDecoder(res.Body).Decode(&data) ``` Store `hiddenKey` against the user’s pending session (e.g. in a server-side store). Send the user to `via.sudomimus.com` with the `exposureKey` in the URL. ## 2. Authenticate — hand off to `via.sudomimus.com` [Section titled “2. Authenticate — hand off to via.sudomimus.com”](#2-authenticate--hand-off-to-viasudomimuscom) Redirect the user’s browser to `via.sudomimus.com` with the exposure key. The user completes the passkey or email-OTP challenge there. * curl ```text # No HTTP call — this is a 302 redirect from your application: Location: https://via.sudomimus.com/?exposure-key= ``` * Node.js ```js const authUrl = new URL("https://via.sudomimus.com/"); authUrl.searchParams.set("exposure-key", exposureKey); return Response.redirect(authUrl.toString(), 302); ``` * Python ```python from urllib.parse import urlencode from flask import redirect return redirect( "https://via.sudomimus.com/?" + urlencode({"exposure-key": exposure_key}), code=302, ) ``` * Go ```go http.Redirect( w, r, "https://via.sudomimus.com/?exposure-key="+url.QueryEscape(exposureKey), http.StatusFound, ) ``` When the user finishes, `via.sudomimus.com` redirects the browser to your `callbackUrl` with `exposure-key` and `confirmation-key` appended as query parameters. URL params are kebab-case Query parameters travelling through the browser URL use kebab-case (`exposure-key`, `confirmation-key`). The JSON fields on API request/response bodies use camelCase (`exposureKey`, `confirmationKey`). ## 3. Redeem — exchange for a token [Section titled “3. Redeem — exchange for a token”](#3-redeem--exchange-for-a-token) In your callback handler, combine the three keys and exchange them at Connect for an access token plus a refresh token. * curl ```bash curl -X POST https://connect-api.sudomimus.com/redeem \ -H "Content-Type: application/json" \ -d '{ "exposureKey": "...", "hiddenKey": "...", "confirmationKey": "..." }' ``` * Node.js ```js // inside GET /auth/callback?exposure-key=...&confirmation-key=... const { exposureKey, hiddenKey } = await loadPendingSession(req); const confirmationKey = req.query["confirmation-key"]; const res = await fetch("https://connect-api.sudomimus.com/redeem", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ exposureKey, hiddenKey, confirmationKey }), }); const { accessToken, refreshToken } = await res.json(); ``` * Python ```python # inside GET /auth/callback?exposure-key=...&confirmation-key=... exposure_key, hidden_key = load_pending_session(request) confirmation_key = request.args["confirmation-key"] res = requests.post( "https://connect-api.sudomimus.com/redeem", json={ "exposureKey": exposure_key, "hiddenKey": hidden_key, "confirmationKey": confirmation_key, }, ) data = res.json() access_token = data["accessToken"] refresh_token = data["refreshToken"] ``` * Go ```go // inside GET /auth/callback?exposure-key=...&confirmation-key=... exposureKey, hiddenKey := loadPendingSession(r) confirmationKey := r.URL.Query().Get("confirmation-key") body, _ := json.Marshal(map[string]string{ "exposureKey": exposureKey, "hiddenKey": hiddenKey, "confirmationKey": confirmationKey, }) res, _ := http.Post( "https://connect-api.sudomimus.com/redeem", "application/json", bytes.NewReader(body), ) var data struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } json.NewDecoder(res.Body).Decode(&data) ``` The access token is a signed JWT. Verify it using your application’s token-signing public key — fetched once from `POST /info` and cached — then trust its claims. See [Tokens and verification](/en-us/concepts/tokens-and-verification/) for the full verification recipe, including the `kty: "Access"` header check. ## 4. Refresh — keep the session alive [Section titled “4. Refresh — keep the session alive”](#4-refresh--keep-the-session-alive) Before the access token expires, exchange the refresh token for a fresh access token **and a new refresh token**. `/refresh` does not require a client-auth JWT. Refresh tokens are rotated — the token you present is consumed, and the response returns its replacement. Persist the new `refreshToken` and use it for the next refresh; re-using a spent one revokes the whole session. Near-simultaneous concurrent refreshes of the same token (e.g. multiple tabs) are tolerated and converge on one session; only reuse *after* the replacement has been issued revokes it. * curl ```bash curl -X POST https://connect-api.sudomimus.com/refresh \ -H "Content-Type: application/json" \ -d '{ "refreshToken": "..." }' ``` * Node.js ```js const res = await fetch("https://connect-api.sudomimus.com/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); // The refresh token is rotated — capture the new one and persist it, // replacing the token you just sent. const { accessToken, refreshToken: newRefreshToken } = await res.json(); await store.saveRefreshToken(newRefreshToken); ``` * Python ```python res = requests.post( "https://connect-api.sudomimus.com/refresh", json={"refreshToken": refresh_token}, ) data = res.json() access_token = data["accessToken"] # The refresh token is rotated — persist the new one, replacing the old. store.save_refresh_token(data["refreshToken"]) ``` * Go ```go body, _ := json.Marshal(map[string]string{"refreshToken": refreshToken}) res, _ := http.Post( "https://connect-api.sudomimus.com/refresh", "application/json", bytes.NewReader(body), ) var data struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } json.NewDecoder(res.Body).Decode(&data) // The refresh token is rotated — persist data.RefreshToken, replacing the old one. store.SaveRefreshToken(data.RefreshToken) ``` For introspection, logout, and account-wide revocation, see [Managing sessions](/en-us/guides/managing-sessions/). ## Looking up application metadata [Section titled “Looking up application metadata”](#looking-up-application-metadata) `POST /info` returns the public profile of an application (name, public key, localized name) given its anchor. It does **not** require a client-auth JWT, so it is safe to call from browsers and untrusted contexts. * curl ```bash curl -X POST https://connect-api.sudomimus.com/info \ -H "Content-Type: application/json" \ -d '{ "applicationAnchor": "your-application", "locale": "en-US" }' ``` * Node.js ```js const res = await fetch("https://connect-api.sudomimus.com/info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ applicationAnchor, locale: "en-US" }), }); const { applicationAnchor: anchor, applicationName, applicationPublicKey } = await res.json(); ``` * Python ```python res = requests.post( "https://connect-api.sudomimus.com/info", json={"applicationAnchor": application_anchor, "locale": "en-US"}, ) info = res.json() ``` * Go ```go body, _ := json.Marshal(map[string]string{ "applicationAnchor": applicationAnchor, "locale": "en-US", }) res, _ := http.Post( "https://connect-api.sudomimus.com/info", "application/json", bytes.NewReader(body), ) ``` The `applicationPublicKey` returned by `/info` is the key your backend uses to verify access tokens for this application. Cache it; refetch only after key rotation. # The three-key model > How Sudomimus proves a redemption is legitimate by splitting per-session proof across three keys — exposureKey, hiddenKey, and confirmationKey — each held by a different party. The three-key model is the heart of Sudomimus’s session security for the browser-mediated flow. Every authentication round-trip through Connect mints **three distinct keys** at different moments, held by different parties, defending against different classes of attack. A successful `/redeem` must present all three. Applies to the Connect browser flow The three-key model is specific to the **Connect** lifecycle — the four-phase flow described in [the Connect flow](/en-us/connect/flow/). Native one-shot flows (`/direct-issue/steam-ticket`, `/direct-issue/access-key`) bypass it: the credential they present *is* the proof. OIDC uses its own authorization-code + PKCE shape. This page describes the shape that backs the Connect flow. This is not about the long-lived material Sudomimus uses to sign tokens (covered in [Tokens and verification](/en-us/concepts/tokens-and-verification/)). It is about how a single browser-mediated login proves itself. ## The three keys [Section titled “The three keys”](#the-three-keys) | Key | Created by | Held by | Visible to | | ------------------- | ------------------------------------------------- | ------------------------------------- | --------------------------------- | | **exposureKey** | `/establish` | Application backend, then the browser | Browser, URL, `via.sudomimus.com` | | **hiddenKey** | `/establish` | Application backend only | Backend | | **confirmationKey** | `via.sudomimus.com`, after the user authenticates | Application backend (via callback) | Browser, URL | `exposureKey` and `hiddenKey` are issued together as a **pair** by the same `/establish` call. They belong to one session, and only that session. Each key carries a role-specific prefix so a misplaced key can be rejected at the request boundary: * `exp_` + 32 lowercase hex characters * `hid_` + 32 lowercase hex characters * `cnf_` + 32 lowercase hex characters ## Why three keys, and not one [Section titled “Why three keys, and not one”](#why-three-keys-and-not-one) If a single opaque session reference were enough to redeem a token, anyone who saw that reference could redeem on the user’s behalf. Splitting the proof three ways means an attacker has to compromise **three different vantage points at the same time**: * **Without `hiddenKey`** — an attacker who steals the URL the user is visiting still cannot redeem. The hidden key never leaves your server. * **Without `exposureKey`** — an attacker who breaches your server still cannot redeem someone else’s session, because each pending session’s exposure key is bound to the specific browser it was sent to. * **Without `confirmationKey`** — neither party can redeem a session the user never actually completed. The confirmation key is only minted by `via.sudomimus.com` after a real passkey or OTP challenge succeeds. ## Lifecycle [Section titled “Lifecycle”](#lifecycle) ```plaintext /establish ────► exposureKey + hiddenKey │ │ ▼ ▼ to browser stay on backend │ ▼ via.sudomimus.com ──► user passes challenge │ ▼ confirmationKey ──► to your callback │ ▼ /redeem ◄──── exposureKey + hiddenKey + confirmationKey │ ▼ accessToken + refreshToken ``` After `/redeem` succeeds, all three keys are consumed and cannot be reused. A second redemption with the same triple fails — even if the application repeats the request. ## Comparison with OAuth 2.0 [Section titled “Comparison with OAuth 2.0”](#comparison-with-oauth-20) If you are familiar with the OAuth authorization-code flow, the rough analogues are: | Sudomimus | OAuth 2.0 | | ----------------- | --------------------------- | | `exposureKey` | `state` parameter (loosely) | | `confirmationKey` | authorization `code` | | `hiddenKey` | *no direct equivalent* | OAuth relies on the long-lived `client_secret` to authenticate the token exchange. Sudomimus instead uses a per-session `hiddenKey` that is fresh on every `/establish` call. Leaking one session’s hidden key compromises that single redemption — not every redemption your application ever performs. This is the same idea as proof-of-possession tokens: prefer short-lived, narrowly-scoped secrets over long-lived shared ones. If you actually want OAuth/OIDC semantics, Sudomimus also runs a [standard OIDC provider](/en-us/oidc/flow/) at `oidc.sudomimus.com`. The three-key model is what powers the Connect protocol underneath; relying parties using OIDC do not see it directly. ## The long-lived keypairs [Section titled “The long-lived keypairs”](#the-long-lived-keypairs) Aside from the three per-session keys, each application has two RSA-2048 keypairs that *are* long-lived: * **Token-signing keypair** — Sudomimus signs access and refresh JWTs with its private half; the application verifies signatures with the public half (fetched from `POST /info`). * **Client-auth keypair** — the application signs every `/establish` request with its private half (delivered once at application creation and at each rotation); Sudomimus verifies with the stored public half. Neither of these is part of the “three-key” model. They authenticate the *channel* between Sudomimus and the application; the three per-session keys authenticate a *single login*. The details are in [Tokens and verification](/en-us/concepts/tokens-and-verification/). # Device authorization flow > Use the Device API to sign in CLIs, launchers, and other public clients through a browser-confirmed user code. Device authorization is for clients that cannot safely hold an application client-auth private key: CLIs, launchers, terminal tools, shared devices, and other public clients. The client asks Sudomimus for a short-lived `deviceCode` / `userCode` pair, shows the user a code, sends them to the browser, and polls until approval turns into ordinary Sudomimus application tokens. The flow follows the [OAuth 2.0 Device Authorization Grant (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628) model: the initiating client receives a device code and a user code, the user approves in a browser-capable user agent, and the client polls until the authorization is complete. Sudomimus keeps the standard device-flow shape while returning Sudomimus application tokens. This is a separate integration path from Connect and Native direct-issue: | Path | What proves the request | | -------------------- | ------------------------------------------------------------------- | | Connect | A client-auth JWT signed by the application’s private key | | Native direct-issue | A platform credential such as a Steam ticket or AccessKey secret | | Device authorization | A public client starts a code session; the browser user approves it | The public HTTP surface is `device-api.sudomimus.com`. The browser approval page is still hosted by `via.sudomimus.com`. ## Protocol overview [Section titled “Protocol overview”](#protocol-overview) | Step | Actor | Endpoint | Result | | ----------- | --------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | | 1. Start | Client | `device-api POST /device-authorize` | `{ deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn, interval }` | | 2. Approve | User in browser | `via.sudomimus.com/device` | The user signs in, confirms the displayed code, and approves or denies | | 3. Poll | Client | `device-api POST /device-token` | Pending state, polling instruction, denial, expiry, or `{ accessToken, refreshToken }` | | 4. Continue | Client | `connect-api POST /refresh` | Later token rotation uses the normal Connect token operations | Only the client sees `deviceCode`. Only the user sees and confirms `userCode`. A successful `/device-token` response consumes the device session, so the same `deviceCode` cannot mint another token pair. ## 1. Start device authorization [Section titled “1. Start device authorization”](#1-start-device-authorization) The client starts by posting its application anchor: ```bash curl -X POST https://device-api.sudomimus.com/device-authorize \ -H "Content-Type: application/json" \ -d '{ "applicationAnchor": "your-application" }' ``` Successful response: ```json { "applicationAnchor": "your-application", "deviceCode": "dvc_...", "userCode": "WDJB-MJHT", "verificationUri": "https://via.sudomimus.com/device", "verificationUriComplete": "https://via.sudomimus.com/device?user_code=WDJB-MJHT", "expiresIn": 600, "interval": 5 } ``` `/device-authorize` does **not** accept a client-auth JWT. The application opts in through configuration instead: it must have an enabled Layer 3 `DEVICE_CODE` ReturnRule. If that rule is missing, the request is refused. ## 2. Send the user to the browser [Section titled “2. Send the user to the browser”](#2-send-the-user-to-the-browser) Show the `userCode` clearly in the client UI and ask the user to open the browser approval page: ```text Visit https://via.sudomimus.com/device Enter code: WDJB-MJHT ``` When possible, open `verificationUriComplete` instead. It pre-fills the code and is also the best target for QR-code or clickable terminal output: ```text https://via.sudomimus.com/device?user_code=WDJB-MJHT ``` The browser page displays the application and code again before approval. The user should compare the browser code with the code shown by the client. The browser never receives `deviceCode` and never displays access or refresh tokens; it only approves or denies the pending session. ## 3. Poll for tokens [Section titled “3. Poll for tokens”](#3-poll-for-tokens) The client polls `/device-token` with the private `deviceCode`: ```bash curl -X POST https://device-api.sudomimus.com/device-token \ -H "Content-Type: application/json" \ -d '{ "deviceCode": "dvc_..." }' ``` Poll no faster than the returned `interval`. While the user is still working, `/device-token` returns an OAuth-style polling error: ```json { "error": "authorization_pending" } ``` After approval, the same endpoint returns ordinary Sudomimus application tokens: ```json { "applicationAnchor": "your-application", "accessToken": "...", "refreshToken": "...", "claims": { "email": { "requirement": "OPTIONAL", "state": "GRANTED" }, "firstName": { "requirement": "OFF", "state": "UNKNOWN" }, "lastName": { "requirement": "OFF", "state": "UNKNOWN" } } } ``` The `claims` block explains the application’s claim policy joined with the user’s standing sharing decision. The actual token payload follows the normal Sudomimus token rules. ## 4. Use Connect for later token operations [Section titled “4. Use Connect for later token operations”](#4-use-connect-for-later-token-operations) Device authorization only owns the initial public-client exchange. Once `/device-token` succeeds, the refresh token belongs to the normal Sudomimus application session model. Use Connect API token operations afterward: * `POST /refresh` to rotate the refresh token and issue a new access token. * `POST /logout` to end the current refresh-token family. * `POST /introspect` to inspect a token. * `POST /revoke-all` for application-level session revocation. See [Managing sessions](/en-us/guides/managing-sessions/) for those operations and [Device polling and errors](/en-us/device/polling-and-errors/) for the polling state machine. ## Application configuration [Section titled “Application configuration”](#application-configuration) An application must allow the browser-side authentication and realization checks it wants users to pass: | Layer | Requirement | | ------- | ------------------------------------------------------------- | | Layer 1 | Existing authentication methods such as email OTP or passkeys | | Layer 2 | Existing identity rules that allow the realized account | | Layer 3 | A `DEVICE_CODE` ReturnRule | `DEVICE_CODE` is a Layer 3 return method, not a new authentication method. The user still signs in through the application’s ordinary Layer 1 methods and is still checked against Layer 2 before the device session can be approved. ## When to choose this path [Section titled “When to choose this path”](#when-to-choose-this-path) Use device authorization when your client is public and cannot keep a client-auth private key secret. That is the usual fit for a CLI distributed to users, a launcher installed on a desktop, a terminal-only environment, or a shared-screen device that needs the user to finish sign-in on another browser-capable device. Use [Connect browser polling](/en-us/native/overview/#browser-polling) instead when you have a confidential application backend that can sign `/establish`. Use [Native direct-issue](/en-us/native/overview/) when the client has a platform credential such as a Steam ticket or a pre-issued AccessKey. The raw endpoint contract is published in the [Device API reference](/en-us/api/device/). # Device polling and errors > Handle Device API polling states, single-use sessions, and the security boundaries around deviceCode and userCode. Device authorization clients spend most of their time waiting. A good implementation treats `/device-token` as a state machine: pending means keep polling, `slow_down` means back off, terminal errors stop the flow, and success consumes the session. ## Polling loop [Section titled “Polling loop”](#polling-loop) After `POST /device-authorize`, store `deviceCode`, show `userCode`, and start polling no faster than the returned `interval`. ```js let intervalSeconds = authorize.interval; while (true) { await sleep(intervalSeconds * 1000); const res = await fetch("https://device-api.sudomimus.com/device-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ deviceCode: authorize.deviceCode }), }); const body = await res.json(); if (res.ok) { return body; // { accessToken, refreshToken, claims, ... } } if (body.error === "authorization_pending") continue; if (body.error === "slow_down") { intervalSeconds = body.interval ?? intervalSeconds + 5; continue; } throw new Error(body.error); } ``` The server may return `slow_down` if the client polls too quickly. When that happens, use the returned `interval` for subsequent polls. ## Polling errors [Section titled “Polling errors”](#polling-errors) `POST /device-token` uses the [RFC 8628 device-flow error vocabulary](https://www.rfc-editor.org/rfc/rfc8628#section-3.5) for polling states. Branch on `error`; do not expect Sudomimus `{ "reason": "..." }` wire reasons from this endpoint’s polling outcomes. | Error | Meaning | Client behavior | | ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------- | | `authorization_pending` | The user has not approved or denied yet. | Keep polling after the current interval. | | `slow_down` | The client is polling too quickly. | Increase the interval; use `interval` if present. | | `access_denied` | The user denied the request, approval failed, or policy no longer allows issuance. | Stop polling and show a denied/failed state. | | `expired_token` | The device authorization session expired. | Stop polling; start again with `/device-authorize`. | | `invalid_request` | The `deviceCode` is malformed, unknown, or already consumed. | Stop polling; start a new flow if appropriate. | | `server_error` | Token issuance failed after approval. | Stop polling and report a retryable service failure. | Successful responses use Sudomimus’s normal camelCase JSON shape. Polling failures intentionally use the device-flow `error` vocabulary so public clients can follow the familiar device-code state machine. ## Expiry and single use [Section titled “Expiry and single use”](#expiry-and-single-use) Each device authorization session is short lived. The production default is currently `expiresIn: 600` seconds, but clients should use the value returned by `/device-authorize` instead of hard-coding a lifetime. Once `/device-token` succeeds, the session is consumed. Repeating the same `/device-token` request with the same `deviceCode` returns `invalid_request` and cannot mint another token pair. ## Code handling [Section titled “Code handling”](#code-handling) Treat the two codes differently: | Value | Who sees it | Purpose | | ------------ | ---------------------------------- | ---------------------------------------------- | | `deviceCode` | Only the initiating client | High-entropy bearer secret for `/device-token` | | `userCode` | The user and browser approval page | Short code the user compares and confirms | Do not display `deviceCode`, put it in logs, embed it in a browser URL, or send it to your application backend unless that backend is the component polling `/device-token`. Losing `deviceCode` before expiry lets whoever holds it poll and consume the approved session. `userCode` is safe to display, but it is not a token and cannot mint tokens by itself. It exists so the browser user can confirm they are approving the same client session shown in the terminal, launcher, or device UI. ## No client secret [Section titled “No client secret”](#no-client-secret) Device authorization is for public clients. `/device-authorize` does not require a client-auth JWT because the client could not protect the signing key. Instead, Sudomimus gates the flow on application configuration: * The `applicationAnchor` must resolve to an enabled application. * The application must have an enabled Layer 3 `DEVICE_CODE` ReturnRule. * Browser approval still runs through the application’s Layer 1 authentication rules. * The realized account is still checked by Layer 2 before approval can complete. If you do have a confidential backend that can protect a client-auth private key, [Connect](/en-us/connect/flow/) or [Connect browser polling](/en-us/native/overview/#browser-polling) may be a better fit. ## Claims and token operations [Section titled “Claims and token operations”](#claims-and-token-operations) `/device-token` returns the same kind of application access and refresh tokens as other Sudomimus flows. Claim sharing follows the application’s claim policy and the user’s grant state, just like Connect or Native direct-issue. Device API has no refresh endpoint. After the initial exchange, use Connect: * `connect-api POST /refresh` * `connect-api POST /logout` * `connect-api POST /introspect` * `connect-api POST /revoke-all` See [Managing sessions](/en-us/guides/managing-sessions/) for token lifecycle calls and the [Device API reference](/en-us/api/device/) for exact request and response schemas. # Adopt a domain > Claim a domain for your organization on Sudomimus by proving DNS control, binding it exclusively to the organization as the basis for login policy and federation. If your organization owns a domain (`example.com`, your company’s domain, a personal domain you control), an **owner** of that organization can **adopt** it on Sudomimus. Once adopted, the domain belongs to your organization on the platform: it is bound exclusively to the organization that proved control of it. Adoption is the **foundation** the rest of this section builds on — it is the ownership primitive that a [login policy](/en-us/domains-federation/domain-login-policy/) and [enterprise federation](/en-us/domains-federation/sign-in-with-your-idp/) attach to. On its own, an adopted domain has no effect on any sign-in flow; it becomes load-bearing the moment you give it a policy. ## What an adopted domain gives you [Section titled “What an adopted domain gives you”](#what-an-adopted-domain-gives-you) * **Exclusive ownership.** A verified domain is bound to a single organization. No other organization can hold a verified claim on the same domain while you hold it. * **A mount point for policy.** Once verified, the domain can carry a [login policy](/en-us/domains-federation/domain-login-policy/) (allow / block / force SSO) and can be bound to one of your [federation connectors](/en-us/domains-federation/federation-connectors/). ## Why this exists [Section titled “Why this exists”](#why-this-exists) Most authentication products treat domain ownership as a paid enterprise feature. In Sudomimus it is a first-class primitive — the platform already needs to verify which email addresses belong to whom in order to support email OTP, and domain adoption is a natural extension of that model. It is particularly useful when: * You run a company whose employees share a domain. * You operate a community or organization whose members share a domain. * You want a verified, organization-owned claim on your domain as the basis for policy and federation. ## The adoption flow [Section titled “The adoption flow”](#the-adoption-flow) 1. **Initiate adoption** from the With portal at [`with.sudomimus.com`](https://with.sudomimus.com): pick one of your organizations and claim the domain you want against it. Only an organization owner can start an adoption. 2. **Publish the DNS record.** Sudomimus gives you a one-line `TXT` record to add to the domain: | Field | Value | | ----------- | -------------------------------------------- | | Host / name | `_sudomimus-challenge.example.com` | | Type | `TXT` | | Value | `sudomimus-domain-verification=` | The dedicated `_sudomimus-challenge` subhost keeps the record clear of any apex SPF / DKIM / other `TXT` records you already publish. The token is fixed for the life of the claim. 3. **Verify.** Back in the portal, click **Verify**. Sudomimus performs one live DNS lookup, compares the record, and on success binds the domain exclusively to your organization. The claim moves from `PENDING` to `VERIFIED`. DNS takes a moment to propagate Verification is on-demand and synchronous — there is no background polling. If you click **Verify** before the record has propagated (or while a stale “no such record” answer is still cached by the resolver), it can fail for a short while. Wait a minute and retry. ## Contested domains and exclusivity [Section titled “Contested domains and exclusivity”](#contested-domains-and-exclusivity) * **Pending claims may coexist.** Two organizations can each have a `PENDING` claim on the same domain — initiation is not exclusive. * **Verification is the exclusive gate.** Only one organization can hold a `VERIFIED` claim at a time: whoever proves DNS control first wins the single verified slot. (This is deliberate — it stops anyone from “squatting” a competitor’s domain in pending state.) * **Verifying a domain another organization already holds** fails with `DomainAlreadyAdopted`. ## Releasing a domain [Section titled “Releasing a domain”](#releasing-a-domain) Releasing is reversible. An organization owner can hand a domain back to the platform at any time, after which it is free for another organization to adopt. Releasing a verified domain tears down its ownership lock and frees the quota slot. Caution Releasing a domain also tears down any [login policy](/en-us/domains-federation/domain-login-policy/) attached to it. If the domain is forcing SSO, release it only when you intend to stop governing how that domain’s users sign in. ## Quota [Section titled “Quota”](#quota) Each organization can hold a limited number of adopted domains (default **3**, counting pending and verified together; releasing any claim frees a slot). Sudomimus staff can raise the limit for your organization on request. The quota is enforced on the self-service surface only. ## Domain format [Section titled “Domain format”](#domain-format) Sudomimus accepts standard ASCII domains with at least two labels (`example.com`, `mail.example.co.uk`). Internationalized (punycode / `xn--`) domains are not accepted yet. Apex and subdomains are independent claims — adopting `example.com` does **not** adopt `mail.example.com`. ## Related [Section titled “Related”](#related) [Domain login policy ](/en-us/domains-federation/domain-login-policy/)Now that the domain is verified, decide how its users may authenticate. [Federation connectors ](/en-us/domains-federation/federation-connectors/)Register the OIDC identity provider you will bind this domain to. # Domain login policy > For users whose email is on a verified domain you own, decide platform-wide whether logins are allowed, blocked, or forced through your identity provider. Once your organization holds a [verified domain](/en-us/domains-federation/adopt-a-domain/), you can give it a **login policy**. The policy governs every account that owns a verified email address on that domain, **platform-wide** — on every application, not just your own. Because you proved DNS control of the domain, Sudomimus treats you as authoritative over how that email namespace signs in. ## The three policies [Section titled “The three policies”](#the-three-policies) | Policy | Effect on accounts with an email on this domain | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`ALLOW_ALL`** | No restriction. This is the default for every verified domain (and what an absent policy means). | | **`BLOCK_ALL`** | Every login is refused, platform-wide, regardless of method. | | **`SSO_ONLY`** | Every login must go through a specific [federation connector](/en-us/domains-federation/federation-connectors/) — your IdP. Every other method (passkey, email OTP, consumer OAuth, Steam, native keys) is refused. | `SSO_ONLY` is covered end-to-end in [Sign in with your IdP](/en-us/domains-federation/sign-in-with-your-idp/#forced-sso-domain-managed); the rest of this page focuses on how all three behave. ## How enforcement works [Section titled “How enforcement works”](#how-enforcement-works) The policy is an **upper gate** evaluated at the moment a login is realized, with one important property: Account-taint, not email-of-the-moment If **any** of an account’s verified emails sits on a `VERIFIED` domain with a `BLOCK_ALL` or `SSO_ONLY` policy, the **whole account** is governed by that policy — on every login, with every method, to every application. It does not matter which email the user typed this time, or whether they logged in with a passkey that has no email at all. The taint follows the account, not the request. This mirrors how account-level disable works. The gate runs **after** the account-active check and **before** the identity (Layer 2) check, so it is genuinely a platform-wide policy, not a per-application rule. A login the policy refuses is rejected with the wire reason `EmailDomainBlocked` (for `BLOCK_ALL`) or `EmailDomainRequiresSso` (for `SSO_ONLY`). ## What a policy does and does not do [Section titled “What a policy does and does not do”](#what-a-policy-does-and-does-not-do) * **It governs authentication, not authorization.** A login that satisfies `SSO_ONLY` still has to pass the application’s [Layer 2 realize rules](/en-us/application-rules/realize-rules/) and [Layer 3 return rules](/en-us/application-rules/return-rules/). Authentication ≠ authorization — forcing SSO does not grant access, it only constrains *how* a user proves who they are. * **It does not revoke already-issued tokens.** Like account disable, the policy is checked at login (realize) time only. Access and refresh tokens that were already minted keep working until they expire by TTL (access 3 hours, refresh 30 days). Setting `BLOCK_ALL` locks a user out of *new* logins immediately; it does not end their current sessions. * **It does not touch the account’s email ownership.** Reverting to `ALLOW_ALL` restores normal access — nothing about the account was deleted. ## Setting a policy [Section titled “Setting a policy”](#setting-a-policy) The login policy is set from the With portal, on the verified domain’s detail page (a **Login policy** tab). It can only be changed by the **sole owner** of the organization that owns the domain — if your organization has more than one owner, no single owner can unilaterally change how everyone on the domain signs in. You can lock yourself out The policy applies to **every** application, including the With portal itself, and there is **no** exemption for your own login. If you set `BLOCK_ALL` (or a misconfigured `SSO_ONLY`) on the domain of your *own* login email, you will lock yourself out of Sudomimus and will need staff to un-block you. The portal warns you before you do this — read the warning. ## Related [Section titled “Related”](#related) [Adopt a domain ](/en-us/domains-federation/adopt-a-domain/)A login policy requires a verified domain — start here. [Sign in with your IdP ](/en-us/domains-federation/sign-in-with-your-idp/)The SSO\_ONLY policy in full: forcing a domain's users through your connector. # Federation connectors > Register your organization's own OIDC identity provider as a reusable connector, then use it to offer enterprise sign-in or force a domain through SSO. A **federation connector** is your organization’s own external OIDC identity provider, registered with Sudomimus once and reused everywhere. If your company runs Microsoft Entra ID, Okta, Google Workspace, Ping, Auth0, or any standards-compliant OIDC provider, you register it as a connector and then either: * offer it as a **“Sign in with …” button** on one of your applications ([application-managed](/en-us/domains-federation/sign-in-with-your-idp/#application-managed-sign-in)), or * **force** a verified domain’s users through it ([domain-managed / forced SSO](/en-us/domains-federation/sign-in-with-your-idp/#forced-sso-domain-managed)). The connector is the shared mount point for both. Sudomimus acts as a **relying party** (OIDC client) against your IdP — it consumes your provider; your provider stays the source of truth for those identities. ## What a connector stores [Section titled “What a connector stores”](#what-a-connector-stores) You register a connector from the With portal on one of your organizations. It holds: | Field | What it is | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Display name** | Shown on the login button and in management UIs (e.g. “Acme Corp SSO”). | | **Issuer** | Your IdP’s issuer URL (`https://login.acme.example`). Sudomimus fetches its OIDC discovery document from `/.well-known/openid-configuration`. | | **Client ID** | The OAuth client ID Sudomimus presents to your IdP. | | **Client secret** | The confidential client secret. **Encrypted at rest** and **write-only** — see below. | | **Scopes** | The scopes Sudomimus requests; must include `openid`. | Each connector is addressed by an opaque **connector anchor** (a value like `Bastion-K7Q2-M9XB-3FNP-Covenant`) — the developer-facing identifier you reference from rules and policies. The internal identifier never leaves the system. Confidential client + PKCE Sudomimus authenticates to your IdP as a **confidential client** using the client secret, and uses **PKCE (S256)** on every authorization. `private_key_jwt` and public (secret-less) clients are not supported yet — the providers Sudomimus targets first (Entra, Okta, Workspace) all support confidential + PKCE. ### The client secret is never revealed back to you [Section titled “The client secret is never revealed back to you”](#the-client-secret-is-never-revealed-back-to-you) The client secret is encrypted the moment you save it. On every read — the connector list, the detail page, the API — it is simply **absent**: the portal can never display it back to you. To rotate it, supply a new secret; to keep the existing one when editing other fields, leave the secret blank. ## Validation at save time [Section titled “Validation at save time”](#validation-at-save-time) When you create or update a connector, Sudomimus **fetches your IdP’s discovery document then and there**. If the issuer is unreachable or does not publish a valid OIDC discovery document, the save is rejected (`FederationConnectorDiscoveryFailed`) rather than storing a dead connector — the failure is loud and immediate, not a surprise at first login. ## The redirect URI to register at your IdP [Section titled “The redirect URI to register at your IdP”](#the-redirect-uri-to-register-at-your-idp) Sudomimus uses a single, platform-fixed redirect (callback) URI for **all** connectors — your IdP distinguishes flows by the per-login `state` value, not by the redirect URI. The connector page shows this URI read-only; register it as an allowed redirect URI in your IdP’s application configuration: ```text https://via.sudomimus.com/oauth/enterprise-federation/callback ``` ## Managing connectors [Section titled “Managing connectors”](#managing-connectors) * **Disable** a connector to retire it without deleting it — useful when you are migrating IdPs. * **Delete** removes it entirely. Connectors in use cannot be removed A connector that is still referenced cannot be disabled or deleted: * referenced by a Layer-1 application sign-in rule → `FederationConnectorInUse`; * pinned by a domain’s `SSO_ONLY` login policy → `FederationConnectorInUseByLoginPolicy`. Remove the rule, or revert the domain’s policy to `ALLOW_ALL`, first. This guard exists so a live forced-SSO domain can never be stranded behind a deleted IdP. ## Quota [Section titled “Quota”](#quota) Each organization can hold a limited number of connectors (default **3**). Sudomimus staff can raise the limit on request. The quota is enforced on the self-service surface only. ## Browsing connectors [Section titled “Browsing connectors”](#browsing-connectors) The With portal has a top-level **Connectors** view that lists every connector across all your organizations, with an organization switcher. ## Related [Section titled “Related”](#related) [Sign in with your IdP ](/en-us/domains-federation/sign-in-with-your-idp/)Put a connector to work — as an application button or a forced-SSO domain policy. [Domain login policy ](/en-us/domains-federation/domain-login-policy/)Bind a connector to a verified domain to force its users through SSO. # Domains and enterprise federation > How an organization claims a domain it owns, decides how that domain's users authenticate, and federates them to its own corporate identity provider. The developer surface on Sudomimus is **organization-based**. An organization owns its applications and sectors, and an organization is also where the enterprise-grade identity features live: claiming the domains you control, deciding how the users on those domains may sign in, and pointing them at your own corporate identity provider. This section covers that whole story. It builds up in three steps, each independently useful and each the foundation for the next. ## The three steps [Section titled “The three steps”](#the-three-steps) | Step | What it is | Who it is for | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | **1. Adopt a domain** | Prove you control a domain (`example.com`) via a DNS `TXT` record; the platform binds it exclusively to your organization. | Any organization that owns a domain. | | **2. Set a login policy** | For users whose email is on that verified domain, decide platform-wide whether logins are allowed, blocked, or forced through SSO. | Organizations that want to govern how their domain’s users authenticate. | | **3. Federate to your IdP** | Register your organization’s OIDC identity provider as a connector, then either offer it as a “Sign in with …” button on an application or force a domain’s users through it. | Companies and communities that run their own IdP (Entra ID, Okta, Google Workspace, …). | Each step stands on its own. Adopting a domain is valuable purely as a verified, organization-owned claim. A login policy needs nothing more than a verified domain. Federation adds the IdP. You only go as far down this path as your needs require. ## A note on organizations [Section titled “A note on organizations”](#a-note-on-organizations) Everything here hangs off an **organization** — the multi-tenant container that owns applications, sectors, domains, and connectors. An account becomes an organization **owner** by creating an organization from the With portal. Membership carries a role (`VIEWER` < `ADMIN` < `OWNER`); the mutations in this section are **owner-only**, and several of them — setting a login policy, disabling an organization’s domain federation — require the caller to be the organization’s **sole** owner, so one co-owner cannot unilaterally change how everyone on a shared domain signs in. All of these surfaces live in the With portal at [`with.sudomimus.com`](https://with.sudomimus.com). ## Where to go next [Section titled “Where to go next”](#where-to-go-next) [Adopt a domain ](/en-us/domains-federation/adopt-a-domain/)Prove DNS control of a domain and bind it exclusively to your organization. [Domain login policy ](/en-us/domains-federation/domain-login-policy/)Allow, block, or force SSO for every user on a verified domain — platform-wide. [Federation connectors ](/en-us/domains-federation/federation-connectors/)Register your organization's OIDC identity provider as a reusable connector. [Sign in with your IdP ](/en-us/domains-federation/sign-in-with-your-idp/)Offer your IdP on an application, or force a domain's users through it. [Start by adopting a domain ](/en-us/domains-federation/adopt-a-domain/) # Sign in with your IdP > Put a federation connector to work — offer your identity provider as a sign-in button on an application, or force a verified domain's users through it. Once you have registered a [federation connector](/en-us/domains-federation/federation-connectors/), there are **two** ways to put it to work. They share the same connector, the same login machinery, and the same account model — they differ only in *who decides* that a user signs in through your IdP. | Mode | Who turns it on | Who it affects | | ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | | **Application-managed** | An application developer, per application | Anyone who chooses the “Sign in with …” button on that app | | **Domain-managed (forced SSO)** | A domain owner, via login policy | Every account on a verified domain, on every application, with no opt-out | Both are **Layer-1 authentication methods**, so they compose with the rest of the [three-layer rules model](/en-us/application-rules/overview/) exactly like any other method. ## Application-managed sign-in [Section titled “Application-managed sign-in”](#application-managed-sign-in) This is the straightforward “let users of my app sign in with our corporate IdP” case. On an application your organization owns, add a Layer-1 authentication rule: ```json { "method": "ENTERPRISE_FEDERATION_APPLICATION_MANAGED", "payload": { "connectorAnchor": "Bastion-K7Q2-M9XB-3FNP-Covenant" } } ``` * The `connectorAnchor` must reference a connector **owned by the application’s own organization** — this is enforced when the rule is saved. * Sudomimus renders a **“Sign in with ``”** button in the authentication UI. * One rule = one button. To offer several IdPs, add several rules (they OR together, like any Layer-1 rules). When the user clicks the button, the browser is redirected to your IdP, returns with an authorization code (browser-driven PKCE), and Sudomimus runs the **same realize pipeline as every other sign-in method**: account-linking decision, identity-row writes, Layer 2 realize, the consent gate, and token issuance. Nothing about federation is special downstream — it produces a normal Sudomimus session. ### How the federated identity links to an account [Section titled “How the federated identity links to an account”](#how-the-federated-identity-links-to-an-account) When your IdP returns a user, Sudomimus decides how to attach them, exactly as for a consumer OAuth provider: 1. **Seen this connector subject before** → reuse the existing account (handles the user’s email changing at the IdP). 2. **The IdP asserts a verified email that matches an existing account** → link this connector to that account. 3. **Neither** → create a new account. Because the link key is namespaced **per connector**, one Sudomimus account can hold several federated identities across different connectors — signing in through two different IdPs that both happen to use the email `jordan@acme.example` resolves to the same account by step 2. ## Forced SSO (domain-managed) [Section titled “Forced SSO (domain-managed)”](#forced-sso-domain-managed) This is the enterprise SSO payoff: **every** user on a domain you own may sign in **only** through your IdP — passkeys, email OTP, and consumer OAuth are taken away from them. It is built from two pieces you have already met: 1. A verified domain with its [login policy](/en-us/domains-federation/domain-login-policy/) set to **`SSO_ONLY`**, bound to one of your organization’s connectors. 2. The latent Layer-1 method **`ENTERPRISE_FEDERATION_DOMAIN_MANAGED`** on each application that agrees to accept these logins: ```json { "method": "ENTERPRISE_FEDERATION_DOMAIN_MANAGED", "payload": {} } ``` The payload is **empty** — unlike application-managed, the connector is **not** named in the rule. It is resolved at login time from the user’s email domain → the verified domain → the connector bound to that domain’s `SSO_ONLY` policy. A different organization’s domain can therefore drive the login, which is the whole point: your app says “I accept whatever IdP the user’s domain owner mandated.” ### What a gated user experiences [Section titled “What a gated user experiences”](#what-a-gated-user-experiences) * **Email-first flows.** When an SSO-gated user enters their email, Sudomimus suppresses every other method and offers **only** the “Continue with ``” path. * **No-email flows** (usernameless passkey, consumer OAuth, Steam). The user is only known *after* they authenticate, so Sudomimus catches them at realize time and redirects them into the SSO flow, then completes the login on the second pass. * **A confirmation screen, not a silent jump.** core-ui shows a “Continue with ``” screen rather than auto-redirecting. An account tainted by **two** different organizations’ SSO domains (rare) gets a **picker** — any one bound connector satisfies the gate. * **New employees.** A brand-new user whose email is on a forced-SSO domain is registered *through* the IdP on first sign-in (registration-via-SSO) — there is no separate enrolment step. ### Apps that have not opted in [Section titled “Apps that have not opted in”](#apps-that-have-not-opted-in) An application that does **not** list `ENTERPRISE_FEDERATION_DOMAIN_MANAGED` simply **rejects** an SSO-gated user — there is no implicit SSO escape hatch injected into an app that did not ask for it. This keeps Layer-1 default-deny intact: forcing SSO never silently adds a method to an application. Forced SSO overrides Layer 1 only `SSO_ONLY` changes *which authentication method* a gated user may use. It does **not** override Layer 2 or Layer 3 — an SSO-authenticated user can still be rejected by an application’s email allowlist, and the result is still delivered by the application’s return rules. Authentication ≠ authorization. ### Offboarding [Section titled “Offboarding”](#offboarding) Because enforcement is at login (realize) time, a departed employee whose IdP account is disabled simply cannot obtain a fresh assertion and cannot log in again. Tokens already issued expire by TTL (access 3 hours, refresh 30 days) — this is the standard SSO offboarding window, the same realize-only behavior as `BLOCK_ALL`. ## Related [Section titled “Related”](#related) [Federation connectors ](/en-us/domains-federation/federation-connectors/)Register the OIDC identity provider both modes use. [Domain login policy ](/en-us/domains-federation/domain-login-policy/)The SSO\_ONLY policy that drives domain-managed forced SSO. [Layer 1 — Authentication rules ](/en-us/application-rules/authentication-rules/)How both federation methods sit in the three-layer rules model. # Choose an integration path > Choose between Connect, OIDC, device authorization, and native direct-issue, then see which public Sudomimus services that path uses. Sudomimus offers **four peer integration paths**. Choose the one that matches your client and existing stack; none is a prerequisite for another. | Path | Best fit | Protocol shape | Start here | | ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ | | **Connect** | Web applications and custom browser sign-in | `establish → authenticate → redeem → refresh` | [Connect flow](/en-us/connect/flow/) | | **OIDC** | Frameworks and partner systems that already speak OpenID Connect | Authorization code + PKCE | [OIDC flow](/en-us/oidc/flow/) | | **Device authorization** | CLIs, launchers, terminal tools, and public clients without a client secret | `device-authorize → browser approval → device-token` | [Device authorization flow](/en-us/device/flow/) | | **Native direct-issue** | Games, desktop apps, CLIs, and services with Steam tickets or AccessKeys | One credential exchange, with an optional browser errand for remediation | [Native flows](/en-us/native/overview/) | Using the official Sudomimus CLI? The table above is for applications you are building. If you want an AI assistant or local script to operate Sudomimus itself, use the [Sudomimus CLI](/en-us/ai/cli/) instead. Browser polling is Connect, not direct-issue A desktop application can also open the system browser and use Connect with `STATUS_POLL`. That flow belongs to the Connect protocol even though the client is native. The Native section documents it alongside direct-issue so desktop integrators can compare both choices. Device authorization is not Connect `STATUS_POLL` Both flows can involve a native-looking client and a browser, but their trust model is different. Connect `STATUS_POLL` starts from a signed `/establish` request by a confidential client; device authorization starts from an unsigned public-client `/device-authorize` request and requires a Layer 3 `DEVICE_CODE` ReturnRule. The four paths are exposed through five public services. The shared browser service is `via.sudomimus.com`, the hosted UI used by Connect, OIDC, device approval, and native errands. ## The five surfaces [Section titled “The five surfaces”](#the-five-surfaces) | Domain | Audience | Protocol | | --------------------------- | ------------------------------------------------ | --------------------------------------- | | `connect-api.sudomimus.com` | Application backend | Connect protocol (JSON over HTTPS) | | `via.sudomimus.com` | End users in a browser | Hosted auth page (browser-only) | | `device-api.sudomimus.com` | Public clients (CLIs, launchers, shared devices) | Device authorization (JSON over HTTPS) | | `native-api.sudomimus.com` | Native clients (desktop apps, games, CLIs) | One-shot direct-issue (JSON over HTTPS) | | `oidc.sudomimus.com` | OIDC relying parties | OpenID Connect 1.0 | ### `connect-api.sudomimus.com` [Section titled “connect-api.sudomimus.com”](#connect-apisudomimuscom) The HTTPS API your **application backend** calls. It hosts: * **Inquiry lifecycle:** `POST /establish`, `POST /redeem`, `POST /status-poll`, `POST /info` * **Token operations:** `POST /refresh`, `POST /introspect`, `POST /logout`, `POST /revoke-all` `/establish` and `/revoke-all` require a client-auth JWT signed with the application’s client-auth private key (RS256, 60-second lifetime, body-bound via `body_sha256`, replay-protected via `jti`). The other endpoints are either self-authenticating (the token itself proves the right to act on its own session) or public (`/info`). See [Connect flow](/en-us/connect/flow/) for end-to-end examples and [Managing sessions](/en-us/guides/managing-sessions/) for the token-operation endpoints. ### `via.sudomimus.com` [Section titled “via.sudomimus.com”](#viasudomimuscom) A hosted web page that runs the actual user-facing authentication flow — passkey prompts, email OTP entry, platform sign-ins. Your application redirects the user to `via.sudomimus.com` with an `exposure-key`; the user completes the challenge there; control returns to your application according to the inquiry’s return method. `via.sudomimus.com` is the user-facing surface. Your code never calls its endpoints directly — it just sends users there. ### `device-api.sudomimus.com` [Section titled “device-api.sudomimus.com”](#device-apisudomimuscom) For public clients that cannot safely hold an application client-auth private key. It hosts: * `POST /device-authorize` — start a short-lived device-code session and receive `{ deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn, interval }`. * `POST /device-token` — poll with `deviceCode` until the browser user approves, denies, or the session expires. `/device-authorize` does not require a client-auth JWT. The application opts in with a Layer 3 `DEVICE_CODE` ReturnRule, and the user completes ordinary Sudomimus authentication in `via.sudomimus.com`. After `/device-token` succeeds, use Connect token operations for refresh, logout, introspection, and revocation. See [Device authorization flow](/en-us/device/flow/). ### `native-api.sudomimus.com` [Section titled “native-api.sudomimus.com”](#native-apisudomimuscom) For native clients that cannot host a browser-based redirect, this surface offers two one-shot endpoints: * `POST /direct-issue/steam-ticket` — a game with a Steamworks ticket exchanges it directly for `{ accessToken, refreshToken }`. * `POST /direct-issue/access-key` — a CLI or service with a pre-issued AccessKey credential exchanges it directly for `{ accessToken, refreshToken }`. Neither requires a client-auth JWT — the Steam ticket and the AccessKey secret are each their own credential. See [Native flows](/en-us/native/overview/). ### `oidc.sudomimus.com` [Section titled “oidc.sudomimus.com”](#oidcsudomimuscom) A standard OpenID Connect provider. Hosts: * `GET /.well-known/openid-configuration` * `GET /.well-known/jwks.json` * `GET /authorize` (with PKCE, `S256` only) * `POST /token` * `GET /userinfo`, `POST /userinfo` * `GET /end-session` Supported grants: `authorization_code`, `refresh_token`. Supported client authentication: `private_key_jwt`, `client_secret_basic`, and `client_secret_post` (confidential-client options) and `none` + PKCE (the public-client option). See [OIDC flow](/en-us/oidc/flow/). ## A typical request flow [Section titled “A typical request flow”](#a-typical-request-flow) A web application using the Connect protocol: ```plaintext Browser ──────────────► via.sudomimus.com │ ▼ user completes the challenge │ confirmation-key │ App backend ─────────────► connect-api.sudomimus.com │ ▼ signed tokens returned │ ▼ App backend verifies tokens (via /info) ``` The browser and the application backend each talk to a different surface; Sudomimus stitches the two halves together internally, so the application never has to handle the user’s raw authentication material. A public CLI using device authorization starts at `device-api`, sends the user to `via.sudomimus.com/device`, then keeps polling `device-api` until approval returns tokens. A game using Steam direct-issue collapses the login to a single round trip against `native-api`. An OIDC relying party talks only to `oidc.sudomimus.com`; the user is still authenticated via `via.sudomimus.com` underneath, but the RP does not see it. ## Everything else is internal [Section titled “Everything else is internal”](#everything-else-is-internal) These five surfaces are the **only** supported integration points. Sudomimus runs other services behind them — but they are internal to the platform and unreachable from outside it, so there is nothing else for an integration to call. If a flow you need isn’t expressed through one of the five surfaces above, it isn’t an integration point. # Quickstart > A minimal end-to-end integration with the Sudomimus Connect API. This page walks through the smallest possible integration: pointing a web application at Sudomimus and obtaining a verified user identity. Recommended: use an official SDK The fastest way to integrate is via an official SDK — install [`@sudomimus/connect`](/en-us/reference/sdks/) and call typed methods (`establish`, `redeem`, `refresh`, `verifyAccessToken`) instead of building raw HTTP requests by hand. [Install the SDK ](/en-us/reference/sdks/) Using an AI coding assistant? Use the Sudomimus CLI when an assistant needs to operate Sudomimus directly. It exposes shell commands and JSON output while keeping login in your browser through the device authorization flow. [Use the CLI with AI ](/en-us/ai/cli/) Not building a web app? Pick the right guide: [Sudomimus CLI ](/en-us/ai/cli/)Shell-friendly account and developer operations for AI agents, local automation, and source checkouts. [Native clients ](/en-us/native/overview/)Desktop apps, games via Steam, and CLI tools — including the browser Errand for consent and profile completion. [OIDC relying parties ](/en-us/oidc/flow/)Standard OpenID Connect — authorization\_code + PKCE. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) 1. **Create or join an organization at [`with.sudomimus.com`](https://with.sudomimus.com)**. The developer self-serve portal is organization-based: applications and sectors live inside an organization, so you need one before you can create an application. Most accounts create their first organization on the spot (the form pre-fills a suggested name); if a teammate already runs one, have them invite you instead. The `/applications` and `/sectors` pages redirect to `/organizations` until you belong to one. 2. **Create your application** inside that organization. When you create the application you receive: * The **`applicationAnchor`** — a stable, lowercase-kebab identifier (e.g. `my-app`), the public name of your application across the API. * The **client-auth private key** — shown **once** at creation, used to sign `/establish` requests. Store it like any production secret. * The application’s **token-signing public key**, which is also accessible at runtime via `POST /info` and is used to verify access tokens. 3. **Add at least one [Return Rule](/en-us/application-rules/return-rules/)** of type `CALLBACK`, listing the hostnames you will redirect users back to. The concrete `callbackUrl` is supplied per inquiry on `/establish`; the rule just gates which hostnames are allowed. 4. **Add at least one [Authentication Rule](/en-us/application-rules/authentication-rules/)** (e.g. `PASSKEY_USERNAMELESS`, `PASSKEY_REASONED`, or `EMAIL_VERIFICATION`) and one [Realize Rule](/en-us/application-rules/realize-rules/) (e.g. `EMAIL` with `allowedEmails: ["*"]` for a public sign-up). Rules are **allowlist-only with default-deny** — an application with zero rules in any layer cannot be used. ## The four phases [Section titled “The four phases”](#the-four-phases) Every authentication round-trip through the Connect API follows the same four phases: 1. **Establish** — your application backend asks Connect to start an authentication session and gets back a session reference (`exposureKey` + `hiddenKey`). 2. **Authenticate** — your application sends the user to `via.sudomimus.com` with the `exposureKey`; the user completes a passkey or email-OTP challenge there. 3. **Redeem** — once `via.sudomimus.com` hands control back via your callback URL (with `exposure-key` + `confirmation-key` in the query string), your backend exchanges the three keys at Connect for a signed access token and refresh token. 4. **Refresh** — your backend exchanges the refresh token for a fresh access token whenever the current one nears expiry. See [the Connect flow](/en-us/connect/flow/) for the full request shapes and how Connect, `via.sudomimus.com`, and your application interact. ## Next steps [Section titled “Next steps”](#next-steps) [Connect flow ](/en-us/connect/flow/)End-to-end curl, Node.js, Python, and Go examples for the Connect API. [The three-key model ](/en-us/connect/three-key-model/)How a single login proves itself across exposureKey, hiddenKey, and confirmationKey. [Managing sessions ](/en-us/guides/managing-sessions/)Refresh, introspect, logout, and revoke-all — the lifecycle after the initial login. [Sudomimus CLI ](/en-us/ai/cli/)A command-line control surface for AI assistants and local automation. # What is Sudomimus > A high-level overview of the Sudomimus authentication and authorization platform. **Sudomimus** is an authentication and authorization platform designed to be embedded into web, desktop, native, and OIDC-compatible applications. It provides a unified way to: * Authenticate end users via multiple methods — **passkeys (WebAuthn)**, **email one-time passwords**, **social sign-in** (Google, GitHub, Discord, Battle.net, X), **Steam** (in-game ticket or “Sign in with Steam”), and **AccessKey credentials** for headless clients — with more being added over time. * Exchange short-lived **tokens** through the Connect flow, device authorization, native direct-issue, or standard OIDC. * Act as a standard **OpenID Connect provider** for relying parties that prefer `authorization_code` + PKCE over the Connect protocol. * Manage users by **domain** — [claim a domain you own](/en-us/domains-federation/adopt-a-domain/), then decide platform-wide how its users authenticate: allow, block, or [force them through your own identity provider](/en-us/domains-federation/sign-in-with-your-idp/). * Enforce **authorization rules** on protected resources through a three-layer allowlist model, so each application does not have to reimplement them. ## Why Sudomimus [Section titled “Why Sudomimus”](#why-sudomimus) Most applications end up reinventing the same authentication primitives: session storage, password resets, email verification, social login, MFA. Sudomimus separates the *identity* layer from the *application* layer, so the authentication surface lives in one place and your application only deals with verified tokens. ## The five public surfaces [Section titled “The five public surfaces”](#the-five-public-surfaces) Sudomimus exposes five public surfaces to integrators: | Domain | Audience | Purpose | | --------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------ | | `connect-api.sudomimus.com` | Application backend | The Connect protocol — establish, redeem, refresh, introspect, logout, revoke. | | `via.sudomimus.com` | End users in a browser | The hosted page that runs the user-facing authentication flow. | | `device-api.sudomimus.com` | Public clients (CLIs, launchers, shared devices) | Device authorization — code confirmation and polling for public clients without a client secret. | | `native-api.sudomimus.com` | Native clients (desktop apps, games, CLIs) | Steam direct-issue and AccessKey direct-issue — one-shot login for clients with no browser. | | `oidc.sudomimus.com` | OIDC relying parties | Standard OpenID Connect provider — discovery, authorize, token, userinfo, JWKS. | You’ll typically pick **one** of Connect, OIDC, device authorization, or native direct-issue. [Choose an integration path](/en-us/getting-started/choose-integration/) compares them. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * Building a **web app with Connect**? Start with the [Quickstart](/en-us/getting-started/quickstart/), then the [Connect flow](/en-us/connect/flow/). * Building a **CLI, launcher, or public client**? Go to [Device authorization](/en-us/device/flow/). * Integrating a **desktop app, game, or client with Steam/AccessKey credentials**? Go to [Native integration](/en-us/native/overview/). * Wiring up an **OIDC relying party**? Go to the [OIDC flow](/en-us/oidc/flow/). * Want to understand the shared model first? Read [Authentication philosophy](/en-us/concepts/philosophy/) and [Accounts and credentials](/en-us/concepts/accounts-and-credentials/). ## Registering your application [Section titled “Registering your application”](#registering-your-application) Applications are created and managed through the developer portal at [`with.sudomimus.com`](https://with.sudomimus.com) — that is where you obtain your `applicationAnchor`, retrieve the client-auth private key (shown once at creation), and configure the three layers of rules. Quickstart links to the exact pages. # Account deletion > How GDPR Art. 17 erasure surfaces at integration time — what your application sees when a user deletes their Sudomimus account, what we erase server-side, and how to handle the new error symbol. A Sudomimus end-user can permanently delete their account at any time from the Profile page on [`with.sudomimus.com`](https://with.sudomimus.com). This page is for **application developers** integrated with Sudomimus: it describes what your application observes when that happens and how to handle it gracefully. The user-facing flow itself — including confirmation and policy text — is part of [the Privacy Policy](https://sudomimus.com/legal-hub/privacy). ## What your application sees [Section titled “What your application sees”](#what-your-application-sees) A deleted account is treated the same as a disabled account by every realize-time and refresh-time chokepoint: the difference is the error symbol and that the condition is irreversible. | When | Error you receive | HTTP / OIDC mapping | | ----------------------------------------------- | -------------------------------------- | ------------------- | | `POST /redeem` on Connect | `AccountDeleted` | `403 Forbidden` | | `POST /refresh` on Connect | `AccountDeleted` | `403 Forbidden` | | `POST /direct-issue/steam-ticket` on Native API | `AccountDeleted` | `403 Forbidden` | | `POST /direct-issue/access-key` on Native API | `AccountDeleted` | `403 Forbidden` | | `POST /token` (auth-code) on OIDC API | `invalid_grant` — “Account is deleted” | `400` | | `POST /token` (refresh-token) on OIDC API | `invalid_grant` — “Account is deleted” | `400` | | `GET /userinfo` on OIDC API | `invalid_token` — “Account is deleted” | `401` | `AccountDeleted` is a new error symbol introduced alongside the existing `AccountDisabled`. **Already-issued access tokens are not retroactively invalidated** — they remain syntactically valid until their `exp` claim, just like for the disabled case. The user is gone at the next refresh attempt, not the moment they click the button. ## How to handle `AccountDeleted` in your app [Section titled “How to handle AccountDeleted in your app”](#how-to-handle-accountdeleted-in-your-app) Treat it the same way you treat `AccountDisabled` today, **with one difference**: do not surface a “your account is suspended, contact support” UI. A deleted account is gone by user choice; the appropriate UX is to drop the local session and direct the user toward a fresh sign-up if they want one. A simple decision tree: ```plaintext /refresh fails with AccountDeleted │ ▼ Clear your local access cookie / SDK state │ ▼ Surface a sign-out screen │ ▼ (optionally) offer "Sign up again" → starts a fresh /establish flow ``` What you should **not** do: * Do not retry — the symbol is terminal, not transient. * Do not auto-re-create an account on the user’s behalf. A new sign-up is a deliberate user action. * Do not assume the user’s previous data carries over. Even if they sign up again with the same email, your application sees a brand-new `subject` (sector subject) with no historical link to the old one. ## Owning a live organization blocks deletion [Section titled “Owning a live organization blocks deletion”](#owning-a-live-organization-blocks-deletion) Applications and sectors are owned by an **organization**, not by an individual, so deletion is gated at the organization level: a user cannot delete their account while they are the **sole `OWNER` of an organization that still holds a live resource**. * An organization blocks deletion when you are its **sole `OWNER`** and it still holds either an **enabled** application or a **non-disabled** sector. Retiring those resources clears the block. * A **co-owned** organization never blocks — another `OWNER` remains to manage it. * An organization whose resources are all already disabled never blocks; on deletion it simply becomes owner-less. The erasure attempt fails with `AccountOwnsLiveOrganizations` (surfaced as HTTP `409 Conflict`); the response carries the blocking organizations’ anchors so the in-product flow can list exactly what to retire. The full path to deletion is therefore a retire-then-delete cascade: in each organization you solely own, disable every application, then disable each now-empty sector (now permitted, because their applications are all disabled), then delete the account. (You may also disable the organization itself, but that is not required for deletion.) This is deliberate. An organization — and the applications and sectors it owns — is infrastructure that holds other people’s data, so Sudomimus will not silently destroy it as a side effect of one developer’s personal account deletion. If you solely own an organization with live resources and are thinking about closing your account, plan a retire step for it first. ## Re-registration semantics [Section titled “Re-registration semantics”](#re-registration-semantics) A user who deletes their account and then signs up again — even with the same email address — gets a **brand-new account** with no historical link to the deleted one: * A new internal account (and therefore a new `subject` / sector subject for every application sector — the value your application receives changes). * A new `EmailIdentity` row (the old one was deleted, freeing the email). * New `Authentication` rows. * No carry-over of preferences, applications, or any session. From your application’s perspective this is indistinguishable from a brand-new user. Your `subject`-keyed data store should treat the two as unrelated. ## Related [Section titled “Related”](#related) * [Managing sessions](/en-us/guides/managing-sessions/) — `/logout` and `/revoke-all` for ending sessions without deleting the account. * [Privacy Policy — Deleting Your Account](https://sudomimus.com/legal-hub/privacy) — the user-facing version of this contract. # Managing sessions > The lifecycle endpoints after the initial login — refresh, introspect, logout, and revoke-all on the Connect API. A login is the start of a session, not the end of the integration. Connect provides four endpoints for the rest of the session lifecycle: **`/refresh`**, **`/introspect`**, **`/logout`**, and **`/revoke-all`**. This page is the single reference for all of them. If you’re using the OIDC flow, see also [`/end-session` in the OIDC guide](/en-us/oidc/flow/#5-end-session) — it has a related but narrower purpose. ## At a glance [Section titled “At a glance”](#at-a-glance) | Endpoint | Authenticates with | Idempotent | Scope of effect | | ------------------ | ------------------------ | -------------------------------------------------------------- | ------------------------------------------------------ | | `POST /refresh` | The refresh token itself | No (rotates the refresh token — each token works exactly once) | One session | | `POST /introspect` | The access token itself | Read-only | One session | | `POST /logout` | The refresh token itself | Yes (calling twice returns `revoked: true` both times) | One session | | `POST /revoke-all` | Client-auth JWT (RS256) | Yes | All sessions for an account, scoped to the calling app | None of these endpoints require setting up new infrastructure — they reuse the keys you already have from your initial integration. ## `/refresh` — extend a session [Section titled “/refresh — extend a session”](#refresh--extend-a-session) Exchange a refresh token for a fresh access token **and a new refresh token**. Refresh is **strict rotation** ([OAuth 2.1 BCP §4.14.2](https://www.rfc-editor.org/rfc/rfc9700#section-4.14.2)): the refresh token you present is consumed and invalidated in the same call, and the response hands you its replacement. Store the new `refreshToken` and use it for the next refresh. Re-presenting an already-used refresh token is treated as family compromise — the entire refresh-token family is revoked and the user must re-authenticate. Near-simultaneous concurrent refreshes of the *same* token (e.g. two browser tabs at once) are the exception: they converge on a single new session rather than logging the user out. Reusing a token *after* its replacement has been issued still triggers compromise, so always store and send the latest rotated token regardless. ```bash curl -X POST https://connect-api.sudomimus.com/refresh \ -H "Content-Type: application/json" \ -d '{ "refreshToken": "..." }' ``` Response: ```json { "accessToken": "", "refreshToken": "", "claims": { "email": { "requirement": "REQUIRED", "state": "GRANTED" }, "firstName": { "requirement": "OPTIONAL", "state": "GRANTED" }, "lastName": { "requirement": "OFF", "state": "UNKNOWN" } } } ``` Persist the rotated `refreshToken`, replacing the one you just used — the old one is now invalid. The `claims` block is the same per-claim view returned by `/redeem` — see [the `claims` block](/en-us/concepts/identity-claims/#the-claims-block) for how to read it. **Auth**: none — possession of the refresh token is the credential. The new access token’s TTL is the one resolved on the original `/redeem` (or `/direct-issue/*`, or OIDC `/token`). It is not re-resolved on refresh. ### Refresh can fail on claims [Section titled “Refresh can fail on claims”](#refresh-can-fail-on-claims) `/refresh` is not only a success-or-revoke endpoint. If, since the last token was minted, a **required** claim has stopped being satisfied — the developer escalated a claim policy from optional to required, or the user revoked a grant — the refresh is rejected with `ClaimConsentRequired` rather than minting a token missing that required claim. Recovery depends on the client, because `/refresh` itself cannot collect consent: * **Native clients** (Steam / AccessKey) recover by re-running the original direct-issue, which returns an [Errand](/en-us/native/claims-and-errand/) handoff for the user to grant consent. * **Browser applications** recover by sending the user through an ordinary interactive login again. This is rare in practice — it only happens when a policy or grant changes mid-session — but build your refresh path to surface a re-authentication prompt rather than treating every refresh failure as a hard logout. ## `/introspect` — is this token still valid? [Section titled “/introspect — is this token still valid?”](#introspect--is-this-token-still-valid) Ask Sudomimus about the current status of an access token. Use this when you want to invalidate sessions promptly across services — e.g. when a user clicks “log out everywhere” and you need other tabs or services to notice within a bounded time. ```bash curl -X POST https://connect-api.sudomimus.com/introspect \ -H "Content-Type: application/json" \ -d '{ "accessToken": "..." }' ``` Response: ```json { "status": "active", "recommendedRecheckSeconds": 600 } ``` **`status`** is one of: * `"active"` — the underlying refresh token exists, is not suspended, and is not expired. * `"revoked"` — the underlying refresh token has been explicitly suspended (via `/logout` or `/revoke-all`). * `"expired"` — the underlying refresh token is past its `expiredAt` date. * `"not_found"` — the token does not match any record under the calling application, or the token cannot be parsed. **`recommendedRecheckSeconds`** is how long Sudomimus suggests you may cache the result before re-introspecting. It is always 600 seconds today. Treat the access token’s own `exp` claim as the upper bound on its usable lifetime; introspection is for catching *early* revocation. **Auth**: none — the access token is self-authenticating. Anyone holding the token can ask whether it is still valid; nothing else is required. ### When to call introspect [Section titled “When to call introspect”](#when-to-call-introspect) Local signature verification covers correctness; introspection covers freshness. A reasonable pattern: * Verify the access token’s signature and `exp` claim **on every request** (cheap, local). * Call `/introspect` opportunistically — once per N minutes per session, on a background job, or when a user-visible state change suggests it. You do not need to introspect on every request. Doing so would defeat the point of having a signed token in the first place. ## `/logout` — invalidate a single session [Section titled “/logout — invalidate a single session”](#logout--invalidate-a-single-session) Suspend one refresh token. The token’s access tokens stop being reported as active by `/introspect`, and `/refresh` against it will fail. ```bash curl -X POST https://connect-api.sudomimus.com/logout \ -H "Content-Type: application/json" \ -d '{ "refreshToken": "..." }' ``` Response: ```json { "revoked": true } ``` * `revoked: true` — the token was suspended (or was already suspended/expired; calling twice is fine). * `revoked: false` — the token is invalid or not found. **Auth**: none — possession of the refresh token authorizes logging out that session ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) style). `/logout` does not nuke access tokens cryptographically A `/logout` call suspends the refresh-token record. Already-issued access tokens remain syntactically valid until their `exp` — the only way other services notice is by calling `/introspect` (or by the access token expiring and `/refresh` failing). Build your “log out everywhere” UX with that in mind. ## `/revoke-all` — kill every session for an account [Section titled “/revoke-all — kill every session for an account”](#revoke-all--kill-every-session-for-an-account) Suspend every refresh token issued to a specific account, within the calling application. Use this for account-takeover incident response, “log me out of all devices”, or support-initiated session termination. The account is identified by its **sector subject** — the `subject` value your application sees on its tokens (the `sub` you key your users on), not the raw account UUID. Sudomimus reverse-maps it to the underlying account internally. ```bash curl -X POST https://connect-api.sudomimus.com/revoke-all \ -H "Content-Type: application/json" \ -H "Authorization: SudomimusClientJWT $SUDOMIMUS_CLIENT_AUTH_JWT" \ -d '{ "subject": "sub_9SQ5535CRWNDDM2T" }' ``` Response: ```json { "revokedCount": 3 } ``` `revokedCount` is the number of refresh tokens that were suspended by this call. Already-suspended or already-expired tokens are not double-counted. **Auth**: a client-auth JWT, signed exactly like the one for `/establish`. The scope of the action is implicit — only sessions issued to that account *within the calling application* are touched. You cannot use one application’s client-auth key to revoke sessions in another application. ## Putting it together — a typical session lifecycle [Section titled “Putting it together — a typical session lifecycle”](#putting-it-together--a-typical-session-lifecycle) ```plaintext /redeem ──► access (3h) refresh (30d) │ │ │ │ ▼ │ used on every request │ │ │ ▼ expires │ /refresh ◄──────────────┤ new access + rotated refresh │ │ ▼ │ │ │ user clicks "log out" ──► /logout │ ▼ revoked │ /introspect ──► "revoked" ``` For the OIDC variant of refresh, see [OIDC relying parties — Refresh](/en-us/oidc/flow/#4-refresh). # Native claims and the Errand > Choose a claim policy for native direct-issue and handle the browser Errand when required consent or profile data is missing. Steam ticket and AccessKey direct-issue authenticate without an application login page. That keeps the happy path short, but it also means the client cannot display a consent form or ask the user to complete missing profile data. This page covers both sides of that constraint: 1. Choose claim policies that fit a non-interactive client. 2. Handle the **Errand** browser handoff when a required real claim cannot yet be issued. The shared policy and consent model is documented in [Identity claims and sharing](/en-us/concepts/identity-claims/). ## Choose a native claim policy [Section titled “Choose a native claim policy”](#choose-a-native-claim-policy) | Policy | Native direct-issue behavior | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **Off** | Never requested. | | **Optional** | Shared only if the user already granted it. Never blocks, so a native-only user may never be prompted. | | **Required** | Guaranteed present with real data. Missing consent or data returns `403` with an Errand. | | **Synthetic** | Guaranteed present, using real data when granted and a stable placeholder otherwise. Never blocks and never creates an Errand. | For most native integrations, prefer **`SYNTHETIC`** over **`REQUIRED`** unless you specifically need verified real data. * Need a stable name or email-shaped value, and a placeholder is acceptable: use `SYNTHETIC`. * Need a real verified email for delivery or reconciliation: use `REQUIRED` and implement the Errand flow. * Want real data when already granted but can continue without it: use `OPTIONAL`. If every requested claim is `OFF` or `SYNTHETIC`, claim policy can never force direct-issue into a browser handoff. Synthetic names are generated placeholders. Synthetic emails use a stable `…@proxy.sudomimus.email` address. Proxy delivery is best-effort, not guaranteed, and OIDC exposes synthetic email with `email_verified: false`. ## The Errand [Section titled “The Errand”](#the-errand) An **Errand** is short-lived account remediation, not token issuance. When direct-issue cannot satisfy a required claim, its `403` response gives the client a browser URL. The user completes consent or missing profile work there, then the client retries the original direct-issue. Only two claim-gate reasons carry an Errand: | `403` reason | Meaning | Browser work | | -------------------------- | ----------------------------------------------------- | -------------------------------------------------------------- | | `ClaimConsentRequired` | A required claim has not been granted. | Grant consent and, when necessary, first add the missing data. | | `RequiredClaimDataMissing` | Consent exists, but the account lacks the real value. | Register an email or complete the missing name. | Other `403` responses, such as rule denial or a disabled account, are terminal and do not include an Errand. ### Handoff response [Section titled “Handoff response”](#handoff-response) ```json { "reason": "ClaimConsentRequired", "claims": { "email": { "requirement": "REQUIRED", "state": "UNKNOWN" }, "firstName": { "requirement": "OPTIONAL", "state": "UNKNOWN" }, "lastName": { "requirement": "OFF", "state": "UNKNOWN" } }, "errand": { "errandKey": "ernd_...", "url": "https://via.sudomimus.com/errand?key=ernd_...", "expiresAt": "2026-06-10T12:30:00Z" } } ``` * Open `errand.url` in the user’s **system browser**. * Treat `errandKey` as a bearer secret. It is also used to poll status. * The Errand is single-use and expires after **30 minutes**. ### Client loop [Section titled “Client loop”](#client-loop) ```text native client ── direct-issue ──▶ 403 { reason, claims, errand } │ ├── open errand.url in the system browser ├── poll GET /errand/{errandKey}/status └── on COMPLETED, retry direct-issue once ──▶ 200 { tokens, claims } ``` Polling is optional. A client may instead ask the user to confirm that they finished in the browser before retrying. ```bash curl https://native-api.sudomimus.com/errand/ernd_.../status # → { "status": "PENDING" } # → { "status": "COMPLETED" } # → { "status": "EXPIRED" } ``` Poll about every two seconds with a sensible overall timeout. `EXPIRED` deliberately covers unknown, malformed, consumed, and genuinely expired keys; rerun direct-issue to obtain a fresh handoff. The status endpoint never issues tokens. Retries normally return the same live Errand when it has at least 15 minutes remaining and the required work has not changed. This prevents an eager retry loop from splitting user progress across multiple URLs. ## Security behavior [Section titled “Security behavior”](#security-behavior) * **Consent only:** no additional sign-in is required because the credential holder already proved control of a token-minting credential. * **Writing identity data:** the browser requires sign-in, and the signed-in account must match the account resolved from the Steam ticket or AccessKey. * Optional and synthetic claims never create an Errand. * Connect `/refresh` and OIDC `/token` do not embed Errand handoffs. A native session blocked during refresh recovers by running direct-issue again. ## Related [Section titled “Related”](#related) * [Native integration](/en-us/native/overview/) — browser polling, Steam ticket, and AccessKey flows. * [Identity claims and sharing](/en-us/concepts/identity-claims/) — the shared policy, grant, and inclusion model. * [Tokens and verification](/en-us/concepts/tokens-and-verification/) — the tokens issued after the claim gate is satisfied. # Native flows > Choose browser-polling Connect, Steam ticket direct-issue, or AccessKey direct-issue for desktop apps, games, CLIs, and services. The Connect browser flow assumes you can redirect a browser to `via.sudomimus.com` and receive a callback. That works for web applications but is awkward for desktop apps, games, and headless tools. A native client has three flows available, depending on what it can do. Two are **Native API** (`native-api.sudomimus.com`) one-shot direct-issue endpoints — Steam direct-issue and AccessKey direct-issue. The third, browser polling, is a **Connect API** flow (`/establish` → `/status-poll` → `/redeem`) that a native client drives itself; it does not run on Native API. If your client is public and should not receive an application client-auth private key or a pre-issued AccessKey secret, use [Device authorization](/en-us/device/flow/) instead. Device authorization is the code-confirmation flow for CLIs, launchers, and similar public clients. | Flow | When to use | Round-trips | Endpoint | | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------ | | **Browser polling** | Any native client with a system browser available | 3+ (establish + N×poll + redeem) | `connect POST /establish` → `/status-poll` → `/redeem` | | **Steam direct-issue** | Game client running inside Steam, with access to Steamworks SDK | 1 | `native-api POST /direct-issue/steam-ticket` | | **AccessKey direct-issue** | CLI / headless service / launcher pre-provisioned with an application-scoped credential for a known existing account | 1 | `native-api POST /direct-issue/access-key` | ## Browser polling [Section titled “Browser polling”](#browser-polling) When your native client can open the user’s system browser but cannot easily receive a callback URL, use the polling flow: 1. The client backend calls `connect POST /establish` (signed with the application’s client-auth JWT) declaring a `STATUS_POLL` return method, and receives `{ exposureKey, hiddenKey }`. 2. The client opens the system browser pointed at `https://via.sudomimus.com/?exposure-key=`. 3. The user completes the passkey or email-OTP challenge in the browser. 4. The client **polls** `connect POST /status-poll` every few seconds with `{ exposureKey, hiddenKey }`. Once the user finishes, the poll returns `{ status: "REALIZED", confirmationKey }`. 5. The client then redeems the three keys at `connect POST /redeem` for `{ accessToken, refreshToken }`. This works on any platform with a default browser — Windows, macOS, Linux desktop apps, Electron, etc. The application’s Layer 3 rules must allow `STATUS_POLL`. The `/establish` call is the standard client-auth-signed Connect request — see [Web applications](/en-us/connect/flow/) for the full shape — except the return method is `STATUS_POLL`: ```bash curl -X POST https://connect-api.sudomimus.com/establish \ -H "Content-Type: application/json" \ -H "Authorization: SudomimusClientJWT $SUDOMIMUS_CLIENT_AUTH_JWT" \ -d '{ "applicationAnchor": "your-application", "returnMethods": [ { "type": "STATUS_POLL", "payload": {} } ] }' # → { "exposureKey": "exp_...", "hiddenKey": "hid_..." } ``` Then poll `/status-poll` with those two keys every few seconds. The poll carries **no** client-auth JWT — possession of the `hiddenKey` is what authorizes it: ```bash curl -X POST https://connect-api.sudomimus.com/status-poll \ -H "Content-Type: application/json" \ -d '{ "exposureKey": "exp_...", "hiddenKey": "hid_..." }' # While the user is still authenticating in the browser: # { "status": "PENDING" } # Once they finish: # { "status": "REALIZED", "confirmationKey": "cnf_..." } ``` When the poll returns `REALIZED`, redeem the three keys at `connect POST /redeem` for the access and refresh tokens (same `/redeem` call as the web flow). ## Steam direct-issue [Section titled “Steam direct-issue”](#steam-direct-issue) For games shipped through Steam, Sudomimus supports a silent login that does not open a browser at all. The user does not see a login prompt; their Steam identity is exchanged directly for a Sudomimus session. ```bash curl -X POST https://native-api.sudomimus.com/direct-issue/steam-ticket \ -H "Content-Type: application/json" \ -d '{ "applicationAnchor": "my-game", "steamTicketHex": "", "steamAppId": 480 }' ``` The flow is: 1. The game calls Steamworks `ISteamUser::GetAuthTicketForWebApi("sudomimus")` — **not** `GetAuthSessionTicket`. The two are different ticket types and are not interchangeable. The identity string must be exactly `"sudomimus"` (case-sensitive); other values are rejected. 2. The game waits for the `GetTicketForWebApiResponse_t` callback before using the ticket. 3. The ticket bytes are hex-encoded and sent as `steamTicketHex` to `POST /direct-issue/steam-ticket`, together with `applicationAnchor` and `steamAppId`. 4. Sudomimus verifies the ticket with Steam, looks up or creates the account, and — on the happy path — returns `{ accessToken, refreshToken }` in one round trip. If the application requires consent or profile data the Steam account has not provided, this returns a `403` with an **Errand** handoff instead — see [When direct-issue needs consent or profile data](#when-direct-issue-needs-consent-or-profile-data). 5. The game calls `Steamworks.CancelAuthTicket(handle)` after receiving the tokens. The user never leaves the game. The Steam account is the source of identity. ### Application configuration for Steam [Section titled “Application configuration for Steam”](#application-configuration-for-steam) The application must have: * **Layer 1**: a `STEAM_TICKET` AuthenticationRule with `allowedSteamAppIds: number[]` containing this game’s Steam App ID. * **Layer 2**: a rule that will match — typically `STEAM_ID` with `allowedSteamIds: ["*"]` for any verified Steam account, or a list of specific SteamID64 strings. * **Layer 3**: a `DIRECT_ISSUE` ReturnRule. A Steam-first account that has never linked an email needs a `STEAM_ID` (or `ACCOUNT_ALIAS` / `SECTOR_SUBJECT`) Layer 2 rule; an `EMAIL`-only Layer 2 will reject it. This endpoint does **not** require a client-auth JWT — the Steam ticket itself attests both the user and the binary’s right to talk to the application. > **Web counterpart.** The browser-side “Sign in with Steam” button uses the `STEAM_OPENID` Layer 1 method instead of `STEAM_TICKET`. Both paths land on the **same** per-user Steam identity, so a user who first signed in through a game can subsequently sign in through the web button (and vice versa) without any account-linking step. See [Authentication rules](/en-us/application-rules/authentication-rules/) for the `STEAM_OPENID` rule shape. ## AccessKey direct-issue [Section titled “AccessKey direct-issue”](#accesskey-direct-issue) For environments without a Steam ticket but where the target Sudomimus account is already known — CLI tools, custom launchers, headless services, automated test rigs. The “proof” is a Sudomimus-issued credential pair (`accessKeyIdentifier` + `accessKeySecret`) generated from the developer portal and handed out-of-band to the operator. ```bash curl -X POST https://native-api.sudomimus.com/direct-issue/access-key \ -H "Content-Type: application/json" \ -d '{ "applicationAnchor": "my-cli-tool", "accessKeyIdentifier": "acs_k_", "accessKeySecret": "acs_t_<64-char lowercase hex>" }' ``` Both credential strings carry mandatory prefixes: * `acs_k_` — the public identifier, followed by a UUIDv4. * `acs_t_` — the secret half, followed by 64 lowercase hex characters (shown exactly once at creation; unrecoverable afterwards). The prefixes are part of the canonical form. They make the two halves visually distinguishable and let secret scanners match accidentally-committed credentials by literal substring. ### Application configuration for AccessKey [Section titled “Application configuration for AccessKey”](#application-configuration-for-accesskey) The application must have: * **Layer 1**: an `ACCESS_KEY_DIRECT` AuthenticationRule (empty payload). Default-deny unless explicitly added. * **Layer 2**: a rule matching the target account — `EMAIL`, `STEAM_ID`, `ACCOUNT_ALIAS`, or `SECTOR_SUBJECT`. * **Layer 3**: a `DIRECT_ISSUE` ReturnRule. **AccessKey credentials cannot create new accounts.** The credential is issued against an existing Sudomimus account; if that account is deleted, every credential bound to it is rejected at login time. Credentials are managed from [`with.sudomimus.com`](https://with.sudomimus.com) on the application detail page → **Access keys** tab. Revocation is a soft delete (`revokedAt` timestamp); rotation = revoke + reissue. Expired credentials are not auto-evicted but are rejected at the handler. This endpoint does not require a client-auth JWT either — the access-key secret is itself the credential. Embedding the client-auth private key in a distributable CLI would be reversible by any operator anyway. ## When direct-issue needs consent or profile data [Section titled “When direct-issue needs consent or profile data”](#when-direct-issue-needs-consent-or-profile-data) Both direct-issue endpoints are one-shot **readers** — they cannot pop a consent screen or ask the user to type in an email. So when an application requires a [claim](/en-us/concepts/identity-claims/) the user has not granted, or requires data the account does not have yet (a Steam account with no email, for instance), the call cannot just succeed. Instead it returns a `403` carrying an **[Errand](/en-us/native/claims-and-errand/)** — a short-lived browser side-trip where the user completes that work: ```json { "reason": "ClaimConsentRequired", "claims": { "email": { "requirement": "REQUIRED", "state": "UNKNOWN" }, "firstName": { "requirement": "OPTIONAL", "state": "UNKNOWN" }, "lastName": { "requirement": "OFF", "state": "UNKNOWN" } }, "errand": { "errandKey": "ernd_...", "url": "https://via.sudomimus.com/errand?key=ernd_...", "expiresAt": "2026-06-10T12:30:00Z" } } ``` The `reason` is one of `ClaimConsentRequired` (the user must agree to share a required claim) or `RequiredClaimDataMissing` (consent is there, but the account data is not). Both Steam and AccessKey direct-issue behave identically here. To recover: 1. Open `errand.url` in the user’s **system browser**. The page walks the user through any sign-in, data entry, and consent that is owed. 2. Poll `GET /errand/{errandKey}/status` (`native-api`) every \~2 seconds until it reports `COMPLETED` — or just let the user tell your UI they’re done. 3. Retry the **same** direct-issue call once. It now succeeds with `{ accessToken, refreshToken }`. ```bash curl https://native-api.sudomimus.com/errand/ernd_.../status # → { "status": "PENDING" } user still working in the browser # → { "status": "COMPLETED" } done — retry the direct-issue # → { "status": "EXPIRED" } expired/consumed/unknown — re-run direct-issue for a fresh errand ``` A `200` from either endpoint also carries a `claims` block (the same shape as in the `403`), so even on success you can see which optional claims were shared and which were withheld. The full contract — the 30-minute lifetime, why retries reuse the same `errandKey`, and the two security tiers (consent-only vs. sign-in-required) — is in [The Errand](/en-us/native/claims-and-errand/). ## When to use which [Section titled “When to use which”](#when-to-use-which) | Your client is… | Use | | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | A web application | Connect + `via.sudomimus.com` (regular flow) | | A desktop app / Electron / mobile with a browser available | Connect + `via.sudomimus.com` via system browser + `connect /status-poll` | | A public CLI / launcher without a client secret | [Device authorization](/en-us/device/flow/) | | A Steam-distributed game | `native-api POST /direct-issue/steam-ticket` (silent, one round trip) | | A CLI / headless service tied to a known account by a pre-issued credential | `native-api POST /direct-issue/access-key` (one round trip with an AccessKey) | In all cases the **token format** returned at the end is the same — your application only ever deals with the same access-token shape, regardless of how the user authenticated. # OIDC flow > Integrate an OpenID Connect relying party with discovery, authorization code + PKCE, /token, /userinfo, and /end-session. If you have an existing application that already speaks **OpenID Connect**, or you want to use an off-the-shelf OIDC library, you can integrate with Sudomimus as a standard OIDC relying party (RP). The Sudomimus OIDC provider lives at `oidc.sudomimus.com` and supports the **authorization code flow with PKCE**, the canonical modern OIDC integration shape. Use this guide when: * Your framework or platform has a first-class OIDC integration (Next-Auth, Spring Security, Keycloak adapter, etc.) and you want to slot Sudomimus in as the IdP. * You’re integrating with a partner system that already expects an OIDC provider. * You prefer the OIDC mental model (clients, scopes, ID tokens) to the Connect protocol. If you’re starting fresh and just want the smallest custom integration, the [Connect protocol](/en-us/connect/flow/) is usually shorter. ## Discovery [Section titled “Discovery”](#discovery) Sudomimus publishes a standard OIDC discovery document. Point your library at the issuer URL `https://oidc.sudomimus.com` and it will fetch the rest from there: ```bash curl https://oidc.sudomimus.com/.well-known/openid-configuration ``` The document advertises: * **`response_types_supported`**: `["code"]` (authorization code flow only). * **`grant_types_supported`**: `["authorization_code", "refresh_token"]`. * **`scopes_supported`**: `["openid", "email", "profile", "offline_access"]`. * **`id_token_signing_alg_values_supported`**: `["RS256"]`. * **`code_challenge_methods_supported`**: `["S256"]` — **PKCE is required**, plain not supported. * **`token_endpoint_auth_methods_supported`**: `["private_key_jwt", "client_secret_basic", "client_secret_post", "none"]`. ## Register your application [Section titled “Register your application”](#register-your-application) In [`with.sudomimus.com`](https://with.sudomimus.com), on the application you want to expose via OIDC: 1. Add a Layer 3 **OIDC** return rule: ```json { "returnMethod": "OIDC", "payload": { "redirectUris": ["https://app.example.com/oidc/callback"], "postLogoutRedirectUris": ["https://app.example.com/"], "allowedScopes": ["openid", "email", "profile", "offline_access"], "tokenEndpointAuthMethod": "private_key_jwt" } } ``` 2. Add the Layer 1 and Layer 2 rules you would for any other application — at least one authentication method (e.g. `PASSKEY_USERNAMELESS` or `PASSKEY_REASONED`) and at least one realize rule (e.g. `EMAIL` with the addresses or domain pattern you accept). The OIDC flow runs through the same authentication challenge as the rest of the platform. 3. **Choose a client authentication method**: * **`private_key_jwt`** (recommended for confidential clients) — your RP holds a private key and signs a JWT assertion at `/token`. The signing key is your application’s client-auth key (the same key used to sign `/establish` requests on the native protocol). * **`client_secret_basic`** (confidential clients) — your RP presents its shared secret in the HTTP `Authorization: Basic` header at `/token`. * **`client_secret_post`** (confidential clients) — your RP sends its shared secret in the `/token` form body (`client_id` + `client_secret` parameters). * **`none`** — only for public clients (SPAs, mobile apps without a backend). **PKCE is required.** The application’s **`applicationAnchor`** is your `client_id`. ## The OIDC flow [Section titled “The OIDC flow”](#the-oidc-flow) ### 1. Authorization request [Section titled “1. Authorization request”](#1-authorization-request) Redirect the user’s browser to `/authorize` with the standard OIDC parameters: ```text https://oidc.sudomimus.com/authorize ?client_id=my-app &redirect_uri=https%3A%2F%2Fapp.example.com%2Foidc%2Fcallback &response_type=code &scope=openid%20email%20profile &state= &nonce= &code_challenge= &code_challenge_method=S256 ``` Required: `client_id`, `redirect_uri`, `response_type=code`, `scope` (must include `openid`), `code_challenge`, `code_challenge_method=S256`. Optional but recommended: `state`, `nonce`. Sudomimus redirects the user to `via.sudomimus.com` where they authenticate via the methods allowed by your Layer 1 rules. After a successful authentication and realize, the browser returns to your `redirect_uri` with a `code` and the original `state`. ### 2. Token exchange [Section titled “2. Token exchange”](#2-token-exchange) POST the authorization code to `/token`. The body is **`application/x-www-form-urlencoded`**, per OIDC: * private\_key\_jwt ```bash curl -X POST https://oidc.sudomimus.com/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "redirect_uri=https://app.example.com/oidc/callback" \ --data-urlencode "code_verifier=$PKCE_VERIFIER" \ --data-urlencode "client_id=my-app" \ --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ --data-urlencode "client_assertion=$CLIENT_ASSERTION_JWT" ``` The `client_assertion` is a JWT you sign with your application’s client-auth private key. Required claims: `iss = client_id`, `sub = client_id`, `aud = the exact token endpoint URL` (`https://oidc.sudomimus.com/token` in production), fresh `jti`, `iat`, `exp` (within 300s of `iat`). RS256. * none (PKCE) ```bash curl -X POST https://oidc.sudomimus.com/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$AUTH_CODE" \ --data-urlencode "redirect_uri=https://app.example.com/oidc/callback" \ --data-urlencode "code_verifier=$PKCE_VERIFIER" \ --data-urlencode "client_id=my-app" ``` No client assertion is sent. PKCE (`code_verifier` matching the `code_challenge` from the authorization request) is the only client authentication. Successful response (JSON): ```json { "access_token": "", "token_type": "Bearer", "expires_in": 10800, "id_token": "", "scope": "openid email profile", "refresh_token": "" } ``` * **`id_token`** — signed by Sudomimus’s platform-wide OIDC key; verify against `https://oidc.sudomimus.com/.well-known/jwks.json`. Standard OIDC claims (`iss`, `sub`, `aud`, `exp`, `iat`, optional `nonce`, `email`, `name`). * **`access_token`** — signed by the application’s token-signing key, same shape as a native Connect access token. * **`refresh_token`** — included only if you requested the `offline_access` scope. ### 3. Userinfo [Section titled “3. Userinfo”](#3-userinfo) ```bash curl https://oidc.sudomimus.com/userinfo \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Returns the claims permitted by the granted scopes: ```json { "sub": "", "email": "", "name": "" } ``` `sub` is the **sector subject** — the per-sector, application-visible identifier (the same value as the `id_token` `sub`), not the raw account UUID. `/userinfo` accepts both `GET` and `POST`. ### 4. Refresh [Section titled “4. Refresh”](#4-refresh) If you requested `offline_access` and got a refresh token, exchange it at `/token`: ```bash curl -X POST https://oidc.sudomimus.com/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=$REFRESH_TOKEN" \ --data-urlencode "client_id=my-app" # plus client_assertion for confidential clients ``` You can optionally pass `scope` to request a narrowed subset of the originally-granted scopes. Per [OIDC §12.1](https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken), the ID token from a refresh does not include a new `nonce`. ### 5. End session [Section titled “5. End session”](#5-end-session) ```text https://oidc.sudomimus.com/end-session ?id_token_hint= &client_id=my-app &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2F &state= ``` `/end-session` signals the end of an OIDC session and redirects to the `post_logout_redirect_uri` (which must match one of the registered URIs exactly). It does **not** revoke refresh tokens on its own — to invalidate the underlying session, call Connect’s `/logout` (single session) or `/revoke-all` (every session for an account). See [Managing sessions](/en-us/guides/managing-sessions/). ## Using an OIDC library [Section titled “Using an OIDC library”](#using-an-oidc-library) Most modern OIDC libraries (Node’s `openid-client`, Python’s `authlib`, Java’s `nimbus-jose-jwt`, etc.) discover everything from the issuer URL and handle PKCE, JWKS, and token verification automatically. A minimal Node example: ```js import { Issuer } from "openid-client"; const issuer = await Issuer.discover("https://oidc.sudomimus.com"); const client = new issuer.Client({ client_id: "my-app", redirect_uris: ["https://app.example.com/oidc/callback"], response_types: ["code"], token_endpoint_auth_method: "none", // or "private_key_jwt" }); // At login: const codeVerifier = generators.codeVerifier(); const codeChallenge = generators.codeChallenge(codeVerifier); const authUrl = client.authorizationUrl({ scope: "openid email profile", code_challenge: codeChallenge, code_challenge_method: "S256", state, nonce, }); // At callback: const params = client.callbackParams(req); const tokenSet = await client.callback(redirectUri, params, { code_verifier: codeVerifier, state, nonce }); const userinfo = await client.userinfo(tokenSet.access_token); ``` ## Token verification reminder [Section titled “Token verification reminder”](#token-verification-reminder) OIDC ID tokens are verified against the JWKS at `oidc.sudomimus.com/.well-known/jwks.json` — that’s the standard OIDC mechanism, and your library does it for you. The `access_token` returned by `/token` is **not** a JWKS-verifiable token in the platform-wide sense; it’s a per-application access token of the same shape as the Connect flow, verified against your application’s `applicationPublicKey` from `POST /info`. If your library tries to verify the access token, point it at `/info` for the key, or simply treat the access token as opaque and use `/userinfo` for claims. See [Tokens and verification](/en-us/concepts/tokens-and-verification/). # SDKs > Official Sudomimus SDKs — strongly typed clients for the Connect and Native APIs in TypeScript, Python, and C#. The Sudomimus SDKs are strongly typed clients generated from the public OpenAPI 3.1 contracts in [`sudomimus/sudomimus-spec`](https://github.com/sudomimus/sudomimus-spec): [`connect.yaml`](https://github.com/sudomimus/sudomimus-spec/blob/main/connect.yaml) (Connect protocol), [`native.yaml`](https://github.com/sudomimus/sudomimus-spec/blob/main/native.yaml) (`native-api` direct-issue), and [`device.yaml`](https://github.com/sudomimus/sudomimus-spec/blob/main/device.yaml) (`device-api` device authorization). The SDK source lives in [`sudomimus/sudomimus`](https://github.com/sudomimus/sudomimus) — TypeScript, Python, and C# in one monorepo. SDKs wrap the raw HTTPS calls with serialization, request/response types, JWT parsing, application public-key caching, signature verification helpers, and structured errors — everything you’d otherwise build by hand. If you’d rather call the API directly, see [Web applications](/en-us/connect/flow/) for curl, Node.js, Python, and Go HTTP snippets. ## Available SDKs [Section titled “Available SDKs”](#available-sdks) All packages are currently at **alpha** stability — usable, with the API surface tracking the spec, but expect some churn before the first stable release. [TypeScript / JavaScript ](https://github.com/sudomimus/sudomimus/tree/master/sdks/typescript/packages)@sudomimus/connect, @sudomimus/native, @sudomimus/token — for Node.js and browsers. [Python ](https://github.com/sudomimus/sudomimus/tree/master/sdks/python/packages)sudomimus-connect, sudomimus-native, sudomimus-token — sync (ConnectClient) and async (AsyncConnectClient). [C# ](https://github.com/sudomimus/sudomimus/tree/master/sdks/csharp/src)Sudomimus.Native and Sudomimus.Token. The Connect package is not yet shipped for C#. The three packages per language split along responsibility: * **`connect`** — full Connect API client (Establish, StatusPoll, Redeem, Refresh, Info, Introspect, Logout, RevokeAll). Use this in your application backend. * **`native`** — Native API client (Steam ticket direct-issue, AccessKey direct-issue). Use this in desktop apps, games, CLIs. * **`token`** — token-parsing utilities only (`verifyAccessToken`, `verifyRefreshToken`, the `kty` constants). Useful when a service only needs to verify tokens and doesn’t call the API. The `connect` and `native` packages already depend on `token` internally, so most integrations don’t need to install it explicitly. The Device API has a published [OpenAPI reference](/en-us/api/device/) for direct HTTP clients. A dedicated SDK package will appear here after it is introduced in the SDK repository. Tabs below are synchronised: pick your language once and every block on the page switches with it. ## Install [Section titled “Install”](#install) * TypeScript ```bash # pnpm pnpm add @sudomimus/connect # npm npm install @sudomimus/connect # yarn yarn add @sudomimus/connect ``` * Python ```bash # uv uv add sudomimus-connect # pip pip install sudomimus-connect ``` ## Initialise the client [Section titled “Initialise the client”](#initialise-the-client) The client needs to know how to sign `/establish` requests with your application’s client-auth private key. Supply the key once at construction time; the SDK signs internally on every relevant call. * TypeScript ```ts import { ConnectClient } from "@sudomimus/connect"; const client = new ConnectClient({ baseUrl: "https://connect-api.sudomimus.com", // clientAuth: configure with your application's client-auth private key // so the SDK can sign /establish and /revoke-all internally. // See the package README for the exact option shape. }); ``` * Python ```python from sudomimus_connect import ( ConnectClient, ConnectClientAuthWithKey, ) with ConnectClient( client_auth=ConnectClientAuthWithKey( application_anchor="my-app", private_key_pem=open("client-auth-private.pem").read(), ), ) as client: ... ``` An async equivalent exists as `AsyncConnectClient` with the same method names. ## Establish — start a session [Section titled “Establish — start a session”](#establish--start-a-session) * TypeScript ```ts const inquiry = await client.establish({ applicationAnchor: process.env.SUDOMIMUS_APPLICATION_ANCHOR!, returnMethods: [ { type: "CALLBACK", payload: { callbackUrl: "https://your-app.com/auth/callback" }, }, ], }); const { exposureKey, hiddenKey } = inquiry; ``` * Python ```python from sudomimus_connect import EstablishRequest inquiry = client.establish(EstablishRequest( applicationAnchor="my-app", )) # inquiry.exposureKey, inquiry.hiddenKey ``` Request fields are camelCase (`applicationAnchor`, `returnMethods`) because they’re generated from the OpenAPI schema. Method names are snake\_case. ## Status poll — check the session [Section titled “Status poll — check the session”](#status-poll--check-the-session) * TypeScript ```ts const status = await client.statusPoll({ exposureKey, hiddenKey }); if (status.status === "REALIZED") { const { confirmationKey } = status; // hand off to redeem } ``` The response is discriminated on `status` (`"PENDING"` or `"REALIZED"`); only `REALIZED` carries a `confirmationKey`. * Python ```python from sudomimus_connect import StatusPollRequest status = client.status_poll(StatusPollRequest( exposureKey=exposure_key, hiddenKey=hidden_key, )) ``` ## Redeem — exchange for tokens [Section titled “Redeem — exchange for tokens”](#redeem--exchange-for-tokens) * TypeScript ```ts const tokens = await client.redeem({ exposureKey, hiddenKey, confirmationKey, }); const { accessToken, refreshToken } = tokens; ``` * Python ```python from sudomimus_connect import RedeemRequest tokens = client.redeem(RedeemRequest( exposureKey=exposure_key, hiddenKey=hidden_key, confirmationKey=confirmation_key, )) # tokens.accessToken, tokens.refreshToken ``` ## Refresh / introspect / logout [Section titled “Refresh / introspect / logout”](#refresh--introspect--logout) * TypeScript ```ts // Refresh rotates the token — capture and persist the new one for next time. const { accessToken, refreshToken: newRefreshToken } = await client.refresh({ refreshToken }); await store.saveRefreshToken(newRefreshToken); const { status, recommendedRecheckSeconds } = await client.introspect({ accessToken }); // status: "active" | "revoked" | "expired" | "not_found" await client.logout({ refreshToken }); const { revokedCount } = await client.revokeAll({ subject }); ``` Endpoint semantics are documented in [Managing sessions](/en-us/guides/managing-sessions/). `/refresh` rotates the refresh token — it returns a fresh `accessToken` **and** a new `refreshToken`, and the presented one is consumed; persist the replacement and use it next time. `/refresh`, `/introspect`, and `/logout` are self-authenticating (the token is the credential); `/revokeAll` requires the client-auth credentials configured at construction time. * Python ```python from sudomimus_connect import ( RefreshRequest, IntrospectRequest, LogoutRequest, RevokeAllRequest, ) fresh = client.refresh(RefreshRequest(refreshToken=tokens.refreshToken)) state = client.introspect(IntrospectRequest(accessToken=tokens.accessToken)) client.logout(LogoutRequest(refreshToken=tokens.refreshToken)) revoked = client.revoke_all(RevokeAllRequest(subject="sub_9SQ5535CRWNDDM2T")) # revoked.revokedCount ``` ## Info — fetch application metadata [Section titled “Info — fetch application metadata”](#info--fetch-application-metadata) * TypeScript ```ts const info = await client.info({ applicationAnchor, locale: "en-US", }); // info.applicationName, info.applicationPublicKey ``` * Python ```python from sudomimus_connect import InfoRequest info = client.info(InfoRequest( applicationAnchor="my-app", locale="en-US", )) # info.applicationName, info.applicationPublicKey ``` ## Verify access tokens [Section titled “Verify access tokens”](#verify-access-tokens) * TypeScript ```ts import { TokenError } from "@sudomimus/token"; try { const token = await client.verifyAccessToken(rawJwt); // token.body.subject, token.body.firstName, token.body.lastName? } catch (error) { if (error instanceof TokenError) { // structured token verification failure } throw error; } ``` `verifyAccessToken` parses the JWT, checks `kty === "Access"`, validates expiration, then calls `/info` for the `applicationPublicKey` (cached per `applicationAnchor`) and checks the signature. Use `verifyRefreshToken` for the matching `kty === "Refresh"` flow, and `clearPublicKeyCache()` to force a refetch after a key rotation. Connect access tokens are verified per-application, not against a shared JWKS. See [Tokens and verification](/en-us/concepts/tokens-and-verification/) for the full picture. * Python access.body.subject ```python access = client.verify_access_token(tokens.accessToken) ``` `verify_refresh_token` is the matching refresh-token method. ## Handle API errors [Section titled “Handle API errors”](#handle-api-errors) * TypeScript ```ts import { ConnectApiError } from "@sudomimus/connect"; try { await client.redeem({ exposureKey, hiddenKey, confirmationKey }); } catch (error) { if (error instanceof ConnectApiError) { // error.status — HTTP status // error.reason — stable machine-readable code (when present) // error.body — raw `{ reason?: string }` payload (when present) } throw error; } ``` The `reason` field is omitted when the underlying failure symbol is marked `PRIVATE`; in that case only the HTTP status carries signal. * Python Python raises a typed exception with the same fields. See the [`sudomimus-connect` package README](https://github.com/sudomimus/sudomimus/tree/master/sdks/python/packages/sudomimus-connect) for the exact class name and attributes. ## C\# [Section titled “C#”](#c) The C# SDK ships two packages today, both alpha: * **`Sudomimus.Native`** — direct-issue client for `native-api` (Steam ticket, AccessKey). * **`Sudomimus.Token`** — token-parsing utilities, the same role as `@sudomimus/token`. A `Sudomimus.Connect` package is **not yet shipped** — for the Connect protocol from C#, call the HTTPS endpoints directly (see [Web applications](/en-us/connect/flow/) for the request shapes) and use `Sudomimus.Token` to verify the tokens you receive. Source: [`sdks/csharp/src`](https://github.com/sudomimus/sudomimus/tree/master/sdks/csharp/src). ## Source [Section titled “Source”](#source) [SDK monorepo on GitHub ](https://github.com/sudomimus/sudomimus)Source, issues, and release tags for every official SDK package. [OpenAPI contracts ](https://github.com/sudomimus/sudomimus-spec)sudomimus/sudomimus-spec — the authoritative connect.yaml and native.yaml schemas the SDKs generate from.