# Udibo Identity > Managed identity service in private beta. Begin with first login, then add product features. Full public guides: https://www.udibo.com/docs/identity/llms-full.txt ## Udibo Identity ### Start here - [Start with Udibo](https://www.udibo.com/docs/identity): Choose managed identity or app-owned authentication and understand what you operate. - [Connect your first application](https://www.udibo.com/docs/identity/get-started): Get beta access, register an application, and complete your first sign-in. - [Configure an application](https://www.udibo.com/docs/identity/applications): Configure client credentials, callbacks, grants, and token validation for each environment. - [Understand the pieces](https://www.udibo.com/docs/identity/concepts): Understand tenants, applications, users, organizations, sessions, and permissions. - [Choose a starting project](https://www.udibo.com/docs/identity/starter-projects): Choose a runnable example for a new project and know what needs replacing before production. - [Build with an agent](https://www.udibo.com/docs/identity/agents): Give a coding agent the integration context, public sources, and acceptance checks it needs. ### Sign-in methods - [Social login](https://www.udibo.com/docs/identity/social-login): Connect Google, GitHub, Discord, Apple, or an OIDC provider and understand account linking. - [Passwordless sign-in](https://www.udibo.com/docs/identity/passwordless): Enable email codes and magic links, with their lifetimes and sign-in rules. - [Multi-factor authentication](https://www.udibo.com/docs/identity/mfa): Configure authenticator apps, recovery codes, and your MFA policy. - [Sign-up fields and custom metadata](https://www.udibo.com/docs/identity/signup-fields): Collect additional account details and choose who can edit each field. ### Build your product - [Organizations](https://www.udibo.com/docs/identity/organizations): Group users into customer companies with invitations, memberships, and organization roles. - [Permissions and Roles](https://www.udibo.com/docs/identity/permissions): Define permissions, assign roles, and enforce access in your application. - [Build a B2B SaaS application](https://www.udibo.com/docs/identity/b2b-saas-template): Build a B2B app with sign-in, organizations, roles, and verified webhooks. - [Build a consumer application](https://www.udibo.com/docs/identity/b2c-app-template): Build a consumer app with account recovery, verified email, and user-owned data. - [Build a client portal](https://www.udibo.com/docs/identity/client-portal-template): Build a client portal with document sharing and live resource permission checks. - [Protect an MCP server](https://www.udibo.com/docs/identity/mcp-server-template): Protect a stateless MCP server with hosted sign-in and organization permissions. ### Move to Udibo - [Move an existing application to Udibo](https://www.udibo.com/docs/identity/migrate): Move an existing application in stages, preserve user identity, and rehearse rollback. - [User import](https://www.udibo.com/docs/identity/user-import): Import user profiles and supported password hashes, with an upgrade on first sign-in. ### Go live and operate - [Webhooks](https://www.udibo.com/docs/identity/webhooks): Verify event signatures, handle duplicate deliveries, and understand retry behavior. - [Audit log](https://www.udibo.com/docs/identity/audit-log): Investigate tenant activity and export audit events as CSV or NDJSON. - [Get ready for production](https://www.udibo.com/docs/identity/production): Review environments, sessions, authorization, recovery, and launch readiness. - [Find the next check](https://www.udibo.com/docs/identity/troubleshooting): Diagnose callback, cookie, scope, sign-in, and permission problems in order. --- Source: https://www.udibo.com/docs/identity # Start with Udibo Udibo gives you two ways to add authentication: connect your application to **Udibo Identity**, the managed service, or use **@udibo/oauth2** to run authentication inside your own application. Choose who operates identity first; the rest of your setup follows from that decision. ## Choose your path | You want to… | Start here | You operate | | ------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------ | | Let Udibo manage sign-in, accounts, and recovery | [Connect your first application](https://www.udibo.com/docs/identity/get-started) | Your application, its sessions, and its data authorization | | Replace an existing identity integration | [Move an existing app](https://www.udibo.com/docs/identity/migrate) | The rollout, identity mapping, and application changes | | Own credential storage and run authentication yourself | [Package quickstart](https://www.udibo.com/docs/oauth2/quickstart) | Your authentication service, persistence, email, and security operations | | Give a coding agent the implementation task | [Build with an agent](https://www.udibo.com/docs/identity/agents) | The integration decisions and review of its result | You can use the package as the client of the managed service. Installing it does not require you to run an authorization server or build password-reset pages. ## What the managed service takes care of Your users sign in on your tenant's hosted pages. You configure the sign-in methods, account creation, and MFA policy; your application receives the result through an OAuth2 authorization-code flow. - [Social sign-in](https://www.udibo.com/docs/identity/social-login), [email codes and magic links](https://www.udibo.com/docs/identity/passwordless), and [MFA](https://www.udibo.com/docs/identity/mfa) are configured in your tenant - [Organizations](https://www.udibo.com/docs/identity/organizations) group your customers; [roles and permissions](https://www.udibo.com/docs/identity/permissions) express what they can do in your application - [Webhooks](https://www.udibo.com/docs/identity/webhooks) deliver events to your backend; the [audit log](https://www.udibo.com/docs/identity/audit-log) helps you investigate activity Your backend still protects your application's records. A valid session proves who someone is; it does not, by itself, give them access to another customer's data. The [concepts guide](https://www.udibo.com/docs/identity/concepts) explains that boundary with examples. ## What the package gives you @udibo/oauth2 supplies OAuth2/OIDC clients, authorization and resource servers, Hono integration, a backend-for-frontend (BFF), and React bindings. A BFF is your app's backend layer that keeps tokens on the server and gives the browser a session cookie. The package's [documentation index](https://www.udibo.com/docs/oauth2/index) separates managed integration from app-owned authentication. Its [known limitations](https://www.udibo.com/docs/oauth2/known-limitations) and [runtime support](https://www.udibo.com/docs/oauth2/stability) belong in your evaluation before you choose storage or deploy. ## Availability and fit **Udibo Identity is in private beta.** Start at [Udibo](https://www.udibo.com/) for the current access path. The hosted guides assume you have beta access and a tenant; they do not promise immediate public registration. You can explore the package's [local identity provider](https://www.udibo.com/docs/oauth2/guides/run-a-local-identity-provider) without a managed account. The hosted service currently offers password, social, email-code, and magic-link sign-in, with TOTP MFA and recovery codes. Hosted passkeys, SAML federation, and SCIM provisioning are not available. If one is a launch requirement, evaluate an alternative that provides it today. The [comparison guides](https://www.udibo.com/docs/compare) explain the tradeoffs without treating planned features as available features. ## Your first milestone Aim for a browser sign-in that returns to your application, an authenticated API request, and a sign-out that ends the application's session. Once that works, add the capabilities your product needs and use the [production checklist](https://www.udibo.com/docs/identity/production) before inviting real users. **Next:** [Connect your first application](https://www.udibo.com/docs/identity/get-started). _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/get-started # Connect your first application By the end of this guide, a user will sign in on your Udibo tenant, return to your application, and call an API protected by their application session. Start with one development application and one test account. ## Before you start You need **private-beta access**, a tenant you can manage, and a web application with a backend. Start at [Udibo](https://www.udibo.com/) for access. If you are waiting, run the [local package quickstart](https://www.udibo.com/docs/oauth2/quickstart) to explore the flow. In the dashboard, **Identity** lists the tenants you can manage. Create or select a development tenant, then keep its dashboard open while connecting your app. If you cannot access a required setting, ask your tenant administrator to grant the corresponding access; an application user's role does not grant dashboard administration. For a new project, [choose a starter](https://www.udibo.com/docs/identity/starter-projects). For an existing app, add this integration alongside your current login and switch it on for test accounts first. Follow the [migration plan](https://www.udibo.com/docs/identity/migrate) before moving users. ## 1. Register the application Open your tenant's **Applications** page and choose **Create application**. For a web app whose backend handles the callback: | Setting | Development starting point | Why | | ------------------- | -------------------------------------------------- | -------------------------------------------------- | | Name | A name you recognize, such as “My app development” | Distinguishes this registration from production | | Environment | Development | Permits a local HTTP callback | | Confidential client | Enabled | Your backend can protect the client secret | | Grants | Authorization Code and Refresh Token | Signs users in and renews their access | | Redirect URIs | Your app's complete callback URL | The service returns the browser here after sign-in | | Access token format | Opaque | Your API validates tokens by introspection | For example, an app running at `http://localhost:8000` with its BFF mounted at `/auth` uses `http://localhost:8000/auth/callback`. Match the actual port and path. Use that same URL in your application's configuration. Save the application. **Saving creates the client ID; it does not create a secret.** On the application's detail page, use **Create new secret** and store the one-time value in your backend's secret configuration. Secret management requires separate administrator permission. The [application configuration guide](https://www.udibo.com/docs/identity/applications) explains token formats, scopes, production callbacks, and public clients. ## 2. Gather the configuration Keep these values together for this environment: - The tenant's trusted **issuer URL**, supplied through your beta setup - This application's **client ID** and **client secret** - The exact **callback URL** you just registered - The **scopes** your integration needs; request `openid`, and add `profile` and `email` when you need those identity claims - A separate, high-entropy **application secret** to protect pending login state The issuer is the identity host, not your application's host and not the dashboard URL. Read its discovery document at `{issuer}/.well-known/oauth-authorization-server`; the client can discover the protocol endpoints instead of copying them into every route. Use environment variables or your deployment's secret manager for credentials. Do not put the client secret in browser configuration, a public repository, or an agent prompt. ## 3. Connect the backend and browser Follow [Use Udibo's identity service](https://www.udibo.com/docs/oauth2/guides/use-udibo). It contains the checked Hono BFF wiring and the browser client in one place. The integration has three pieces: 1. Your backend mounts `HonoBff` at `/auth`, exchanges the authorization code, and stores the application's session. 2. Your API validates the access token. With opaque tokens, use introspection against the issuer, as shown in [Protect an API](https://www.udibo.com/docs/oauth2/guides/protect-an-api). 3. Your browser uses `BffClient` to start login and make authenticated requests to the same application origin. React applications can use the package's provider and hooks for session presentation. The guide explicitly marks the session store and resource server as dependencies you supply. A declared variable in a snippet is not a working database adapter. Use a [runnable example](https://www.udibo.com/docs/identity/starter-projects) if you want to inspect all the pieces together before adapting an existing application. ## 4. Complete a sign-in Use a test user in the tenant. For self-registration, configure the tenant's sign-up mode to allow it and enable the intended sign-in method. A closed sign-up mode does not prevent an existing eligible user from signing in. Open your application in a browser and start login from the application. The browser should visit the tenant's hosted sign-in page, complete authentication, and return to your registered callback. Your app then reports an authenticated session. **Checkpoint:** a protected request succeeds while signed in. In a separate signed-out browser context, the same protected request is refused. Signing out ends the application's session; the tenant's SSO session is a separate session, so test the logout behavior you intend to offer. ## Account sessions in your application Use the person's access token on your tenant's host to build device controls: | Request | Result | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `GET /api/account/sessions` | `{sessions: [...]}` with opaque IDs, creation and activity times, address, user agent and a `current` marker | | `DELETE /api/account/sessions/{sessionId}` | 204 after revoking one other login session | | `POST /api/account/sessions/revoke-others` | `{revoked: number}` after revoking the person's other login sessions | These operations require no additional scope or dashboard seat. The credential selects the person and tenant; request parameters cannot select another account. Machine credentials are refused. Session lists exclude revoked, expired and idle sessions and never include secrets or tokens. `current` identifies the login that issued the credential. A credential can outlive that login: then no row is current, and revoke-others returns 409 with `reason: "current_session_required"`. Deleting the current login returns 409 with `reason: "current_session"`; use the tenant's end-session endpoint to sign out. An unknown session ID and another person's ID both return the same 404. Revocation stops session-owned tokens on the next request. A third-party grant that originated from the login retains its existing lifetime. Revoke-others also revokes idle sessions, even though the list omits them. Mutations accept no body or an empty object and share a budget of 30 attempts per minute per person with the hosted security page. A 429 includes `Retry-After` in seconds. ## 5. Make the first success repeatable - Repeat the flow after reloading the page and after restarting the backend - Confirm expired or revoked sessions cannot be restored by a refresh race - Confirm the browser has no access or refresh tokens in local storage - Test one operation without its required permission; it must be refused Use [troubleshooting](https://www.udibo.com/docs/identity/troubleshooting) if a checkpoint fails. Once it passes, choose [sign-in methods](https://www.udibo.com/docs/identity/social-login), add [organizations](https://www.udibo.com/docs/identity/organizations) and [permissions](https://www.udibo.com/docs/identity/permissions) where needed, and work through the [production checklist](https://www.udibo.com/docs/identity/production). _Last verified 2026-09-09._ --- Source: https://www.udibo.com/docs/identity/applications # Configure an application An application registration tells your tenant which client is signing someone in, where it can send the browser afterward, and which token behavior to use. Configure it in your tenant's **Applications** page. Start with the [first-sign-in guide](https://www.udibo.com/docs/identity/get-started) if you have not connected an app yet. ## Keep environments separate Register development and production as separate applications with different client credentials. For stronger separation of user data and sign-in policy, use separate tenants as well: changing the application environment does not create a separate user directory. Use **Development** for local web callbacks such as `http://localhost:8000/auth/callback`. Use **Production** with exact HTTPS web callbacks. Development wildcard patterns are not allowed in production. Native-app loopback IP callbacks and custom schemes have different rules from a hosted web callback. For a web project, use an exact HTTPS address in production instead of relying on a native-client exception. ## Choose the client type | Your application | Client configuration | Where credentials live | | ---------------------------------------- | ---------------------------- | --------------------------------------------------------------------------- | | Web app with a backend or BFF | Confidential | Client secret and tokens on the backend | | Browser-only app or installed native app | Public | No client secret; code shipped to users cannot keep one | | Background service | Separate machine application | Server-side credentials; requires an application owner and permitted scopes | For a browser app with a backend, use the confidential BFF path even if the UI is a React SPA. The backend is the OAuth2 client in this design. Machine applications cannot combine Client Credentials with Authorization Code. The dashboard registration form cannot currently assign the machine application's owner. Coordinate that configuration through your beta setup; selecting the grant alone does not produce a working machine integration. ## Register the callback exactly The redirect URI is the endpoint **in your app** that finishes the login, such as `https://app.example.com/auth/callback`. It is different from the callback you register at a social provider, which returns to the identity service. Check the scheme, hostname, port, path, and query. A callback configured on `localhost` is not the same address as one configured on `127.0.0.1`. A change to your reverse proxy, preview URL, or auth mount may require updating both the registration and your backend configuration. Use an application-controlled return path after the callback. Do not accept arbitrary external redirect destinations from a browser parameter. ## Select grants and identity scopes For browser sign-in with session renewal, select **Authorization Code** and **Refresh Token**. Keep PKCE and state validation enabled in the client. Request `openid` for OIDC sign-in, `profile` for supported profile claims, and `email` when your application needs the address or verification status. Read those claims from UserInfo when needed; do not assume the access token is a complete user profile. OAuth2 **scopes** and your application's **permissions** answer different questions. A scope limits what a token can request. An application permission describes what this user may do, such as `projects:archive`. Register and assign those capabilities through [permissions and roles](https://www.udibo.com/docs/identity/permissions), and enforce them on the backend. ## Choose how your API validates tokens | Format | Validation | Revocation and permission freshness | | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Opaque | Ask the issuer's introspection endpoint | The service checks live token state; caching your own introspection result delays that observation | | JWT | Verify signature, issuer, audience, and expiry using JWKS | Offline verification observes the token's issued claims until expiry; use an online check where you need current state | For JWT access tokens, configure the intended API **audience**. Leaving it blank uses the client ID. Your API must reject a token intended for another audience. Hosted introspection requires a confidential application's credentials and only answers for tokens issued to that application. Public client IDs and another application's credentials receive `{ "active": false }`, including for refresh tokens. A separate API serving public or multiple client applications should use JWT access tokens with its explicit API audience; registering a confidential client alone does not grant cross-application introspection access. See [Protect an API](https://www.udibo.com/docs/oauth2/guides/protect-an-api) for the checked implementation of each mode. ## Create and rotate secrets After saving a confidential client, open its detail page and choose **Create new secret**. Copy the one-time value into the backend secret manager. If the action is unavailable, your tenant access must include secret management. For a rotation, create a replacement, deploy it to all application instances, verify a new login and refresh, and then revoke the previous secret. Coordinate the overlap with your deployment; deleting the old value before every instance has moved can interrupt authentication. **Next:** learn the [identity concepts](https://www.udibo.com/docs/identity/concepts), or use [troubleshooting](https://www.udibo.com/docs/identity/troubleshooting) to diagnose an integration failure. _Last verified 2026-09-07._ --- Source: https://www.udibo.com/docs/identity/concepts # Understand the pieces Imagine you are building a project-management app. People sign in, join a company, and work on that company's projects. These are the pieces that connect the identity service to your product. ## Your service configuration | Term | In your project-management app | | ----------------- | ------------------------------------------------------------------------------- | | Tenant | The user directory and sign-in policy for your product | | Application | An OAuth2 client registration for your web app, mobile app, or backend service | | Issuer | The trusted identity-service URL that issues credentials | | Identity provider | An external sign-in connection, such as Google or your configured OIDC provider | | Administrator | A person with permission to configure your Udibo tenant | A development application and a production application can share a tenant, but then they share its user directory. Use separate tenants if you need separate test users or different tenant-wide policy. ## Your customers A **user** is a person in your tenant. An **organization** groups those users into a customer company or team. One user can belong to several organizations. A consumer app can have users without using organizations at all. Use the validated identity's **subject** (`sub`) as an identity key. If your app accepts more than one issuer, key the external identity by both issuer and subject. An email address can change; it is not a durable authorization key. An organization's Owner, Admin, and Member **membership tiers** manage the organization. Your application's **roles** bundle the permission strings you define, such as `projects:read` and `projects:archive`. Keep those concepts separate: being an organization admin does not automatically make someone an administrator of your Udibo tenant. ## A login creates more than one session 1. The browser visits the tenant's sign-in pages. The tenant establishes its identity session. 2. The browser returns an authorization code to your application's callback. 3. Your backend exchanges the code for tokens and creates its own application session. The browser carries an HttpOnly session cookie for your app. An **access token** authorizes requests to an API. A **refresh token** lets the backend obtain a new access token. An **ID token** describes an authentication result; it is not a substitute for an API access token. Your application's session and the tenant's SSO session have separate lifecycles. Clearing your app's cookie does not necessarily sign the person out of the identity service. Decide which outcome your sign-out button promises, and test that outcome. ## Authentication is the first check Suppose Alice belongs to Acme and Bob belongs to Beacon. Both can sign in. That does not give Bob permission to read an Acme project. Your backend should establish the caller, find the project's actual organization from trusted application data, and check authority for that organization or resource. A browser-supplied organization ID is a requested target, not proof of membership. The [permissions guide](https://www.udibo.com/docs/identity/permissions) covers three useful questions: - **Tenant-wide:** may this person use a capability throughout my product? - **Organization-specific:** may this person act in this named company? - **Resource-specific:** may this person act on this particular document? The active organization in a credential answers for one organization. It does not confer authority over all organizations a browser can name. A React guard can hide a button; the backend must still refuse an unauthorized request. ## Choose the right source for each answer | What you need | Read it from | | -------------------------------------------------- | ------------------------------------------------------------------ | | Session presentation in the browser | Your BFF's session response | | The caller of a protected API request | Validated access-token information | | Profile and email verification | UserInfo with the relevant scopes | | Current access to a named organization or resource | The permission mechanism described in the public permissions guide | | Application records and their owners | Your application's database | A cached session projection is useful for rendering. It can be older than the permissions resolved during token introspection. Do not make a backend access decision from the browser's copy. **Next:** [Choose a starter](https://www.udibo.com/docs/identity/starter-projects), or add [organizations](https://www.udibo.com/docs/identity/organizations) to a connected application. _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/starter-projects # Choose a starting project Choose the smallest example that demonstrates your application's boundary. First get its login and protected request working; then replace the example's data and storage with your product's implementation. ## Run a public package example The [package quickstart](https://www.udibo.com/docs/oauth2/quickstart) includes the clone, install, and run commands for a complete local Hono application. It needs Deno 2 and Git, and does not need a hosted account, database, or email service. | Your project | Example or guide | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Hono app that owns its authentication | [Local Hono application](https://www.udibo.com/docs/oauth2/examples/hono/app-with-own-auth) | | Hono app connecting to a separate issuer | [Hono with external auth](https://www.udibo.com/docs/oauth2/examples/hono/app-with-external-auth) | | Server-rendered React app with delegated sign-in | [Juniper with external auth](https://www.udibo.com/docs/oauth2/examples/juniper/app-with-external-auth) | | An API without a frontend | [Hono API service](https://www.udibo.com/docs/oauth2/examples/hono/api-service) | | A new React app that runs its own authentication | [Juniper template](https://www.udibo.com/docs/oauth2/templates/juniper) or [React Router template](https://www.udibo.com/docs/oauth2/templates/react-router) | | An existing app using managed identity | [Use Udibo](https://www.udibo.com/docs/oauth2/guides/use-udibo) | The examples are development fixtures. In-memory stores reset on restart and demo credentials are public. Follow the example's instructions and the [deployment guide](https://www.udibo.com/docs/oauth2/guides/production-deployment) before using it as the basis of a production app. ## Explore the service walkthroughs These guides explain how to assemble a product on the hosted service: | Product shape | What you will learn | | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | [B2B SaaS](https://www.udibo.com/docs/identity/b2b-saas-template) | Organizations, a protected project list, organization-specific actions, and verified webhooks | | [Consumer app](https://www.udibo.com/docs/identity/b2c-app-template) | Hosted account pages, profile claims, verified email, and records belonging to one user | | [Client portal](https://www.udibo.com/docs/identity/client-portal-template) | Per-document access, organization grants, and checks for a list of resources | **These service templates do not yet have standalone public distributions.** Read the walkthroughs as implementation guides. Use a public package example for a runnable starting point, or coordinate access to the service templates through your beta setup. A hosted account alone does not grant repository access. ## If you have beta template access Complete [application registration](https://www.udibo.com/docs/identity/get-started) first. The templates expect these server-side settings: | Variable | Value | | ---------------------- | ---------------------------------------------------------------------------------- | | `UDIBO_ISSUER` | Your tenant's trusted issuer origin | | `APP_ORIGIN` | This application's origin; its registered callback is `{APP_ORIGIN}/auth/callback` | | `UDIBO_CLIENT_ID` | The confidential application registration's ID | | `UDIBO_CLIENT_SECRET` | Its one-time client secret | | `UDIBO_WEBHOOK_SECRET` | B2B SaaS only: the secret for the endpoint at `{APP_ORIGIN}/webhooks/udibo` | In the provided development checkout, run `deno task dev` from the selected template directory after configuring its environment. The default development ports are 8007 for B2B SaaS, 8008 for the consumer app, and 8009 for the client portal. Register the callback for the origin you actually run. The B2B and portal walkthroughs require the relevant organization, permission, and resource setup too. Complete those steps through the supported administrator workflow; do not embed an administrator's session in your application. ## Make the example your own Keep the authentication boundary while replacing the demonstration data. Implement durable sessions and application storage, preserve resource ownership checks, configure production callbacks and secrets, and test both successful and refused requests. A frontend route guard does not protect the API. **Next:** [Connect your first application](https://www.udibo.com/docs/identity/get-started), then [prepare for production](https://www.udibo.com/docs/identity/production). _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/agents # Build with an agent Give your coding agent the integration path and a definition of success before asking it to add authentication. This avoids a common wrong turn: implementing password storage and login forms when the application is meant to use Udibo's hosted pages. ## Give it this starting context Replace the bracketed values, then copy this brief. Supply secret **variable names**, never credential values. ```text Add authentication to this application using Udibo Identity. Integration path: managed identity service. Framework and runtime: [framework, runtime, and versions]. Application backend: [entrypoint and routing conventions]. Development origin and callback: [exact URLs]. Issuer configuration: [environment variable name]. Client ID/secret configuration: [server-side variable names]. Session persistence: [existing storage, or a decision to make]. Existing identity mapping: [app user ID and current provider, if migrating]. Authorization: [which records belong to a user or organization]. Read https://www.udibo.com/llms.txt first. Read https://www.udibo.com/docs/identity/get-started and https://www.udibo.com/docs/oauth2/guides/use-udibo. Use the documented exports and checked examples. Ask for missing setup values. Keep tokens and client secrets on the backend. Preserve PKCE, state, and CSRF. Prove sign-in, refresh, logout, and refusal of unauthorized API requests. Report remaining app-owned storage and deployment work explicitly. ``` For app-owned authentication, change the path in the brief and start with the [package quickstart](https://www.udibo.com/docs/oauth2/quickstart), [Add login](https://www.udibo.com/docs/oauth2/guides/add-login), and [application deployment](https://www.udibo.com/docs/oauth2/guides/production-deployment). That path includes operating user storage, credentials, delivery, and protections. ## Public sources it can fetch | Resource | Use it for | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | [Site index](https://www.udibo.com/llms.txt) | All published guides and comparisons, with absolute links | | [Service index](https://www.udibo.com/docs/identity/llms.txt) | The hosted-service reading order | | [Full service guides](https://www.udibo.com/docs/identity/llms-full.txt) | The public service material in one text response | | [Package index](https://www.udibo.com/docs/oauth2/llms.txt) | Package integration rules and guide selection | | [Full package docs](https://www.udibo.com/docs/oauth2/llms-full.txt) | The package corpus in one text response | | [Typed package reference](https://jsr.io/@udibo/oauth2/doc) | Exported subpaths and exact API signatures | | [Management API reference](https://www.udibo.com/docs/api) | Published HTTP operations; check the service guide's availability notes too | Start with the index and fetch only the relevant pages when context is limited. The full-text resources are useful when the agent needs to compare behavior across guides. None requires access to Udibo's internal documentation. ## Integration rules - A managed-service client consumes identity; it does not implement token issuance or the tenant's password and MFA pages - Browser session state is for presentation. Protect the API independently - Validate issuer, audience, expiry, and signature or introspection state before trusting token claims - A scope, a permission, and an organization membership are different things; follow the [concepts guide](https://www.udibo.com/docs/identity/concepts) - Use actual exported package subpaths. A `declare const` in a guide marks an application-owned dependency that still needs implementation - In-memory stores are development fixtures. Test a production session adapter's atomic update and revocation behavior with the package's contract tests - Preserve the previous identity provider and identity mapping during a [migration](https://www.udibo.com/docs/identity/migrate) until rollback has been rehearsed - Do not infer a public hosted endpoint, provisioning ability, pricing promise, or enterprise feature from a package interface or an API operation's name ## Ask for evidence at handoff The agent should show which callback and protected routes changed, where secrets and sessions live, which checks passed, and what needs deployment configuration. Have it verify a successful login, a signed-out refusal, insufficient authority, access to another user's record, refresh after expiry, and logout. **Next:** [Connect your first application](https://www.udibo.com/docs/identity/get-started) or [move an existing app](https://www.udibo.com/docs/identity/migrate). _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/social-login # Social login **Purpose:** let your users sign in to your application with an account they already have. You configure a connector per provider in your dashboard; the provider's button appears on your tenant's hosted sign-in page; we run the whole flow and hand your application a signed-in user, exactly as if they had typed a password. Built-in connectors exist for **Google**, **GitHub**, **Discord**, and **Apple**. Any other provider that speaks OpenID Connect — an enterprise IdP, another platform, your own — is a **generic OIDC** connector: give us its issuer URL and we take the rest from the provider's own discovery document. ## How an account is matched - **A linked identity is keyed by the provider's stable subject identifier, never by email.** An email address can change hands; the provider-and-subject pair cannot. Someone who changes their email at the provider keeps their account with you. - **A verified email collision links automatically.** When a provider asserts a **verified** email that equals the **verified** email of an existing account, the identity is attached to that account and the sign-in proceeds. If either side is unverified — the provider did not vouch for the address, or the account never confirmed it — the sign-in is refused instead of guessing, because an unverified match is how account takeover by email squatting works. - **An unknown identity provisions a new account**, subject to your tenant's sign-up mode. A closed tenant refuses self-serve provisioning the same way it refuses the sign-up form. A waitlist-only tenant **captures** the provider's email onto the waitlist instead of refusing — sending the same confirmation email and landing on the same you're-on-the-list page as the join form, idempotently — but only when the provider **asserts that email as verified**. Without a verified address there is nothing safe to put on a list, so a waitlist-only tenant refuses exactly as the form does. And a waitlist invitation can be redeemed through a provider button when the provider asserts a verified email equal to the invited address. - **Signed-in users connect and disconnect providers themselves** on the hosted `/security` page. Connecting or disconnecting a sign-in method emails the account owner, and the last remaining way to sign in cannot be disconnected — set a password or connect another provider first. ## Linked accounts in your application Send the person's access token to `GET /api/account/linked-accounts` on your tenant's host. The response contains `identities` and `hasPassword`. Each identity includes its opaque `id`, provider key, provider `displayName`, email when available, and `createdAt`. Raw provider subjects, tokens and connector secrets are omitted. Linked identities remain listed when their provider is disabled; available providers are not part of this response. `DELETE /api/account/linked-accounts/{identityId}` disconnects that person's identity and returns 204. It shares the hosted page's last-method guard, audit and owner notification. The last usable sign-in method returns 409 with `reason: "last_method"`; an unknown, malformed or another person's ID returns the same 404. Disabled providers do not count as a remaining usable method. The existing notification is attempted for an owner with a verified email; delivery failure does not undo the unlink. These caller-only operations require no management scope or dashboard seat. Machine credentials are refused, and the operator host answers 404. A mutation accepts no body or an empty object; no subject or tenant selector is accepted. To connect another account, send the person to the tenant's hosted `/security` page. There is no application-supplied return address for that flow. ## Setting up a connector In the dashboard: **Providers** → **Add provider**. Pick the provider type, then paste the client ID and client secret from the provider's own console. Apple's inputs are different: its client ID is the **Services ID** configured for Sign in with Apple on the web (not the App ID), it has no client secret — in its place you paste the contents of the **`.p8` private key** Apple lets you download once — and it also asks for your **Team ID** and **Key ID**. The form shows the exact callback URL to register with the provider: ``` https://{your-tenant-host}/auth/social/{provider}/callback ``` Providers match that URL exactly, so every origin your sign-in page is served on needs its own registration in the provider's console. For a generic OIDC connector the URL segment is the connector's row id rather than a type name, because a tenant may have several OIDC connectors; built-in types are limited to one connector each. Client secrets are sealed with authenticated encryption at rest, and reads — dashboard and API alike — report only whether a secret is stored, never its value. Each connector has its own **enabled** toggle, so you can stage one without offering it: a disabled connector keeps its configuration and its linked identities, and its button simply does not render. ## Generic OIDC A generic OIDC connector needs the provider's **issuer URL**. Configuration is read from the provider's own discovery document (`{issuer}/.well-known/openid-configuration`), so there are no endpoint URLs to transcribe. By default the flow requests the `openid`, `email`, and `profile` scopes; set the connector's scopes to override that. The issuer must be a public `https` URL. We refuse issuers that resolve to private, loopback, or otherwise internal addresses — at save time and again on every outbound call — because your issuer URL is a URL our servers fetch. ## Turning social sign-in on and off Two switches have to agree before a button renders on the sign-in page: the tenant-wide **social** sign-in method (dashboard: **Security** → **Sign-in methods**), and the connector's own **enabled** toggle. The tenant-wide method is also writable through the management API: | Method | Path | Scope | | ------- | ---------------------------- | ------------------------ | | `PATCH` | `/api/identity/tenants/{id}` | `identity:tenants:write` | The `authMethods` object you send is **merged into the current setting** — a switch you omit keeps whatever it is set to, so a request that changes the social switch can name social alone and leave the rest of your policy untouched (send `false` to turn it off): ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const response = await fetch( `https://www.udibo.com/api/identity/tenants/${tenantId}`, { method: "PATCH", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}`, "content-type": "application/json", }, body: JSON.stringify({ authMethods: { social: true } }), }, ); if (!response.ok) throw new Error(`update failed: ${response.status}`); ``` `password`, `emailCode` and `emailLink` keep their current values here — the request says nothing about them. Send all four if you would rather state the whole policy; the result is the same when the values agree. A request that would leave every method off is refused, checked against the merged result. `{id}` is your tenant's id — the same one in your dashboard URL — and the token must hold administrative authority over that tenant. Connector rows themselves are managed in the dashboard; there is no connector management API today. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. _Last verified 2026-09-09._ --- Source: https://www.udibo.com/docs/identity/passwordless # Passwordless sign-in **Purpose:** let your users sign in with nothing but access to their inbox. Two flows, independently switchable, both built into your tenant's hosted sign-in page — no code to write and nothing to render yourself. - **Email code:** the sign-in page emails a **6-digit code** and the user types it into the same page. A code is **single-use** and expires after **10 minutes**. - **Magic link:** the sign-in page emails a link. Opening it shows a confirmation button — so a mail client or security scanner that prefetches the URL cannot spend it — and confirming completes the sign-in on that device. A link is **single-use** and expires after **15 minutes**; an expired one sends the user back to the sign-in page to request a fresh one. ## The same rules as every other sign-in Both flows land in the same completion path as password and social sign-in, so passwordless is not a side door: - **Multi-factor authentication still applies.** A user who owes a challenge under your [MFA policy](https://www.udibo.com/docs/identity/mfa) is challenged before any session exists — an emailed code does not stand in for a second factor. - **Ineligible accounts are refused indistinguishably.** A disabled or deleted account answers exactly like a wrong code or a dead link, so the flow never confirms whether an address has an account. - **Requesting a code or link never reveals anything either.** The response is the same whether or not the address is known, and requests are rate-limited per source and per address. - **A passwordless session is never a remembered session.** An emailed credential is forwardable and often opened on a device the account holder does not control, so the session either flow creates ends with the browser session and is bounded by your tenant's session lifetimes either way — deliberately, and regardless of your remember-me settings. Password sign-in keeps its explicit remember-me checkbox. ## Turning the flows on and off Each flow is its own tenant-level switch, on by default, edited in the dashboard under **Security** → **Sign-in methods** or through the management API: | Method | Path | Scope | | ------- | ---------------------------- | ------------------------ | | `PATCH` | `/api/identity/tenants/{id}` | `identity:tenants:write` | The `authMethods` object you send is **merged into the current setting** — a switch you omit keeps whatever it is set to, so you can send just the one you are changing. A policy that would leave every method off is refused, and the check runs on the merged result: turning off the last method your tenant still has is a 400 whether you name all four switches or only that one. A tenant can go fully passwordless by turning the password method off and keeping every other method on — the hosted pages follow the policy, so the password form disappears everywhere it appears, account creation included: ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const response = await fetch( `https://www.udibo.com/api/identity/tenants/${tenantId}`, { method: "PATCH", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}`, "content-type": "application/json", }, body: JSON.stringify({ authMethods: { password: false } }), }, ); if (!response.ok) throw new Error(`update failed: ${response.status}`); ``` Naming only `password` is the whole request: `emailCode`, `emailLink` and `social` keep whatever they are set to. Send all four if you would rather be explicit — the result is the same when the values agree. `{id}` is your tenant's id — the same one in your dashboard URL — and the token must hold administrative authority over that tenant. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/mfa # Multi-factor authentication **Purpose:** a second factor for your users' accounts, run entirely on the hosted pages: enrollment, the sign-in challenge, and recovery. You choose the policy per tenant; there is nothing to build. The factor is **TOTP** — any standard authenticator app, enrolled by QR code or by typing the secret. Enrollment issues **10 single-use recovery codes**, shown once at that moment and stored only as hashes, so a user who loses the authenticator can still get in. Enrolled secrets are sealed with authenticated encryption at rest, and challenge attempts are rate-limited. ## Where your users meet it - **Enrollment and management** live on the hosted `/security` page: set up two-factor, see its status, and turn it off — when your policy allows either (see below). - **The challenge** happens at sign-in, before any session exists. Password, social, and passwordless sign-in all pass through the same gate, so no method skips the second factor. - **Recovery codes** are accepted at the challenge in place of a TOTP code; each works once. ## The policy One tenant-level knob, `mfaPolicy`, with three values: | Value | Meaning | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `off` | No one can newly enroll. **A factor that is already enrolled is still challenged** — see below. | | `optional` | Users may enroll themselves; enrolled users are challenged at sign-in. The default. | | `required` | Everyone is challenged: an unenrolled user is taken through enrollment at their next sign-in, and users cannot turn their own factor off. | **Switching to `off` does not bypass enrolled factors.** A user who set up two-factor is still challenged at sign-in until their factor is removed — by them on `/security`, or by you (below). A policy flip silently disabling a protection a user chose for themselves is the wrong failure mode, so the policy governs enrollment, not whether an existing factor counts. ## Resetting a user's MFA When someone loses both the authenticator and their recovery codes, an administrator resets them from the user's page in the dashboard (**Users** → the person → Danger zone) or through the management API. The reset removes the user's factors **and revokes their sessions**, so the next sign-in starts clean — and re-enrolls, if your policy is `required`. | Method | Path | Scope | | ------ | ----------------------------------------------- | ---------------------- | | `POST` | `/api/identity/{tenantId}/users/{id}/reset-mfa` | `identity:users:write` | ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const userId = Deno.env.get("USER_ID"); const response = await fetch( `https://www.udibo.com/api/identity/${tenantId}/users/${userId}/reset-mfa`, { method: "POST", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}` }, }, ); if (!response.ok) throw new Error(`reset failed: ${response.status}`); const result: { success: true; sessionsRevoked: number } = await response .json(); console.log(`sessions revoked: ${result.sessionsRevoked}`); ``` ## Setting the policy In the dashboard the policy lives under **Settings**; programmatically it is one field on the tenant: | Method | Path | Scope | | ------- | ---------------------------- | ------------------------ | | `PATCH` | `/api/identity/tenants/{id}` | `identity:tenants:write` | ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const response = await fetch( `https://www.udibo.com/api/identity/tenants/${tenantId}`, { method: "PATCH", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}`, "content-type": "application/json", }, body: JSON.stringify({ mfaPolicy: "required" }), }, ); if (!response.ok) throw new Error(`update failed: ${response.status}`); ``` `{tenantId}` / `{id}` is your tenant's id — the same one in your dashboard URL — and the token must hold administrative authority over that tenant. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/signup-fields # Sign-up fields and custom metadata **Purpose:** collect what your application needs to know about a user at the moment they create their account — a company name, a team size, a marketing consent — without building a form. You define the fields; the hosted create-account page renders them; each answer lands on the new user's record, where your application can read user-owned answers with the person's access token. ## The two metadata buckets Every user record carries two free-form JSON buckets, split by who may write them: - **`metadata`** — owned by you. Written from the dashboard and by a management-API token; **never editable by the user**. The right home for anything your application relies on. - **`userMetadata`** — owned by the user. They edit it themselves on the hosted `/profile` page or through the account API; you can also write it from the dashboard and management API. The right home for preferences and self-descriptions. Each bucket is capped at **16 KB serialized** and **8 levels deep**. Neither bucket rides tokens. An authorized management-API user read returns both; the caller's account API returns only `userMetadata`. ## Read and update the signed-in person's metadata Call `GET /api/account` on your tenant's own origin, `https://{tenantId}.udibo.com`, with the person's bearer access token. The response is `{ "userMetadata": { ... } }`. It requires no management scope or dashboard seat. The credential fixes the person: there is no user or tenant selector, and machine credentials are refused. Send `PATCH /api/account` with a JSON body such as `{ "userMetadata": { "theme": "dark", "oldPreference": null } }` to update that same bucket. Keys merge at the top level, a `null` value deletes its key, and a nested object replaces the previous value of that key. Concurrent merges to the same account apply one after another, so none of them loses another's keys. The response has the same shape as the read. The size and depth limits apply to the final bucket. Only `userMetadata` is accepted; naming another person or sending account fields such as email, verification state or display name is refused. Use `target: "userMetadata"` for sign-up answers your application needs to read with the person's token. The admin-owned `metadata` bucket is absent from this surface, including sign-up answers stored there. It remains available to authorized administrators through the dashboard and management API. Treat user-editable values as preferences or self-descriptions, never as authorization grants. ## Defining sign-up fields In the dashboard: **Settings** → **Custom sign-up fields**. Each field is: | Setting | Rules | | ---------- | ------------------------------------------------------------------------------------------------------------ | | `key` | Where the answer lands in the bucket. Starts with a letter; letters, digits, underscore; up to 64 characters | | `label` | What the form shows. Up to 100 characters | | `type` | `text`, `number`, `boolean`, or `select` | | `required` | Off by default. A required `boolean` must be ticked | | `options` | For `select`: 1–50 choices, and the submitted value must be one of them | | `target` | Which bucket the answer lands in: `userMetadata` or `metadata`. Required, with no default | Up to **50 fields** per tenant. A `text` answer is capped at 1024 characters. Keys the sign-up form already owns — `username`, `email`, `password`, `confirmPassword`, `firstName`, `lastName`, `displayName`, `type`, `redirect`, `intent` — are reserved and refused. Values are validated and typed on submit: a number must parse, a select value must be one of the field's options, and a missing required field blocks the account creation with a per-field message. The create-account page's data also exposes the field configuration, so a client rendering its own form can offer the same inputs. The field list is one tenant setting, editable in the dashboard or replaced wholesale through the management API. A `PATCH` replaces the whole list, so re-send every field you keep, each with its `target`. A field without a `target` is refused with a `400` and nothing is written, so re-sending a field can never quietly move its future answers into the user-editable bucket. To move a field, send it with the other `target`. In the dashboard, **Stored in** is a required choice with nothing preselected, so re-adding an existing key names its bucket too. | Method | Path | Scope | | ------- | ---------------------------- | ------------------------ | | `PATCH` | `/api/identity/tenants/{id}` | `identity:tenants:write` | ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const response = await fetch( `https://www.udibo.com/api/identity/tenants/${tenantId}`, { method: "PATCH", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}`, "content-type": "application/json", }, body: JSON.stringify({ signupFields: [ { key: "company", label: "Company", type: "text", required: true, target: "userMetadata", }, { key: "teamSize", label: "Team size", type: "select", options: ["1", "2-10", "11-50", "50+"], target: "metadata", }, ], }), }, ); if (!response.ok) throw new Error(`update failed: ${response.status}`); ``` Changing the field list affects future sign-ups only; nothing rewrites the answers existing users already gave. ## Writing metadata after sign-up Your application updates either bucket on any user through the users API. The bucket you send **replaces** that bucket; a bucket you omit is untouched. | Method | Path | Scope | | ------- | ------------------------------------- | ---------------------- | | `PATCH` | `/api/identity/{tenantId}/users/{id}` | `identity:users:write` | ```ts const tenantId = Deno.env.get("UDIBO_TENANT_ID"); const userId = Deno.env.get("USER_ID"); const response = await fetch( `https://www.udibo.com/api/identity/${tenantId}/users/${userId}`, { method: "PATCH", headers: { authorization: `Bearer ${Deno.env.get("UDIBO_API_TOKEN")}`, "content-type": "application/json", }, body: JSON.stringify({ metadata: { plan: "starter", seats: 5 } }), }, ); if (!response.ok) throw new Error(`update failed: ${response.status}`); ``` `{tenantId}` / `{id}` is your tenant's id — the same one in your dashboard URL — and the token must hold administrative authority over that tenant. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. _Last verified 2026-09-11._ --- Source: https://www.udibo.com/docs/identity/organizations # Organizations **Purpose:** the building block for an application sold to companies rather than to individuals. An organization groups _your end users_, carries their membership tier, and gives you a boundary to scope authority to — so the same person can be an administrator in one customer's account and a read-only member in another's, with one login. Organizations are optional. A tenant whose product has no notion of "a company" never has to create one; nothing else in the identity service depends on them. ## The shape An organization belongs to exactly one tenant. Membership is an edge from the organization to one of your users, never a copy of that user — so a person belongs to as many organizations as you invite them to, keeps one account, and carries a separate tier in each. Each organization has: | Field | Rules | | ---------- | ----------------------------------------------------------------------------------------------------------------------- | | `name` | What people see. 1–100 characters | | `slug` | A stable handle within the tenant: lowercase letters, digits and single hyphens, 1–100 chars | | `metadata` | A JSON bucket you own. A JSON **object** at the root, at most 16 KB serialized and 8 levels deep, and no NUL characters | Organizations are **flat**. There is no parent organization and no group inside one; sub-teams and organization hierarchies are deliberately not built, and neither is claiming an email domain to auto-join people. ## Membership tiers Every membership carries one of three built-in tiers. They are fixed — you cannot rename or remove them — and they inherit downward, so an `owner` satisfies a check for `admin`, which satisfies `member`: | Tier | What it means | | -------- | --------------------------------------------------------------------------- | | `owner` | Full control of the organization, including its members, roles and settings | | `admin` | Manages the organization's members and settings | | `member` | Belongs to the organization without administering it | A tier is **not** a permission your application reads. It governs the organization's own management surfaces — who may invite, who may remove, who may end the organization. What a member may do inside _your_ product is a role carrying permissions you registered; see [permissions and roles](https://www.udibo.com/docs/identity/permissions). A membership is addressed by the triple of organization, tier and person, and those three are set when the membership is created and never patched. Changing someone's tier means removing the one they hold and offering the new one. ## Creating an organization **Your users create their own.** On your tenant's own host, a signed-in user creates one at `/organizations/new` and becomes its accepted `owner` in the same write. This is the path a self-serve B2B product wants: the first person from a company signs up, creates their company, and invites their colleagues. **Your application creates one for its signed-in user.** From inside your own product, `POST https://{your-tenant-host}/api/organizations` with that person's own access token makes the organization and their accepted `owner` membership in the same write the hosted page makes. It takes no `identity:*` scope and no dashboard credential — see [organizations inside your own application](https://www.udibo.com/docs/identity/organizations#organizations-inside-your-own-application) below. The hosted page is unchanged and stays supported; this is a second way to reach the same write, not a replacement for it. **You create one for them.** In your dashboard, **Directory** → **Organizations** → **Create organization**, or through the management API: | Method | Path | Scope | | -------- | --------------------------------------------------------- | ------------------------------ | | `POST` | `/api/identity/{tenantId}/organizations` | `identity:organizations:write` | | `GET` | `/api/identity/{tenantId}/organizations` | `identity:organizations:read` | | `PATCH` | `/api/identity/{tenantId}/organizations/{organizationId}` | `identity:organizations:write` | | `DELETE` | `/api/identity/{tenantId}/organizations/{organizationId}` | `identity:organizations:write` | Deleting an organization is a **soft delete** that frees its slug for reuse immediately — but only once the organization holds nothing. **The delete is refused, 409, while the organization holds a live resource grant** — a role you granted it on one of your resources. The refusal names those resources, up to three of them, so a `DELETE` that comes back 409 tells you which grants to deal with rather than leaving you to hunt for them. A grant whose **role definition you have since retired** does not block the delete. It confers nothing while the role is retired and does not list on the resource either, so there would be nothing to revoke and no way out of the refusal. The guard exists because a soft delete would otherwise take its members' access with it silently. Anything they reach through the organization — a resource your application granted the organization as a whole — resolves only through a **live** organization, so the moment the row is soft-deleted every member stops resolving on their very next request, and the grant row itself survives pointing at nothing. Refusing the delete puts that decision in front of the administrator instead of behind them. So the order is yours to choose, deliberately: - **Move the access.** Grant the same role to another organization, or to the individual users who need it, and then delete. - **Revoke it.** Take the grants away with `POST /api/identity/{tenantId}/resource-grants/revoke`, then delete. Members are still left alone by the delete itself: their memberships reference an organization that no longer resolves, which every read already filters on. And the delete stays soft, so the row survives for the audit trail — but no surface brings a deleted organization back today, so treat it as final. ## Inviting members **Three surfaces make the offer**, and all three write the same rows and the same audit events: an `owner` or `admin` on the organization's own page on your tenant's host, you in the dashboard under **Directory** → **Organizations**, and **your application, acting as an `owner` or `admin` signed in to it** — `POST https://{your-tenant-host}/api/organizations/{organizationId}/invitations` with that person's own access token, described in [organizations inside your own application](https://www.udibo.com/docs/identity/organizations#organizations-inside-your-own-application). An offer names an email address and a tier or role: ```json { "email": "ada@example.com", "role": "admin" } ``` The address decides what happens next, and the caller does not choose: - **The address already has an account in your tenant.** The offer is written straight to a pending membership, waiting for that person to accept. - **The address has no account yet.** The offer is stored as an invitation against the address alone, and waits until someone signs up and verifies it. The response says which happened, so your own UI can say the right thing. Offering the same address the same role twice is refused as a conflict rather than creating a second offer. **Only the second branch expires.** An invitation written against an address with no account is acceptable for **7 days**. A pending membership — the branch taken when the address already has an account — carries no expiry at all and waits indefinitely. Either way you withdraw one by deleting it, which frees the address to be offered that role again. ### Accepting is bound to the person, never to the link **An id is not a capability on either branch**, which is what makes an invitation link safe to hand out: one that reaches the wrong person buys them nothing. What each branch binds to differs, and it is worth knowing which you are handling: - **An invitation** converts only for a session whose user **holds the invited address and has verified it**, and only before it expires. - **A pending membership** converts only for the session of the **person it was written for** — it is claimed by user id, so the email is not re-checked at acceptance and an unverified address does not stop it. Invitees accept on your tenant's own host at `/organization-invite`, where the offers waiting for them are listed. **We email the invitee on both branches.** Creating an offer queues one message to the address it names, linking `/organization-invite` on your tenant's own host with that offer already selected. It goes out under your tenant's branding, through your tenant's own sender, and counts against the same email allowance as the rest of your mail. It carries no token — the id in the link is not a capability on either branch — so the message is safe to forward and worthless to intercept. What it says splits the same way the offer does, because what the recipient has to do differs: - **An address with no account** is told to create one **on that address**, verify it, and then accept, and the message names the 7-day deadline. - **An address that already has an account** is told to sign in and accept. That message promises no deadline, because a pending membership has none. **Delivery does not gate the offer, and the response says nothing about it.** The offer is written, returned to you, and listed to the invitee whether or not the message goes out — a send that fails costs a notification, not an invitation. If you need to be certain a person was told, send your own message too; what the offer wrote comes back to you in the response. Three things suppress the mail while leaving the offer standing: - An address on your tenant's **suppression list** (a hard bounce or a spam complaint) is not mailed. - One **inviter** may cause at most 20 invitation emails an hour, across every address and every organization. This is a ceiling on volume, not on invitations: past it the memberships and invitations are still written and still listed, and only the mail is dropped. Invite a team larger than that in one sitting and some of them will not hear from us — tell those people yourself, or spread the invitations out. - One address is mailed at most 5 times an hour **per organization**. The budget is per organization on purpose: it bounds a repeated invite-and-withdraw loop against one inbox without letting one organization's sends silence another organization's invitation to the same person. **Invitations do not open a closed door.** If your tenant's sign-up mode is `closed` or `waitlisted`, an invitee who has no account still cannot create one — an organization invitation is not a sign-up admission, and only a waitlist invitation is. Invite people who already have accounts, or admit them through the waitlist first. ### Removing a member Removing is per membership, not per person: a member holding two roles in one organization has two memberships, and removing one leaves the other. The last accepted `owner` cannot be removed, so an organization is never ownerless. ## Bringing people back to your application The three pages your users act on — create at `/organizations/new`, pick at `/organizations`, accept at `/organization-invite` — take a **`return_to`**, so somebody you send there lands back in your product instead of finishing on our page and having to find their own way home: ``` https://{your-tenant-host}/organizations/new?return_to=https%3A%2F%2Fapp.example.com%2Fteams ``` **Where it may point is your own registered redirect URIs.** An absolute URL is accepted only when its **origin** — scheme, host and port together — is the origin of a redirect URI you registered on one of your live applications. Any path on that origin is fine; it does not have to be the callback itself. The allowlist is deliberately that list and no other: an origin you registered already receives this tenant's authorization codes, so sending a person there grants nothing that registering it did not. A path on your tenant's own host (`/organizations`, say) is accepted too. Four registrations do **not** qualify an origin, each for the reason sign-in already ignores it: - A **wildcard** registration (`https://preview-*.example.com/callback`) names no single origin. - A callback on an application you have **deleted**. - A **plaintext `http://`** callback on a `production` application, which sign-in does not honour either. - A **different port** on a host you registered. The port is part of the origin. **A `return_to` we do not accept is dropped, not refused.** The person is never shown an error and never stopped mid-flow: they finish exactly where they would have without the parameter — on the new organization's page, on the switcher, or on your tenant's root. A value we rejected is never written back into the page either, so a crafted one cannot be reflected at whoever opens the link. **It survives signing in.** Somebody who follows one of these links signed out goes through sign-in and comes back to the page with the parameter intact, and an acceptance that needs authentication carries it through the same round trip. **We say what happened in the query** when we hand the person back: - After creating: `organization=`, the organization they just made. - After picking: `organization=`, empty when they chose Personal. - After accepting an invitation: `organization-invite=`. ## The active organization A credential we issue acts in **one** organization at a time — the active one. That is what lets your application ask "may they do this _here_" without you threading an organization id through every call. - **One membership auto-selects.** Someone who belongs to exactly one organization never has to pick; it is resolved on every issuance, never written back. - **Two or more select nothing** until the person chooses. They pick on your tenant's own host at `/organizations`, where they can also act with no organization at all. - **Your application can name one.** Add `organization` to the authorization request — the organization's id or its slug — and the sign-in lands already acting there. This writes the **session's** pick, not just this credential's, so another application signing in on the same browser session sees the organization your request selected. ``` GET https://{your-tenant-host}/api/oauth2/authorize?…&organization=acme ``` To move an existing credential to another organization without sending the person back through sign-in, name it on a **refresh** request. Here it must be the organization's **id** — the refresh grant does not accept a slug: ```ts async function tokenForOrganization( host: string, clientId: string, refreshToken: string, organizationId: string, ): Promise<{ access_token: string; refresh_token: string }> { const response = await fetch(`https://${host}/api/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: refreshToken, organization_id: organizationId, }), }); if (!response.ok) { throw new Error(`token request failed: ${response.status}`); } return await response.json() as { access_token: string; refresh_token: string; }; } ``` The request above authenticates as a public client. A confidential client sends its `client_secret` the same way it does on any other refresh. `organization_id` is accepted on the refresh grant and nowhere else — sending it on any other grant is an `invalid_request`. **An unavailable organization refuses the exchange before anything rotates** — whichever access-token format your application is configured for, and whether or not it requests the `openid` scope. The refresh token you presented is still good, so a retry without the parameter succeeds and the credential keeps the organization it already had. Confirm the organization is one the person belongs to — `/api/memberships` lists exactly those — before naming it. The refusal is about the organization **this request names**. One the credential already carries that stops resolving — a membership revoked since the switch — does not refuse: the credential carries no organization from then on, so its organization claims drop rather than moving to another organization, and its refresh loop keeps working. **Every way an organization can be unavailable answers the same.** Unknown, another tenant's, deleted, never a member, membership revoked — one refusal for all five, down to not echoing the id back, so probing cannot tell a tenant's organizations apart from ones that never existed. Expect the refusal, not a reason you can branch on. ## What your application reads ### The organization on the credential The active organization reaches your application as three claims: | Claim | Value | | ----------- | ----------------------------------------------------------------------------------------- | | `org_id` | The organization's id | | `org_slug` | Its stable handle — the thing to route on | | `org_roles` | The roles the subject holds there, unexpanded, exactly as `/api/memberships` reports them | They are stamped on the `id_token`, the UserInfo response, the introspection response, and — for applications configured for JWT access tokens — the access token itself. All four run the same resolution, so the claims mean the same thing wherever you read them. The organization's display `name` is deliberately **not** a claim: it changes, and a token is the wrong place to cache it. Read it from `/api/memberships`. **Every surface answers for the same organization, for the whole life of the credential.** The organization is resolved once, when the credential is issued, and recorded on it: the `id_token` and a JWT access token carry that resolution in their signed bytes, and the introspection and UserInfo responses re-validate **that** organization on every call rather than re-deciding which organization it is. Introspecting the family's refresh token answers the same way. So switching organizations changes what the _next_ credential is minted for and leaves an outstanding one alone, and a gateway that routes on `org_id` from the JWT while the service behind it authorizes on `permissions` from introspection is reading one organization on both sides. All three are **absent** when no organization is active, and each is re-derived from live rows — at every issuance, and again on every introspection and UserInfo response — so a deleted organization or a revoked membership never leaves a **stale** organization on those two surfaces. Only what was already signed into an `id_token` or a JWT access token keeps what it was minted with until that credential expires or refreshes. When the credential's own organization stops resolving — deleted, or the membership revoked — introspection and UserInfo **drop** the organization claims rather than answering for whichever organization the login session has since picked, and the `permissions` they return narrow to what the subject holds tenant-wide. A revoked membership reads as "no organization", never as a different one. Do not confuse `org_roles` with the separate **`roles`** claim, which carries the tenant-wide role slugs a subject holds everywhere. Mixing them would apply an organization's roles outside it. ### The memberships the caller belongs to Tokens deliberately carry the active organization, not the whole list. When you need the list — to render a switcher of your own — ask for it with the person's own access token, on your tenant's own host: ```ts interface Membership { org_id: string; org_slug: string; name: string; roles: string[]; } async function memberships( host: string, accessToken: string, ): Promise { const found: Membership[] = []; let cursor: string | null = null; do { const url = new URL("/api/memberships", `https://${host}`); if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { throw new Error(`memberships failed: ${response.status}`); } const page = await response.json() as { memberships: Membership[]; cursor: string | null; }; found.push(...page.memberships); cursor = page.cursor; } while (cursor); return found; } ``` It answers for the caller and only the caller: there is no field naming somebody else, it needs no scope, and a machine (`client_credentials`) credential is refused — it holds no memberships. Accepted memberships only, up to 100 per page. **`roles` lists what is held, not what it implies.** The tiers inherit when we resolve a check, but this array is unexpanded — an owner comes back as `["owner"]` alone. A reader that asks `roles.includes("admin")` therefore denies the owner; compare against every tier that satisfies you, or ask `/api/check` and let the resolution do it. ### Whether they may act here The `permissions` claim on the credential already answers for the active organization, and on an introspection or UserInfo response it answers as of now. For any other organization — or a live answer for a reader holding a JWT access token, whose claims were fixed when it was signed — ask directly: ```ts async function mayActInOrganization( host: string, accessToken: string, organizationId: string, permission: string, ): Promise { const response = await fetch(`https://${host}/api/check`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ permissions: permission, resource: { type: "organization", id: organizationId }, }), }); if (!response.ok) throw new Error(`check failed: ${response.status}`); const { results } = await response.json() as { results: Record; }; return results[permission] === true; } ``` [Permissions and roles](https://www.udibo.com/docs/identity/permissions) covers the whole model — registering the capability strings, composing them into roles, and the three widths an assignment can have. ## Roles inside an organization An organization has its own role vocabulary, separate from the membership tiers above. Roles defined for the whole tenant can be held anywhere in it; roles defined on one organization's page apply only there, and the organization's own `owner` or `admin` composes them from the permissions you registered. Both kinds resolve into the same `permissions` claim, so your application never has to know which kind someone holds. A role slug may not name one of the built-in tiers, or one of the access levels that govern the Udibo dashboard — a role of your customers' can never become authority over your tenant. Manage the tenant-wide vocabulary in your dashboard under **Access control** → **Roles**, or through `/api/identity/{tenantId}/organization-roles`; an organization's own roles live under the **Roles** tab on its page. ## Organizations inside your own application Everything your users do on the hosted pages, your application can also do for them from inside your own product. The surface is on **your tenant's own host**: ``` https://{your-tenant-host}/api/organizations ``` It is the same organization surface the hosted pages drive — the same service calls, so the rows and the audit events are identical however the write arrives. The hosted pages stay exactly as they are; use either, or both. **It exists only on your tenant's own host.** On Udibo's own host every one of these paths answers 404. The `/api/identity/…` endpoints are the separate administrator surface described [below](https://www.udibo.com/docs/identity/organizations#the-administrators-api-and-your-applications); do not point your application at those. **The credential is the signed-in person's own access token**, issued by your tenant's authorization server — the same token your application already sends to `/api/memberships` and `/api/check`. No `identity:*` scope is required, and no dashboard access: a customer's end user holds neither and never will. Neither substitutes for authority either. What a caller may do is decided on every request by that caller's **own accepted membership**, so holding a scope changes nothing. A token your tenant did not issue, or no token at all, is a 401. **A machine (`client_credentials`) token is refused 403 on every verb, including create.** A machine credential acts for an application rather than for a person, so it holds no memberships and there is nobody for it to act as. The refusal is the same on every path and lands before any organization is looked up, so it never tells you anything about an organization. Call this API with a person's token, and only with that. ### What you can call, and what it takes | Method | Path | Authority | | -------- | ---------------------------------------------------- | ----------------------- | | `GET` | `/api/organizations` | Any signed-in person | | `POST` | `/api/organizations` | Any signed-in person | | `GET` | `/api/organizations/{organizationId}` | Any accepted membership | | `PATCH` | `/api/organizations/{organizationId}` | `owner` or `admin` | | `DELETE` | `/api/organizations/{organizationId}` | `owner` | | `GET` | `/api/organizations/{id}/members` | Any accepted membership | | `DELETE` | `/api/organizations/{id}/members/{userId}/{role}` | `owner` or `admin` | | `GET` | `/api/organizations/{id}/member-roles` | `owner` or `admin` | | `POST` | `/api/organizations/{id}/invitations` | `owner` or `admin` | | `GET` | `/api/organizations/{id}/invitations` | `owner` or `admin` | | `DELETE` | `/api/organizations/{id}/invitations/{invitationId}` | `owner` or `admin` | | `GET` | `/api/organizations/{id}/roles` | `owner` or `admin` | | `POST` | `/api/organizations/{id}/roles` | `owner` or `admin` | | `GET` | `/api/organizations/{id}/roles/{roleId}` | `owner` or `admin` | | `PATCH` | `/api/organizations/{id}/roles/{roleId}` | `owner` or `admin` | | `DELETE` | `/api/organizations/{id}/roles/{roleId}` | `owner` or `admin` | The three tiers of the [membership ladder](https://www.udibo.com/docs/identity/organizations#membership-tiers) are the whole authority model here — never a scope, never a seat, never anything your application asserts about the caller: - **Listing your own organizations and creating one** need only a signed-in person. The creator becomes the new organization's accepted `owner` in the same transaction, so there is never an organization nobody manages. - **Any accepted membership** reads the organization and its member list. - **An accepted `owner` or `admin`** does everything else: rename, invite, withdraw an invitation, revoke a membership, and define, edit or retire the organization's own roles. Reading the invitation list, the invitation vocabulary (`member-roles`) and the organization's roles is manager-only too. - **An `owner`** deletes the organization, and is also required to offer or revoke the `owner` role itself. Two rules carry over from the surfaces you already know, because it is the same code underneath: a delete is refused 409 while the organization [still holds a live grant](https://www.udibo.com/docs/identity/organizations#creating-an-organization), and a role may only carry permissions **your tenant has registered** — an unregistered key is refused 400, naming each offending entry by index. Renaming a role is refused 409 while a grant still holds it, exactly as retiring it is. **Below the read tier there is one refusal, byte for byte.** A malformed id, an unknown one, one belonging to another tenant, a deleted organization and one the caller simply is not a member of all answer the same 404, so this API cannot be used to learn which organizations exist. There is no reason in it to branch on. Only where the caller is already a known manager and the **tier** is the one thing missing is the refusal a 403 that says so: an `admin` deleting the organization, and an `admin` offering or revoking the `owner` role. **Nothing in the request names a tenant.** The host does. A `tenantId` in the body or the query string is dropped rather than honoured, and the write lands in the tenant whose host you called. ### Calling it from the browser These endpoints answer any origin — `Access-Control-Allow-Origin: *`, and `Access-Control-Allow-Credentials` is never sent. That is safe here for one reason: **no cookie authenticates anything on this surface.** The only credential read is the `Authorization` header your own code attaches to that one request, so a wildcard hands another page nothing it could not already send for itself. A request carrying your tenant's session cookie and no bearer token is a 401. What a browser in your application sends: - `Authorization: Bearer ` on every request. - `Content-Type: application/json` on a write. That makes it a preflighted request; the `OPTIONS` is answered before authentication, because a preflight carries no credential to authenticate. - Not `credentials: "include"`. It buys nothing — no cookie is read, and without `Access-Control-Allow-Credentials` the browser would refuse to expose the response to you anyway. ```ts export async function createOrganization( host: string, accessToken: string, input: { name: string; slug: string }, ): Promise<{ id: string; slug: string; name: string }> { const response = await fetch(`https://${host}/api/organizations`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify(input), }); if (response.status !== 201) { throw new Error(`create organization failed: ${response.status}`); } return await response.json() as { id: string; slug: string; name: string }; } ``` An invitation posted here behaves exactly as [inviting members](https://www.udibo.com/docs/identity/organizations#inviting-members) describes: the address decides whether a pending membership or a pre-subject invitation is written, the response says which, and the invitee is emailed either way. **Accepting is still ours** — the invitee finishes at `/organization-invite` on your tenant's own host, and no API accepts an offer for them. ## The administrator's API, and your application's Two APIs manage organizations, and what separates them is who is calling: - **`/api/identity/{tenantId}/…` is the administrator's surface** — the endpoints listed under [creating an organization](https://www.udibo.com/docs/identity/organizations#creating-an-organization). The credential that reaches them today is the one your dashboard session carries; a machine credential minted from an application in **your own** tenant is not accepted there. Use the dashboard or an approved administrator workflow, and keep administrator credentials out of your application. - **`/api/organizations` on your tenant's own host is your application's surface** — the same organizations, acting as the person signed in to your product, authorized by their membership rather than by authority over your tenant. This is the one to build your own organization screens on. `/api/identity/organizations` is neither. It is the same caller-relative surface addressed on Udibo's own host, for people signed in to Udibo itself, and it answers 404 on your tenant's host. Your application never calls it. Your users' own surfaces are unaffected by either: the hosted organization pages, `/api/memberships` and `/api/check` all run on your tenant's own host and authenticate the person's own credential. ## Limits today - **Flat only.** No nested organizations, no groups inside one, and no domain capture that auto-joins an address. - **No resend button.** The invitation email goes out once, when the offer is created. To send another, withdraw the offer and make it again — which mints a new one and a new link, inside the hourly budgets above. - **No delivery reporting.** Nothing tells you whether an invitation email was sent, dropped for budget, or bounced; the response is the same either way. - **Accepting happens on our page.** Your application can create organizations, invite people and manage members for its signed-in user, but nothing accepts an offer on the invitee's behalf: they finish at `/organization-invite` on your tenant's own host. - **A tier or role is never patched onto an existing membership.** Remove and re-offer. - **Organizations are not a tenancy boundary.** They group your end users inside one tenant; they do not partition your data for you. _Last verified 2026-09-14._ --- Source: https://www.udibo.com/docs/identity/permissions # Permissions and Roles **Purpose:** the customer-facing guide to authorizing your **own** application with Udibo Identity — what to register, how it reaches your code, and what we promise never to do with it. Everything here is a published commitment: changing a rule in this document changes a promise. ## The whole model in one paragraph You register **permissions** — capability strings your application interprets, like `posts:write`. You define **roles** that carry them. You **assign** a role to a person at one of three widths: across your whole tenant, inside one organization, or on a single **resource** of your own — a document, a project, whatever your application owns. We store the assignment, resolve it on every request, and hand your application the resulting set. We never interpret a permission string. What `posts:write` means is decided entirely by your code. **The three widths, once, because it is the whole model:** a **tenant-wide** assignment counts everywhere; an **organization** assignment counts only inside that organization; a **resource** assignment counts only on that one resource. They union — holding a permission at any width that applies means you hold it. Nothing narrows: an organization assignment never leaks to another organization, and a grant on one document never answers for another. ## The word "role" means three different things here This trips people up, so it is worth thirty seconds up front. On your organization page, three sections each use the word, and they govern different worlds: | Where | Governs | Who manages it | | ---------------------- | ----------------------------------------------------- | ---------------- | | **Members** | the organization itself — invite, remove, rename | the organization | | **App access** | **your application** — this document | the organization | | **Organization roles** | what a member can do, as this organization defines it | the organization | And separately, **Administrators** governs your Udibo dashboard. None of these confers anything in the others. Only **App access** — and its tenant-wide counterpart on a user's page — reaches your application. ## Where each thing is managed Roles are **defined** in one place and **assigned** in three, and the sidebar shows only the first. Nothing on the **Roles** page says where a role is held; that is decided when someone assigns it. | Question | Where | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | What capabilities exist? | **Access control** → **Permissions** | | What roles carry them? | **Access control** → **Roles** — one registry, every role assignable at any width | | What kinds of thing can a role be scoped to? | **Access control** → **Resources** — the type registry, like `document` | | Who holds a role tenant-wide? | **Directory** → **Users** → the person → Roles | | Who holds a role inside an organization? | The organization's own page, at `/organizations/{id}` on your tenant, → App access — its owner or admin does this, not the dashboard | | Who holds a role on one resource? | **Access control** → **Resources** → the type → the resource (or the resource-grants API) | The same `editor` can be held all three ways at once by different people. A role's page shows how many assignments carry it, across every width, so an edit to what it carries is made knowing whom it reaches; it does not yet break that number down by width. ## 1. Register the permissions your app checks for A permission is a string. Register the ones your code will check, so the rest of the system can offer them for selection instead of asking someone to type them. **Dashboard:** **Access control** → **Permissions** → **Register permission**. Click a permission in the list to edit its description or remove it. **API:** ``` POST /api/identity/{tenantId}/permissions { "key": "posts:write", "description": "Publish and edit posts" } ``` Rules: - Lowercase letters, digits, and `. : _ -` separators, starting with a letter. Up to 120 characters. - **Up to 500 keys per tenant.** The cap is what keeps a token carrying a subject's permissions bounded by construction rather than by truncating it. The 501st registration is refused as a validation error on the `key` field — the message states the cap, so do not parse it for which key was rejected. - **No word is reserved.** Your keys name capabilities in your own application, so you may register anything the format allows — `identity.` and `udibo.` included. A key of yours that happens to read like one of ours means nothing here: every key is resolved only within the tenant that registered it. - The key is **not editable**, because roles reference it by value. Delete and re-register to rename; the key frees for reuse immediately. - Deleting a permission does not edit your roles. A role still listing it simply stops conferring it. ## 2. Define roles that carry them **Dashboard:** **Access control** → **Roles** → **Create role**, then tick the permissions it carries. Click a role in the list to change what it carries or retire it. An organization's own roles are on that organization's page under its **Roles** tab, the same way. **Retiring a role, or changing its slug, is refused while anything still holds it.** The API answers `409` — naming how many members, pending invitations and grants still hold the role — and the dashboard shows the same refusal as a notice on the role's page or its edit form. Memberships, invitations and group grants narrowed to a role (`@org:acme#editor`) name the role by its slug, so a rename would leave them naming a role that no longer exists; editing the name, description or permissions while keeping the slug is always fine. Retirement neither demotes nor revokes: reassign or remove the holders first, then retire or rename. Once nothing holds it, the delete is a soft one and the slug frees for reuse. **API:** ``` POST /api/identity/{tenantId}/organization-roles { "slug": "editor", "name": "Editor", "permissions": ["posts:write"] } ``` A role carrying nothing is a legitimate starting point. A role naming a permission you have not registered is refused, by index — `permissions.1` tells you which entry is wrong, not just that one of them is. ### One registry, three widths Every role on the **Roles** page is defined once and can be held at any width: across the tenant, inside one organization, or on one resource. You do not define a tenant version and an organization version of `editor`; you define `editor` and decide the width each time you assign it. A tenant-wide or organization assignment resolves into the same `permissions` claim; a resource assignment is answered by `POST /api/check` only, since resource grants never ride the credential. Either way your application never needs to know how someone came to hold it. An organization's page also has an **Organization roles** section where its manager can compose roles of their own, from the same registry. Those roles are granted on the same page under App access — the picker lists the tenant's roles and the organization's own, told apart — and count inside that organization only: one cannot be held tenant-wide, on a resource, or in another organization. Define the vocabulary your whole application shares on the **Roles** page, and leave an organization's own roles to what only that organization needs. ### Editing a role changes it for everyone holding it This is the sharp edge, and it is deliberate. Resolution reads the role definition on **every request**, so adding a permission widens it for every holder immediately, with no assignment changing. That is what makes roles worth having rather than a list of per-person grants — and it is why every definition write is audited with the full resulting permission set, so a widening is one diffable row in your audit log. ## 3. Assign a role to someone Three widths, and the difference is the whole point. **Tenant-wide** — the role applies everywhere, including inside every organization. This is what a product without organizations wants. - **Dashboard:** **Directory** → **Users** → the person → Roles. - **API:** `POST /api/identity/{tenantId}/users/{userId}/roles` with `{ "roleId": "..." }`. **Inside one organization** — the role applies only there. The same person can hold different roles in different organizations, and holds nothing from membership alone. [Organizations](https://www.udibo.com/docs/identity/organizations) covers the organization itself: creating one, inviting members, and how a credential comes to be scoped to one of them. - **Dashboard:** the organization page → App access. Granting here is the organization's own manager's job, not yours — you define the vocabulary, they decide who gets what. - **API:** the same route with `{ "roleId": "...", "organizationId": "..." }`. **On one resource** — the role applies only to that resource. First register the kinds of thing you scope authority to (Dashboard: **Access control** → **Resources** → Register resource type). A type key is a word like `document`: lowercase letters, digits and `_ -` separators, starting with a letter, up to 64 characters, and up to 100 types per tenant. You never register the individual resources — a grant against `{ type, id }` creates the record on first use, and the `id` is whatever your own system calls it, up to 255 characters. ``` POST /api/identity/{tenantId}/resource-grants { "subject": { "type": "user", "id": "..." }, "roleId": "...", "resource": { "type": "document", "id": "readme" } } ``` The subject can be a `user` or an `organization` — an organization grant admits every accepted member, so you hand a whole team a resource in one call. An organization subject may also name **`role`**: which of its members exercise the grant (the Zanzibar userset subject, `org:acme#admin`). That is a built-in organization role — `owner`, `admin`, `member`, where a higher role satisfies a lower one — or one of your own **tenant-wide** role slugs, matched exactly: the same vocabulary a membership can carry, so a slug no membership could hold is refused `400` rather than stored as a grant that confers to nobody. Omit it and every accepted member holds the grant; `''` is not the `member` role. A grant with a role and one without are two distinct grants on the same resource, revoked independently, and each listed row carries its `subjectRole` (`null` for every member). The role is `roleId` **or** `builtInRole` — exactly one. A built-in role (`owner`, `admin`, `member`, `support`) needs no definition of yours: it carries a fixed permission set and cannot be retired, and it counts everywhere a defined role would, including in the `roles` claim. The four are a ladder — `owner` above `admin` above `support` above `member` — and `support` is the read-only support seat: every non-sensitive read permission plus `identity.users.impersonate`, and no write, secret or export. Granting or revoking a built-in role requires holding it — or a higher one — at that scope yourself, and never less than `admin`, whether directly, tenant-wide, or through an organization grant that admits you — the grant's own `role` decides which of its members that is, exactly as it decides who holds the grant's permissions, so a grant naming no role lets any accepted member curate at that rank; an administrator's seat over the tenant stands in when they are not one of its users. Otherwise the call is refused `403`. Repeating a grant returns the existing one rather than creating a second — but it still answers `201`, so the status code does not tell you whether anything was written. `POST /api/identity/{tenantId}/resource-grants/revoke` takes the same body back off again. **A grant outlives nothing.** An organization holding a live grant cannot be deleted: the delete is refused `409` and names the resources that block it, because a group grant resolves only through a live organization and deleting one would cut every member's access silently. Revoke or move what it holds first — see [`organizations.md`](https://www.udibo.com/docs/identity/organizations). **Placing a grant is an administrator's call, not an end user's.** There is no self-serve grant surface for your users. An administrator places one from the dashboard or through the API above — the two run the same checks and write the same audit event. Today the API accepts the credential your dashboard session carries and not a machine credential minted in your own tenant, so a production application cannot place a grant on its own yet. An application permission such as `documents:share` does not grant authority to call this management endpoint. **The dashboard browses what authorization holds, never what you have.** The **Resources** list shows, per type, how many resources carry a live grant and how many grants that is. A type's page lists those resources — most recently granted first, with the grant count and who holds something there — and each opens the resource's own page: every grant on it, a **Revoke** beside each, and a form to grant a role to a user (by email, username or id) or to an organization (by slug or id, optionally narrowed to the members holding a role). A box on the type page opens any resource by the id your application uses, whether or not anything has been granted on it yet, so a first grant can be placed there. Read the list as what authorization has touched, never as an inventory: a resource appears once something is granted on it and drops out when its last grant is revoked, so nothing you have never shared is there, and something you deleted still is while a grant survives. Your own database remains the record of what exists. The list offers **Next** and **Previous** and no total beyond the type's counts, and there is no search over resource ids — an exact id opens a resource; a prefix would need an index this table does not carry. **The type's activity log is a page of its own.** Every grant written and every grant revoked against resources of that type, most recent first, with the holder, the role, and when; each entry links to the resource's page. It is a log, not an inventory, for the same reason as above, and it carries no total count because counting it would mean scanning every grant of the type. **Removing a resource type is refused while live grants sit on live resources of that type.** The API answers `409` — naming how many live grants, accepted or still pending, are scoped to resources of that type — until you revoke them; the dashboard shows the same refusal as a form error on the type's page. Two kinds of grant are not counted. A grant whose role has been retired confers nothing and does not block; it is retired along with the type, so restoring the role later cannot bring it back. And a grant on a resource record that is no longer live resolves to nothing, so it does not block either — the count is over what still resolves, not over every row that ever named the key. Nothing in the product removes a resource record today (one is created on first grant and kept), so in practice every grant you have not revoked, carrying a role you have not retired, counts. Once nothing live references it, the delete is a soft one: checks naming that type start being refused and the key frees for reuse. **One name is unavailable, and it is not ours.** You cannot register a resource type called `organization`, because your tenant already scopes roles to organizations — a resource type by that name would be a second, different thing with the same name, and a check request could not say which you meant. Every other word is yours, `tenant` and `identity_tenant` included. Assignment is **idempotent**: assigning a role someone already holds returns the existing assignment rather than an error, so a retried request is safe. Revoking (`DELETE .../roles/{roleId}`) stops the role counting immediately: the next introspection, UserInfo response or check no longer sees it. What was already signed — a JWT access token, an `id_token` — carries what it carried until it is re-issued; see below. ## Worked examples Three shapes cover most applications. They are not modes; a tenant can use any of them together, and a check unions whatever applies. ### Plain roles, no organizations A single-workspace app: every user belongs to the tenant and nothing else. Register `notes:read`, `notes:write` and `users:manage`; define `member` carrying the first, `editor` the first two, `admin` all three; assign each person one of them **tenant-wide** from their user page. The token's `permissions` claim carries the union, and your code reads it offline. That is the whole of RBAC, and a tenant that never registers a resource type or creates an organization stops here. ### Organizations, with a role that means something inside each A B2B app where each customer company is an organization. The same `editor` from the Roles page is granted to Bob **inside Acme** from Acme's App access section, and to nobody else. When Bob's active organization is Acme his token carries `notes:write`; when he switches to another organization it does not. A tenant-wide `support-agent` role assigned to your own staff still counts inside every organization, because tenant-wide never narrows. ### One document shared with one person A note-taking app where a note has an owner and can be shared. Register the types `note` and `folder` on the **Resources** page, and define `note-owner`, `editor` and `viewer` on the **Roles** page. When Alice creates note `n_123`, an administrator grants her `note-owner` on `{ "type": "note", "id": "n_123" }` — today from the dashboard or with a dashboard-session credential, see _Placing a grant_ above; when she shares it with Bob, `editor` on the same resource; with her whole team, `viewer` to the organization Acme (every accepted member, or only its admins). Your database keeps the note; Udibo keeps who may do what to it. Rendering Bob's list is one batch check over the ids you already have. ### Several resource types that each want an "editor" Permission keys are yours, and a check always names the resource it is about, so there are two clean ways to do this and one to avoid. - **Generic verbs, one role.** Register `read`, `write` and `share`; define `viewer`, `editor` and `owner` once. `editor` on `note:n_123` and `editor` on `folder:f_7` are the same role on different resources, and your code asks `write` on whichever it is rendering. This is the simplest and reads well when every type supports the same verbs. - **Type-prefixed keys, one role per type.** Register `notes:write` and `folders:write`; define `note-editor` and `folder-editor`. Use this when the types genuinely differ in what editing means, or when a tenant-wide holder of one must not get the other. - **Avoid a single `editor` that carries every type's keys** — `notes:write` and `folders:write` in one role. It works, and a note grant carrying `folders:write` is harmless because a check on a note never asks for it, but every widening to one type silently widens the other, and tenant-wide holders get both. Roles do not inherit across types either way: `editor` on a folder says nothing about the notes in it (see Limits today). ## Reading the answer ### The `permissions` claim A credential we issue for a **person** carries a `permissions` claim: a sorted array of the keys that subject holds, resolved for the organization the credential is scoped to plus their tenant-wide roles. The claim is **absent** when the subject holds nothing, so your code can treat missing and empty alike. Three things bound where you will find it. A `client_credentials` credential carries no `permissions` claim at all — a machine has no roles and no self. An **opaque** access token carries no claims at its own surface, so read the set off the introspection response; a JWT access token, the `id_token` and the UserInfo response all carry it directly. All four resolve it through the same computation, for the same organization — the one the credential was issued for. What differs between them is only _when_ the set inside that organization was resolved. ```ts interface SubjectClaims { sub: string; permissions?: string[]; } export function holds(claims: SubjectClaims, permission: string): boolean { return claims.permissions?.includes(permission) ?? false; } ``` This is the fast path, and for most applications it is the only one you need. How fresh it is depends on which surface you read it from: - **The introspection and UserInfo responses resolve it when you ask.** Each re-resolves the set from your assignments as they stand, so a role granted a moment ago already counts and a revoked one has already stopped. - **A JWT access token and the `id_token` carry what was resolved when they were signed.** A role granted after that is not in them until the credential is re-issued — the trade the format makes for validating with no round trip. - **A copy your own application kept is as old as the copy** — a session projection built once at sign-in, a store in the browser. This is the staleness most applications actually meet; re-read the claim instead. **Which organization the set was resolved for is settled when the credential is issued.** A credential carries one organization — the one named explicitly on its refresh, else the one the login session had picked at issuance — and every surface resolves the set for **that** organization for as long as the subject still holds it. So an introspection or UserInfo response — or an introspection of the family's refresh token — cannot answer for a different organization than the `id_token` issued alongside it, and switching organizations changes what the _next_ credential carries rather than what an outstanding one reports. If the membership behind it is revoked, the organization claims drop and the set narrows to your tenant-wide roles — never another organization's. ### `POST /api/check` For the cases the claim cannot cover — an organization the credential does not carry, **any resource-level question** (resource grants never ride the credential), and a live answer for a JWT reader, whose claims were fixed when the token was signed — ask us directly. ``` POST https://{your-tenant-host}/api/check Authorization: Bearer {the user's access token} { "permissions": ["posts:write", "posts:delete"], "resource": { "type": "organization", "id": "..." } } ``` ```json { "subject": "...", "resource": { "type": "organization", "id": "..." }, "results": { "posts:write": true, "posts:delete": false } } ``` Omit `resource` and you get the credential's own scope — the organization the token was issued for plus tenant scope, which is exactly what its `permissions` claim resolved, so you never have to echo `org_id` to keep the two agreeing. A token issued for no organization still gets tenant scope alone. The organization is read from the token; no request field can name it, so asking about a different one still means naming it in `resource`. The `resource` in the answer says which scope was used, and is `null` only when the answer really was tenant-wide. **So a resource-less answer is credential-dependent, and you must not apply it tenant-wide.** Two people using your application hit this endpoint with the same body and get answers scoped to different organizations, because their tokens carry different ones. Read the `resource` the answer comes back with, or name the scope you meant — `resource: { type: "organization", id }` for a per-organization gate, your own resource for a per-object one. Both are unchanged by this and say what they mean. Name one of your registered resource types instead of `organization` to ask about one of your own resources: `"resource": { "type": "document", "id": "readme" }`. `permissions` also accepts a bare string for the one-permission case, and up to **50** keys in one request. ```ts async function check( host: string, accessToken: string, permissions: string[], resource?: { type: string; id: string }, ): Promise> { const response = await fetch(`https://${host}/api/check`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify( resource ? { permissions, resource } : { permissions }, ), }); if (!response.ok) throw new Error(`check failed: ${response.status}`); const answer = await response.json() as { results: Record; }; return answer.results; } ``` **The list question.** When you are rendering a page of resources and need to know which ones the caller may act on, ask once instead of once per row: ``` POST https://{your-tenant-host}/api/check/batch { "permissions": ["docs:edit"], "resource": { "type": "document", "ids": ["readme", "roadmap", "..."] } } ``` ```ts async function checkMany( host: string, accessToken: string, permissions: string[], resource: { type: string; ids: string[] }, ): Promise>> { const response = await fetch(`https://${host}/api/check/batch`, { method: "POST", headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json", }, body: JSON.stringify({ permissions, resource }), }); if (!response.ok) throw new Error(`batch check failed: ${response.status}`); const answer = await response.json() as { results: Record>; }; return answer.results; } ``` The answer is keyed by id, then by permission. Up to 100 ids of **one registered** type — the batch has no organization branch, so `organization` is not a type it accepts; ask about an organization on `/api/check`. Each id is answered exactly as `/api/check` would answer it alone. (In our own measurements the batch answers 100 candidates for the cost of about one-and-a-half single checks.) Three things about these endpoints worth knowing: - **It is on your own host**, not `www.udibo.com` — the same origin as the token endpoint that issued the credential. Your users' tokens live in your tenant, and that is where they are validated. - **It requires no scope.** Authentication is the whole gate, because the answer is strictly less than the caller's own token already tells them. - **It answers only for the caller.** There is no field naming somebody else, and a `client_credentials` token is refused — a machine credential has no self, and defaulting to the application's owner would answer about a person who did not call. ## What we promise - **We never interpret your permission strings.** Not for billing, not for features, not for support tooling. They are opaque to us by design. - **A role of yours can never confer Udibo dashboard authority**, whatever its permissions are called. Authority over a tenant comes from a grant over that tenant, never from a role one of your subjects holds — so the two can share a word without sharing a meaning. - **We run on the same seam you do.** Udibo's own dashboard authority is a grant on an `identity_tenant` resource of Udibo's tenant, written through the same `assignRole` and read through the same `permissionsFor` your grants are; there is no private path for our own use. An organization that owns a tenant holds it as `org:#admin`, so only its owner/admin members administer it — the userset subject above, applied to ourselves. - **A definition role placed or widened by one of the tenant's own users must be within what they hold at that scope.** Assigning a `roleId` — on a resource, inside an organization, or tenant-wide, through the API or the dashboard — is refused `403` when the caller is a user of the tenant and does not hold, at the target scope, every `identity.*` permission the role carries; your own permission keys are never bounded — and an invitation naming a `roleId` is refused on the same rule as an outright grant. Editing a role that is already placed is bounded the same way, at every scope it is placed at, counting a placement from the moment it is written: an invitation nobody has accepted yet bounds the edit exactly as an accepted one does, because accepting re-checks nothing. A revoked placement stops bounding it, since it confers nothing and cannot come back except through a placement that runs the rule again. An administrator working the tenant from their seat confers on the tenant's behalf. Revoking the last owner or the last admin of a Udibo-managed tenant through `POST …/resource-grants/revoke` is refused `409`, exactly as it is on the Administrators page. - **Every write is audited** in your tenant's log, with the permission set on role writes and the role and scope on assignments — resource grants included (`admin.resource_grant.*`, `admin.resource_type.*`). ## Limits today - **A permission is held or not held** — there are no negative permissions and no precedence rules. Two roles carrying overlapping sets simply union. - **Roles do not inherit.** Udibo's own built-in access levels do (owner satisfies admin), but a role you define matches exactly and implies nothing — including across resources: there is no "editor on the project implies editor on its documents" derivation yet. Express the wider grant directly. - **A resource assignment names a role, not a bare relation**, the same as every other width: you grant "the role that carries what editing means," not a standalone `#editor` edge. - **A role's page counts its holders across every width without saying which.** The type pages list resources with a preview of their holders, and an organization's own App access lists what its members hold there; neither is a per-role view. - **A held role cannot be retired or re-slugged, and nothing does either for you.** The refusal counts what holds it; reassigning or removing each holder is a separate action on the member, invitation or grant. _Last verified 2026-09-13._ --- Source: https://www.udibo.com/docs/identity/b2b-saas-template # Build a B2B SaaS application **Availability:** this walkthrough describes a service template currently available through beta repository access. It is not a public standalone starter. You can follow the same design in your own application using the [first-login guide](https://www.udibo.com/docs/identity/get-started). For runnable public package examples, use [starter projects](https://www.udibo.com/docs/identity/starter-projects). ## Before you start You need an accessible Udibo tenant, a development application configured as a confidential authorization-code client with refresh tokens, and a client secret. Choose opaque access tokens for these walkthroughs; they validate through introspection. See [application registration](https://www.udibo.com/docs/identity/applications) for the exact settings. The app requests `openid profile email`. Define application permissions in your tenant's permission catalog and assign roles separately; permission names are not additional OAuth scopes. ## Set up the example Use `http://localhost:8007` as the app origin and register `http://localhost:8007/auth/callback` exactly. Set these environment values in your local runtime: | Variable | Value | | ---------------------- | -------------------------------------------- | | `UDIBO_ISSUER` | Your tenant's issuer origin | | `APP_ORIGIN` | `http://localhost:8007` | | `UDIBO_CLIENT_ID` | The registered application's client ID | | `UDIBO_CLIENT_SECRET` | A secret created for that application | | `UDIBO_WEBHOOK_SECRET` | The signing secret for your webhook endpoint | If you have the template checkout, run `deno task dev` from `templates/b2b-saas`. The application requires all five values at startup. Keep secrets on the server. Register a webhook endpoint at your reachable HTTPS application origin plus `/webhooks/udibo`. A hosted service cannot deliver to your laptop's localhost: use a development tunnel or a deployed preview for that part of the trial. Follow [webhook registration](https://www.udibo.com/docs/identity/webhooks) and select the organization events you want to receive. ## 1. Sign in and establish a session The server discovers the tenant's endpoints, starts the authorization-code flow, exchanges the code, and keeps tokens behind a backend-for-frontend. The browser carries an application session cookie. Login, signup, and consent run on the tenant's hosted pages. Configure [sign-in methods](https://www.udibo.com/docs/identity/social-login) there instead of rebuilding those pages inside the example. **Checkpoint:** you return to the app after login, and a protected request is validated through introspection. ## 2. Keep customer organizations separate Create two [organizations](https://www.udibo.com/docs/identity/organizations). Give your test user `projects:read` and `projects:archive` through roles in one organization, then give them different access in the other. The project list reads the validated token's `permissions` and active `org_id`, then restricts its own rows to that organization. A credential answers for at most one active organization. Permission to read in one must not expose another's projects. The archive action obtains a project's organization from the application's own data and asks `POST /api/check` about `projects:archive` in that organization. It does not accept an organization supplied by the browser as proof of access. **Checkpoint:** a user can read or archive only where their role allows it. Test a project in the other organization and an unknown project ID. ## 3. Receive organization changes Verify the webhook signature over raw request bytes before parsing JSON. Enforce the timestamp tolerance and deduplicate deliveries. The activity list must also filter events to the reader's organization. Read the [delivery contract](https://www.udibo.com/docs/identity/webhooks) for retry behavior and the current private-beta worker cadence. Treat webhook data as eventually delivered; authorize protected requests from current validated identity. ## Make it your product Replace the example's in-memory project and activity stores with durable storage. Keep organization ownership on each application row, and retain the cross-organization denial tests. The example links to hosted organization-management pages. Plan the return journey back into your application. It does not call administrator-only tenant-management APIs using an end user's token. Embedding those screens is an option the example does not take. Your tenant's own host answers `/api/organizations` for the person signed in to your application: it creates organizations, invites members, and manages memberships and the organization's own roles, authorized by that person's own accepted membership rather than by any credential of yours — no scope, no dashboard access, and a machine (`client_credentials`) token refused on every verb including create. See [organizations](https://www.udibo.com/docs/identity/organizations) for the address, the verbs and the authority tiers. Continue with [permissions](https://www.udibo.com/docs/identity/permissions) and the [production checklist](https://www.udibo.com/docs/identity/production). _Last verified 2026-09-14._ --- Source: https://www.udibo.com/docs/identity/b2c-app-template # Build a consumer application **Availability:** this walkthrough describes a service template currently available through beta repository access. It is not a public standalone starter. You can follow the same design in your own application using the [first-login guide](https://www.udibo.com/docs/identity/get-started). For runnable public package examples, use [starter projects](https://www.udibo.com/docs/identity/starter-projects). ## Before you start You need an accessible Udibo tenant, a development application configured as a confidential authorization-code client with refresh tokens, and a client secret. Choose opaque access tokens for these walkthroughs; they validate through introspection. See [application registration](https://www.udibo.com/docs/identity/applications) for the exact settings. The app requests `openid profile email`. Define application permissions in your tenant's permission catalog and assign roles separately; permission names are not additional OAuth scopes. ## Set up the example Register `http://localhost:8008/auth/callback` and configure these local runtime values: | Variable | Value | | --------------------- | -------------------------------------- | | `UDIBO_ISSUER` | Your tenant's issuer origin | | `APP_ORIGIN` | `http://localhost:8008` | | `UDIBO_CLIENT_ID` | The registered application's client ID | | `UDIBO_CLIENT_SECRET` | A secret created for that application | If you have the template checkout, run `deno task dev` from `templates/b2c-app`. Keep the client secret and tokens on the server. The example models a personal notes application. Define `notes:publish` as an application permission and grant it through a role to one test user. ## 1. Establish who is making the request Hosted login completes through a backend-for-frontend. The server validates access tokens through introspection and reads profile and email fields from UserInfo. The browser receives a session cookie. Use the validated subject to identify the user. Email and display name are profile fields; they can change and are not database ownership keys. **Checkpoint:** sign in as two people and confirm each request resolves to the correct user. ## 2. Protect each person's data The notes list filters by the signed-in subject. Reading a note checks its owner before returning it. Keep both checks when replacing the example's in-memory store with your database: a filtered list does not protect a direct request for somebody else's note ID. Creating a note also requires a verified email address. This is an account requirement separate from being authenticated. Use the validated profile's verification state and provide a clear path to verification. **Checkpoint:** one person's note never appears in another person's list or direct lookup. An unverified user cannot create a note. ## 3. Add a paid or privileged capability Publishing requires both ownership of the note and the `notes:publish` permission from the token's current introspection response. A role can grant that permission without changing application code. The browser's cached session data can help render a button. It is not the authority for the server action. Removing the role should deny the next protected request even if the browser still shows the old button. **Checkpoint:** test an owner with the permission, an owner without it, and a different user who does have the permission. ## Make it your product Choose your [signup fields](https://www.udibo.com/docs/identity/signup-fields), [passwordless options](https://www.udibo.com/docs/identity/passwordless), and [MFA policy](https://www.udibo.com/docs/identity/mfa). Walk through recovery and logout as well as the happy-path login. The template demonstrates profile reads and application-owned notes. It does not implement every account-management or sensitive-action reauthentication screen. Identify those requirements before treating the example as a complete account area. Replace in-memory data, use production session storage, and finish the [production checklist](https://www.udibo.com/docs/identity/production). For existing users, begin with [migration planning](https://www.udibo.com/docs/identity/migrate). _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/client-portal-template # Build a client portal **Availability:** this walkthrough describes a service template currently available through beta repository access. It is not a public standalone starter. You can follow the same design in your own application using the [first-login guide](https://www.udibo.com/docs/identity/get-started). For runnable public package examples, use [starter projects](https://www.udibo.com/docs/identity/starter-projects). ## Before you start You need an accessible Udibo tenant, a development application configured as a confidential authorization-code client with refresh tokens, and a client secret. Choose opaque access tokens for these walkthroughs; they validate through introspection. See [application registration](https://www.udibo.com/docs/identity/applications) for the exact settings. The app requests `openid profile email`. Define application permissions in your tenant's permission catalog and assign roles separately; permission names are not additional OAuth scopes. ## Set up the example Register `http://localhost:8009/auth/callback` and configure: | Variable | Value | | --------------------- | -------------------------------------- | | `UDIBO_ISSUER` | Your tenant's issuer origin | | `APP_ORIGIN` | `http://localhost:8009` | | `UDIBO_CLIENT_ID` | The registered application's client ID | | `UDIBO_CLIENT_SECRET` | A secret created for that application | If you have the template checkout, run `deno task dev` from `templates/client-portal`. Define `documents:read`, `documents:write`, and `documents:share` in your permission catalog. Register the document and project resource types you use, and arrange initial resource grants through an administrator workflow. [Permissions](https://www.udibo.com/docs/identity/permissions) explains the resource and grant API shapes. ## 1. Ask about the document being accessed A token cannot enumerate every document a person may access. The application gets candidate documents from its own data store and asks Udibo about those specific resources. The list uses `POST /api/check/batch`: one set of checks for documents and another for their projects, chunked to the endpoint's limit of 100 candidates. It returns only documents allowed by the resulting decisions. A direct read checks the same access boundary. Updating a document additionally requires `documents:write`. Keep the read and write decisions separate. **Checkpoint:** a user granted one document sees that document and cannot read another by guessing its ID. ## 2. Model inheritance deliberately Udibo does not store your application's document-to-project hierarchy. The portal knows that relationship, checks both resources, and accepts an appropriate grant on either. A project grant therefore reaches its documents because the application applies that rule. It does not automatically reach arbitrary descendants in Udibo. **Checkpoint:** grant a project and verify access to its documents, then confirm a different project's documents remain inaccessible. ## 3. Share with people or organizations A resource grant can name a person or an organization. An organization grant resolves through accepted membership and can restrict which member roles may exercise it. The active organization on a token is not a substitute for a resource check. An organization-scoped `documents:read` role does not by itself grant access to every document resource. Conversely, a person with a direct resource grant may have no `permissions` claim on their token and still be allowed to read it. **Checkpoint:** test a direct recipient, an organization recipient, a member outside the grant's role restriction, and someone with a similarly named organization permission but no resource grant. ## What sharing can do today The example reads `documents:share` to decide who should be offered a sharing option. That application permission does not authorize a write to Udibo's tenant-management API. Resource grants currently require an administrator workflow, and application machine tokens do not provide general tenant-management access. Account for that limitation before promising self-service sharing to your customers. Replace the in-memory workspace with durable application data, preserve consistent responses for missing and inaccessible documents, and use the [production checklist](https://www.udibo.com/docs/identity/production) before launch. _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/mcp-server-template # Protect an MCP server **Availability:** this walkthrough describes a service template available through beta repository access. It is not a public standalone starter. You need an accessible Udibo tenant. For runnable public package examples, see [starter projects](https://www.udibo.com/docs/identity/starter-projects). The template runs a stateless MCP server on Deno and Hono. Its `team_notes` tool returns sample notes for one configured organization. The server accepts people whose access token names that organization and carries `notes:read`. ## Register the client and grant access Choose the server's canonical external URL, ending in `/mcp`, such as `https://notes.example.com/mcp`. In your tenant, define `notes:read`, create a role carrying it, and grant that role to the intended people inside the organization. Follow [permissions](https://www.udibo.com/docs/identity/permissions) for role assignment. Manually register the MCP client's application with these settings: - **Type:** third-party. Hosted authorization requests consent for scopes the person has not already approved. - **Client authentication:** public (`confidential: false`), with no secret. - **Grant:** authorization code. - **Access token format:** JWT. - **Audience:** the exact external MCP URL, including `/mcp`. - **Redirect URI:** the client's IP-loopback callback, including the path and any fixed query parameters. Production registrations permit HTTP callbacks on `127.0.0.1` or `[::1]` and allow an ephemeral port. A `localhost` hostname is not the production loopback exception. See [application registration](https://www.udibo.com/docs/identity/applications) for the dashboard and API workflow. Dynamic client registration is not part of this starter. Configure your MCP client with its registered client ID and `openid profile` scopes. Permission names are separate from OAuth scopes. The client must use PKCE S256, generate and verify OAuth state, and request the intended organization with the hosted authorization endpoint's `organization` slug parameter. The TypeScript SDK's OAuth provider supports `state()` but makes it optional; supply it for Udibo. Sign in and approve the displayed consent. ## Configure the server Set three environment values in the template's `.env`: - `UDIBO_ISSUER`: your tenant origin, such as `https://.udibo.com`. - `MCP_RESOURCE_URL`: the canonical external server URL ending in `/mcp`. - `MCP_ORGANIZATION_ID`: the ID of the organization whose notes it exposes. Run `deno task serve` from `templates/mcp-server` in the template checkout. The development defaults listen on `127.0.0.1:8010` and identify the resource as `http://127.0.0.1:8010/mcp`. Set `DENO_SERVE_ADDRESS` for your deployment, serve HTTPS through your proxy, and preserve the canonical Host header. The server rejects other Host values and foreign Origin headers; this template is intended for native MCP clients, not cross-origin browser clients. **Checkpoint:** call `/mcp` without a bearer token. It should return 401 with a `WWW-Authenticate` header containing `resource_metadata`. Fetch that URL: its `resource` must be this MCP server's URL, and its `authorization_servers` must contain your tenant origin. The client uses those documents to discover the hosted authorization and token endpoints. ## Verify a tool call and a refusal Connect the MCP client using Streamable HTTP. After authorization, initialize the connection, list tools and call `team_notes`. A successful call returns the configured organization ID and a sample welcome note. Repeat with a member who has no `notes:read` grant, and with a permitted person acting in another organization. Both must receive 403. The organization check compares the verified ID exactly; another organization's matching slug does not grant access. A token from another tenant or for another audience must receive 401. Machine credentials cannot supply the required `openid` scope. The server creates a fresh transport per POST and returns JSON without a server-side session ID. GET streams and session DELETE return 405. Each request must carry its own bearer token in the Authorization header. ## Keep the boundaries when extending it Replace the sample notes with your application's storage and keep its queries scoped to the configured organization. This starter demonstrates a read-only tool, not tenant administration or resource-level sharing. Udibo currently mints the application's fixed registered audience. MCP clients send `resource` on authorization and token requests, but that parameter does not select or narrow token issuance. Register a separate application for another resource; this is not general RFC 8707 resource selection. Never pass the MCP token onward to UserInfo, `/api/check`, or another API. JWT validation uses the tenant's public keys and checks signature, access-token type, issuer, audience and expiry. It does not perform an immediate revocation lookup. Choose an access-token lifetime appropriate for your data and keep host clocks synchronized: the starter allows no clock leeway. See [application registration](https://www.udibo.com/docs/identity/applications) and the [production checklist](https://www.udibo.com/docs/identity/production) before deploying. _Last verified 2026-09-09._ --- Source: https://www.udibo.com/docs/identity/migrate # Move an existing application to Udibo Treat migration as two connected changes: your application starts trusting a new issuer, and your users gain identities in that issuer. You can prepare and test both before changing the login most customers see. **The hosted service is in private beta, and user import is operator-run.** Coordinate access, the import method, and the cutover with Udibo before setting a launch date. There is no self-serve import endpoint or automatic ongoing sync. ## 1. Inventory what your current login does List the behavior your users rely on, not just the SDK imports: | Area | Questions to settle | | ------------- | ------------------------------------------------------------------------------------------------------------ | | Identities | Which stable user IDs does your app store? Can you export verified-email state and social provider subjects? | | Credentials | Can the provider export password hashes? What exact algorithms, parameters, and encodings are present? | | Recovery | Which password-reset, MFA, passkey, and email-verification flows must survive? | | Authorization | Where do organization membership, roles, resource ownership, and paid entitlements live? | | Sessions | Which cookies, token audiences, refresh behavior, and logout promises does the app depend on? | | Integrations | Which webhooks, enterprise connections, mobile deep links, and background jobs use the current provider? | Use the [comparison for your current product](https://www.udibo.com/docs/compare) to identify its particular migration questions. Hosted passkeys, SAML, and SCIM are not available in Udibo today; resolve any such dependency before attempting a move. ## 2. Connect a test application first Complete [your first sign-in](https://www.udibo.com/docs/identity/get-started) with a development registration and test accounts. Keep the current login available while the new callback, session storage, and API validation are being tested. If your app already uses OIDC, start with its existing standards-based client. Compare discovery, claims, audience, logout, and refresh expectations before replacing application code. A shared protocol does not make provider-specific SDK hooks or management APIs interchangeable. When accepting both providers during a rollout, validate each against its own configured issuer, keys, and audience. Do not let unverified token content choose an arbitrary issuer or disable verification to accept both. ## 3. Preserve the connection to your product data Keep your application's existing user ID where possible. Add an explicit mapping from the old external identity to the new `(issuer, subject)` identity. Test that the mapping still finds the person's projects, purchases, and organization memberships. Do not merge accounts solely because two records contain the same email address. Resolve duplicate or ambiguous identities before rollout using verified ownership and a reviewed mapping. Treat imported social identities as `(provider, subject)` pairs, not display names. Your application's role assignments and resource ownership are a separate migration. Importing a user does not reproduce the authorization model from another product. Recreate and test the necessary [organizations](https://www.udibo.com/docs/identity/organizations) and [permissions](https://www.udibo.com/docs/identity/permissions). ## 4. Choose a credential strategy | Situation | Plan | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | You can export a supported password-hash format | Use the [user import format](https://www.udibo.com/docs/identity/user-import); a successful sign-in upgrades the imported credential | | The provider cannot export hashes, or the encoding is unsupported | Import the account without a password and plan a supported recovery or sign-in flow | | The account uses only a social provider | Import its supported provider/subject association and verify the connection configuration | | You run authentication in your own app instead of using the service | Use the [package migration guide](https://www.udibo.com/docs/oauth2/guides/migrate-from-another-provider) and your own storage implementation | The hosted importer accepts a defined subset of bcrypt, PBKDF2, and scrypt encodings. It does not accept Argon2 or every provider's format. Read the [exact limits](https://www.udibo.com/docs/identity/user-import) before promising a migration without password resets. A password import does not transfer MFA enrollments, passkeys, active sessions, or refresh tokens. Agree on a secure transfer channel with Udibo before sending credential material. Do not email password exports or put them in an issue or agent chat. ## 5. Rehearse a small cohort Include a password user, a social-only user, a user needing recovery, a user with multiple organizations, and a disabled user. Test successful behavior and refusals: an invalid password, a revoked session, an unverified email, and a request for another customer's data. Reconcile the import's accepted, skipped, and rejected records. A repeated import is not an incremental synchronization mechanism; existing live-email accounts are skipped. Decide how you will handle accounts created or changed between the export and cutover. ## 6. Cut over with a rollback plan Write down the rollout cohort, the final export window, how users will sign in, and who can pause the change. Keep the old provider available for the agreed rollback period. Do not immediately delete credentials, mappings, or the configuration needed to return traffic to it. Rollback also needs a data plan: an account created only in Udibo will not automatically exist in the previous provider, and a password changed after cutover will not automatically work there. Resolve those cases before widening the rollout. Watch sign-in failures, recovery requests, and authorization refusals as each cohort moves. **Next:** prepare the [import file](https://www.udibo.com/docs/identity/user-import) and review [production readiness](https://www.udibo.com/docs/identity/production). _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/user-import # User import **Purpose:** move an existing user base onto the hosted identity service without making anyone reset their password. You hand over an export containing each user's existing password hash; we store it as-is, and the first time that person signs in correctly we verify against the old hash and quietly rehash into our own format. Nobody is emailed, nobody is interrupted. This page covers both halves: what the importer accepts, and what happens on that first sign-in. ## Import is operator-run today, not self-serve **There is no import endpoint and no dashboard screen.** The importer is a command-line tool run against the database directly, so bringing a user base over means sending us the file and asking us to run it. There is no `POST /api/identity/{tenantId}/users/import`, no upload form, and no scheduled or incremental sync. Plan around that: it is a one-shot cutover you coordinate with us, not something your own deployment pipeline can call. Everything below describes the file you prepare and what will happen to it — the mechanics are worth knowing even though you are not the one invoking them, because every rule below decides whether one of your users lands or is skipped. Moving the other way is **not** symmetrical — taking your data back out is a set of ordinary scoped API calls rather than something we run for you. ## The file An array of JSON objects, or one JSON object per line (NDJSON). The format is detected from the first non-whitespace character, so either works with no flag. **CSV is not accepted.** Only `email` is required. | Field | Rules | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | **Required.** Lower-cased and trimmed before anything else looks at it | | `username` | Up to 50 characters. Omitted, it falls back to the email's local part | | `firstName` | Up to 100 characters, falling back to the email's local part | | `lastName` | Up to 100 characters, falling back to the email's local part | | `displayName` | Up to 100 characters | | `emailVerified` | Carry it over — an address you had already verified stays verified | | `legacyCredential` | The existing password hash, up to 1024 characters. See the formats below | | `identities` | Social logins to relink: `{ provider, subject, email? }`. `provider` and `subject` are capped at 255 characters; the optional `email` is not capped | | `metadata` | Your admin-owned bucket: a JSON **object** at the root, at most 16 KB serialized, 8 levels deep, no NUL characters | | `userMetadata` | The user-editable bucket, same caps | **Omit `legacyCredential` for an account that never had a password** — an account that only ever signed in through a social provider, or a passwordless one. List its social logins in `identities` and it imports whole. Leave both out and the account still imports; that person signs in through whatever your tenant offers and sets a credential then. `type: "bot"` is refused per record. Every imported account is a person. Shaping your own export into that file, with the accounts that never had a password carried by their social links instead: ```ts interface ImportRecord { email: string; username?: string; firstName?: string; lastName?: string; emailVerified?: boolean; legacyCredential?: string; identities?: { provider: string; subject: string; email?: string }[]; metadata?: Record; } interface SourceUser { email: string; handle: string | null; passwordHash: string | null; verifiedAt: Date | null; googleSubject: string | null; plan: string; } function toImportRecord(user: SourceUser): ImportRecord { const record: ImportRecord = { email: user.email, emailVerified: user.verifiedAt !== null, metadata: { plan: user.plan }, }; if (user.handle) record.username = user.handle; if (user.passwordHash) record.legacyCredential = user.passwordHash; if (user.googleSubject) { record.identities = [ { provider: "google", subject: user.googleSubject, email: user.email }, ]; } return record; } function toNdjson(users: SourceUser[]): string { return users .map((user) => JSON.stringify(toImportRecord(user))) .join("\n"); } ``` Send that file over an encrypted channel and delete it once the import is confirmed: it is password material, even though every value in it is already a hash. ## Which password hashes we can carry `legacyCredential` must be in a format one of our verifiers recognizes, or **the record is refused outright** — it is not imported without a password, it is not imported at all. Check your export against this list before sending it. | Format | Encoding we read | | ---------- | -------------------------------------------------------------------------------------------------------------------- | | **bcrypt** | `$2a$…`, `$2b$…` or `$2y$…` | | **PBKDF2** | `pbkdf2_$$$`, or `$pbkdf2-$i=…$…$…`; `sha1`, `sha256` and `sha512` digests | | **scrypt** | `:` — a 16-byte salt and 64-byte key as hex, N=16384, r=16, p=1 | PBKDF2 has two bounds: an iteration count above 1,000,000 is refused, and so is a checksum shorter than 16 bytes — a short one would let anything match. **bcrypt has one too: a cost factor above 12 is refused**, per record, naming the cost it found. Each step of that factor doubles the verification work, and past 12 a wrong-password refusal on that hash takes longer than the fixed deadline every refused sign-in is held to (below) — which would leave those accounts identifiable by response time, the exact signal that deadline exists to remove. A hash whose cost cannot be read is refused for the same reason. **If your provider hashed above cost 12, those users cannot bring their hash across** — import them with no `legacyCredential` and let them set a password through reset, the same fallback argon2 takes below. Upgrade-on-login cannot help there: it needs an imported hash to verify against, so an account imported without one has no password until its owner sets one. **argon2 is not accepted**, and neither is any scrypt encoding other than the one above. If that is what you have, import those users with no `legacyCredential` and send them through your own password reset; everything else about their account still carries over. ## What happens to each record The whole batch inserts in **one transaction**, in chunks, so a database failure leaves nothing half-imported. A record that fails _validation_, though, does not abort the run — it is collected and reported, and the rest still land. - **An email that already has a live account is skipped**, silently and without error, and counted as skipped. That is what makes a re-run safe: importing the same file twice changes nothing the second time. An email freed by a deleted account is not a conflict and imports normally. - **A username collision resolves two different ways.** A `username` you supplied that is taken **skips the whole record**. One we generated from the email's local part is disambiguated instead — `ada`, then `ada-2`, `ada-3` — so a batch of people sharing a local part all land. - **Social identities dedupe on `(provider, subject)`.** A link already pointing at a live account is left alone. One pointing at a **deleted** account is **retargeted**: the old link is removed and the pair is relinked to the account being imported. So re-importing someone you previously deleted moves their social login onto the new account rather than failing — intended for exactly that case, but worth knowing before you re-import a file that overlaps with deleted users. **Idempotency is by email, not by an id of yours.** There is no external-id field to match on, so if you correct a record and re-send the file, the existing account is skipped rather than updated. Fix data on the account after import rather than expecting a second import to patch it. The run reports `{ imported, identitiesLinked, skipped, errors }`, and each error names the record's position and what was wrong with it, so you get one list to fix rather than a stop at the first bad row. **There is no dry-run mode** — a validation pass and the real thing are the same run, which is a good reason to send a small sample first. **That per-record tolerance starts only after the file parses.** A malformed line — one truncated NDJSON record, one stray comma — fails the whole file before any record is examined, and the failure does **not** say which line was bad. Validate that your export is well-formed JSON or NDJSON before sending it; that is the one error the run cannot localize for you. The batch also writes an `admin.user.imported` event into your [audit log](https://www.udibo.com/docs/identity/audit-log), carrying `imported`, `skipped` and the number of errors. **It does not record `identitiesLinked`** — that count exists only on the run's own output, so if you need a durable record of how many social logins were relinked, capture it when the import runs. Rate limits do not apply to the import: it writes rows directly and never touches the sign-in limiters, so a large file cannot throttle itself. **The sign-in surge afterwards is limited normally**, though — if you are cutting over a large user base at a fixed moment, that is the part to think about. ## Upgrade-on-login An imported account holds your old hash and no native credential. The first time that person signs in with the right password, we verify against the old hash, rehash the password into our own format, clear the old hash, and let the sign-in through. It happens inside that request, and there is nothing for you to call. Each upgrade writes an `auth.password.upgraded` event naming which verifier matched, so you can watch the migration drain in your audit log rather than guessing. Three details worth knowing, because each one is a place a reasonable assumption would be wrong: - **A wrong password is refused the same way on an imported account as on an upgraded one.** The response is identical and it counts toward lockout identically, so neither the person signing in nor your support desk can tell the two apart from what comes back. The _timing_ matches too: every refused sign-in is held to one deadline — 400 ms, measured from the start of the attempt rather than added to it — so the extra work of checking an old bcrypt or scrypt hash is not visible from outside, and an address with no account at all takes the same time as either. That holds because the deadline is sized above the most expensive hash the importer will accept, which is why the bcrypt cost cap above exists. Three things the deadline does not cover, stated so you are not surprised: a sign-in refused by the **rate limiter** answers sooner, and answers that way whether or not the account exists; a submission refused by the **CAPTCHA** is refused before any account is looked at, so it too answers sooner, and likewise reveals nothing about the account; and a **successful** sign-in is never delayed, so the first upgrade-on-login for a given account can be slower than a later sign-in by that person. - **If the rehash cannot be saved, the sign-in still succeeds.** A storage failure at that moment does not deny someone a password they just proved. The old hash stays in place, no `auth.password.upgraded` event is written, and the next sign-in tries the upgrade again. So a missing event means "not upgraded yet", never "upgraded silently". - **A native credential always wins.** Once an account has one, the imported hash is never consulted again, so an old hash left behind cannot resurrect a password. **Only the emailed password reset clears the imported hash.** Completing a reset from the link we send both sets the new credential and removes the old one. Every other way a password changes — the account's own settings page, an administrator setting a temporary one, the management API's password endpoint — sets the native credential and **leaves the imported hash in the row**. That leftover is inert: once a native credential exists the imported hash is never read again, on any sign-in path. But it means **"still has a legacy credential" is not a reliable measure of who is left to migrate** — an account that changed its password on the settings page is fully migrated and still carries the row. Count `auth.password.upgraded` events, or treat the presence of a native credential as the signal, rather than the absence of the old hash. Nothing expires an un-upgraded account. Someone who never signs in keeps their imported hash indefinitely, so the migration finishes when your users finish it, not on a deadline. ## What does not come across Import moves accounts and their sign-in methods. It does not move state that is bound to a session or a device: - **MFA enrollments.** There is no field for a TOTP secret or recovery codes, and none is imported. Users re-enroll; if your policy is `required` they are prompted on their first sign-in. - **Sessions.** Everyone signs in once after the move, by construction. - **Provider-issued OAuth tokens.** The link to a social account carries over — the access and refresh tokens that provider issued do not. - **Organizations, memberships and roles.** There is no field for them; create them after the accounts exist. ## Limits today - **No import endpoint, no dashboard screen, no incremental sync.** One coordinated run, as above. - **No dry-run** and no report file — the counts and per-record errors are printed by the run. - **No external-id idempotency.** Matching is by email. - **The file is read whole rather than streamed**, so a very large export is bounded by the memory of the machine running it. Split it if it is huge. - **argon2 is not among the accepted hash formats.** _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/webhooks # Webhooks Receive identity events on your own server. This guide covers endpoint registration, signature verification, retries, and the delivery behavior you should design around. ## Registering an endpoint An endpoint is a URL, a set of subscribed event types, and a signing secret we mint for you. Manage them in the dashboard under **Developers** → **Webhooks**, or through the management API: | Method | Path | Scope | | -------- | ------------------------------------------------------------------------ | -------------------------- | | `GET` | `/api/identity/{tenantId}/webhooks` | `identity:webhooks:read` | | `POST` | `/api/identity/{tenantId}/webhooks` | `identity:webhooks:write` | | `GET` | `/api/identity/{tenantId}/webhooks/event-types` | `identity:webhooks:read` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}` | `identity:webhooks:read` | | `PATCH` | `/api/identity/{tenantId}/webhooks/{id}` | `identity:webhooks:write` | | `DELETE` | `/api/identity/{tenantId}/webhooks/{id}` | `identity:webhooks:write` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}/secret` | `identity:webhooks:secret` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/secret/rotate` | `identity:webhooks:secret` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/test` | `identity:webhooks:write` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}/deliveries` | `identity:webhooks:read` | | `GET` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}` | `identity:webhooks:read` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/replay` | `identity:webhooks:write` | | `POST` | `/api/identity/{tenantId}/webhooks/{id}/deliveries/{deliveryId}/redrive` | `identity:webhooks:write` | **`{tenantId}` is the tenant whose endpoints you are managing** — the same id that appears in your dashboard URL. Every path above is authorized against _that_ tenant rather than against whichever host you called: your token needs the scope in the table **and** administrative authority over the tenant you named. A token that holds nothing over it is answered exactly as one naming a tenant that does not exist, so the URL never tells you whether a tenant is real. **These addresses changed on 2026-08-06, and nothing else did.** The paths above previously carried no tenant (`/api/identity/webhooks…`) and resolved one from the request's host; the tenant now sits in the path. **No capability, scope, or guarantee changed — only the address.** The same scopes gate the same operations, the event catalog is the same catalog, and everything below this section — the destination rules, the once-only secret, signing, the retry schedule, dead-lettering, and the delivery log — is untouched. Your existing endpoints, signing secrets, and delivery history were not modified by the move. The old paths no longer resolve; they were reachable only on Udibo's own host and only with a Udibo staff seat, so no customer request is affected. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. Receiving deliveries is unaffected: that is your own server, authenticating nothing of ours. Registering an endpoint returns the signing secret in the response, and that is the one time it is handed to you unprompted: ```ts interface CreatedEndpoint { id: string; url: string; eventTypes: string[]; secret: string; } async function registerEndpoint( tenantId: string, token: string, url: string, eventTypes: string[], ): Promise { const response = await fetch( `https://www.udibo.com/api/identity/${tenantId}/webhooks`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", }, body: JSON.stringify({ url, eventTypes }), }, ); if (!response.ok) { throw new Error(`could not register endpoint: ${response.status}`); } return await response.json() as CreatedEndpoint; } ``` **Destinations must be public.** We require `https` and refuse to deliver to loopback, private, link-local, CGNAT, and cloud-metadata addresses. The check runs when you register the URL and again before each delivery, where we also resolve the hostname and refuse it if any answer is a private address. That second check **pins the connection**: we resolve your hostname once, refuse the delivery if any answer is a private address, and then open the socket to one of the addresses we just approved rather than looking the name up again. A hostname whose DNS answer flips between the check and the connection — a deliberate rebinding attack with a very low TTL — has nothing to flip to, because there is no second lookup. TLS still completes against your hostname, so SNI and certificate verification are unchanged. A hostname we cannot resolve at all is refused (and retried) rather than attempted. Redirects are never followed, so a `3xx` toward an internal address is recorded as a failed attempt and nothing more. Each attempt opens its own connection and closes it afterwards; we never reuse a socket across the check that authorized it. Outside production, plain `http` and local addresses are permitted so you can point a development endpoint at your own machine; everything else about delivery — signing, retries, dead-lettering — is identical. The environments that relax this are an explicit allowlist, so a misconfigured deployment fails closed into production behavior rather than silently opening up. **The signing secret is shown once**, at creation and after each rotation. It is sealed (AES-256-GCM) at rest rather than hashed, because both we and you need the value, so you can also reveal it later from the endpoint page or `GET …/secret`. Every reveal and rotation is written to your audit log. Secrets never appear in list or read responses — those carry `hasSigningSecret` instead. ## Subscriptions Subscribe with exact event names (`auth.sign_in.succeeded`), a prefix wildcard, or `*` for everything. A wildcard entry must **end** in `.*` and matches everything beneath that prefix at any depth — `auth.*` and the narrower `auth.sign_in.*` are both valid. A wildcard anywhere else in the string is not: `auth.*.succeeded` is refused, not ignored. So is a prefix that matches nothing in the catalog, which is what catches a typo at registration rather than six months later when you notice the silence. **An endpoint's `eventTypes` is replaced wholesale, never merged.** It is an array, so a `PATCH` that sends it sends the complete new subscription list — patching in "one more event" with a single-element array unsubscribes everything else. Read the endpoint, append, and send the whole list back. Omit the field entirely and the existing subscriptions are left alone. An endpoint must keep at least one. The catalog is the identity event taxonomy — user and session lifecycle, auth security events, admin mutations, and the `usage.*` family — and is served live from `GET /api/identity/{tenantId}/webhooks/event-types` rather than pinned in this document, because it grows. **`usage.*` is the automation hook for your usage limits.** Every step of the alert ladder on the billing page — `usage.halfway` at half of a monthly limit or allowance (the day window has no halfway step), `usage.alert_threshold` at your alert threshold (80% by default, and sent even while no hard limit applies), and `usage.limit_reached` when a hard limit stops email or the retained-user allowance is passed — is recorded at most once per meter per window per line (a limit raised and reached again is recorded again), at the same moment the alert email goes out, and whether or not anyone is emailed. The delivery carries the event's `type`, the `tenantId`, and a `data.target` whose `type` names the meter and window — `emails:month`, `emails:day`, `own_sender_emails:month`, `own_sender_emails:day` or `users:month` — with the tenant as its `id`; it carries no `metadata`. The count (`used`), the bound it was measured against and the alert line live on the audit event's `metadata`, which the [audit log API](https://www.udibo.com/docs/identity/audit-log) returns for the delivery's `id`. Subscribe to `usage.limit_reached` to page someone, or to `usage.*` to feed a dashboard. The catalog is the same for every tenant; it is addressed by tenant so that one base URL serves the whole webhook surface, and it answers to anyone who administers the tenant. ## Payload Deliveries are `POST`ed as `application/json`: ```json { "id": "0197f1f0-…", "type": "auth.sign_in.succeeded", "occurredAt": "2026-07-26T12:00:00.000Z", "tenantId": "0197f1f0-…", "data": { "actor": { "type": "user", "id": "0197f1f0-…" }, "target": { "type": "user", "id": "0197f1f0-…" } } } ``` **Deduplicate on the `udibo-webhook-id` header, not on the payload's `id`.** The header identifies one _delivery_: every retry of it carries the same value, so dropping a repeat is exactly right. The payload's `id` identifies the originating _event_, and is deliberately reused when you replay an event — so deduplicating on it would make every replay a silent no-op. Use `id` to correlate a delivery back to the event (and to your audit log), and the header to decide whether you have already processed this delivery. **`id`, and both the actor and target, are nullable.** The test send from **Send test event** or `POST …/test` is delivered through the ordinary pipeline with the ordinary signature, but it has no originating event and no parties, so it arrives as `"type": "webhook.test"` with `"id": null` and an actor and target of `{ "type": null, "id": null }`. Type `id` as nullable or your consumer breaks on the first test you send — which is exactly the delivery you were using to prove it works. `webhook.test` is not in the catalog and cannot be subscribed to; it reaches an endpoint only when you ask for it by id. The payload deliberately carries identifiers and nothing else: our internal capture records detail (failure reasons, IP, user agent) that would make a webhook an account-enumeration oracle, so it is never forwarded. Read the rest through the audit log API. Adding fields here is additive and will be announced. ## Signature verification Every delivery carries three headers: | Header | Value | | ------------------------- | --------------------------------------------------------------- | | `udibo-webhook-id` | Delivery id — same across retries, new per replay; dedupe on it | | `udibo-webhook-timestamp` | Unix seconds at signing time | | `udibo-webhook-signature` | Space-separated `v1,` entries | The signature is **HMAC-SHA256**, keyed by the 32 raw bytes your secret encodes (everything after the `whsec_` prefix, base64url-decoded), over the exact string: ``` {udibo-webhook-id}.{udibo-webhook-timestamp}.{raw request body} ``` Sign the **raw body bytes**, before any JSON parsing or re-serialization. The header is a **list** by construction, so accept the delivery if **any** entry matches rather than assuming there is exactly one — that is what keeps your verifier working if a delivery ever carries two. **Rotating a signing secret overlaps the old and new secrets for 24 hours.** When you rotate, the endpoint keeps the secret it replaced. For the next 24 hours every delivery carries two entries in `udibo-webhook-signature`, one per secret, so your consumer verifies whether or not it has picked up the new value yet. After 24 hours the previous secret stops signing and deliveries carry the new entry alone. Deploy the new secret to your consumer any time inside that window. Each attempt is signed when it is sent, so a retry queued before the rotation goes out signed with whichever secrets are live at that moment. **Rotating again inside the window keeps only the secret that rotation replaced.** The oldest secret stops signing at once, and the 24 hours start again from the second rotation. **If you are rotating because a secret leaked, what protects you is your consumer no longer accepting it.** Remove the old secret from your verifier as soon as the new one is deployed. Signing with it during the window gives nobody anything new — forging a delivery takes the secret itself, not our signatures. There is no separate action to end the window early; rotating a second time takes the leaked secret out of signing immediately, because only the secret that rotation replaces is kept. The previous secret is sealed at rest exactly like the current one and is never returned: rotation answers with the new secret, and reveal answers with the current secret only. **Reject deliveries whose timestamp is more than 5 minutes from your clock**, and compare signatures in constant time. Binding the id and timestamp into the signed string is what stops a captured request from being replayed later or against a different endpoint. ```ts import { Buffer } from "node:buffer"; import { timingSafeEqual } from "node:crypto"; async function verify(request: Request, secret: string): Promise { const body = await request.text(); const id = request.headers.get("udibo-webhook-id"); const timestamp = Number(request.headers.get("udibo-webhook-timestamp")); const header = request.headers.get("udibo-webhook-signature"); if (!id || !header || !Number.isInteger(timestamp)) return null; if (Math.abs(Date.now() / 1000 - timestamp) > 300) return null; const raw = Uint8Array.from( atob(secret.slice("whsec_".length).replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0), ); const key = await crypto.subtle.importKey( "raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = new Uint8Array( await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`), ), ); const expected = `v1,${ btoa(String.fromCharCode(...mac)).replace(/\+/g, "-").replace(/\//g, "_") .replace(/=+$/, "") }`; const ok = header.split(" ").some((entry) => entry.length === expected.length && timingSafeEqual(Buffer.from(entry), Buffer.from(expected)) ); return ok ? JSON.parse(body) : null; } ``` Respond `2xx` to acknowledge. Anything else — or no response within **10 seconds** — is a failed attempt and is retried. **Only your status code is read.** We discard the response body without parsing it, so there is no way to tell us anything except by the status. In particular **`Retry-After` is ignored**: a `429` backs off on the schedule below, not on the interval you name, so shedding load is a matter of failing fast rather than asking us to wait. Every request arrives with `user-agent: Udibo-Webhooks/1.0` if you need something to allowlist on. ## Delivery guarantees **At-least-once.** A delivery row is written in the **same database transaction** as the event that produced it, so ordinarily the two commit together or not at all. An attempt then leases that row rather than removing it, so a worker that dies mid-attempt has the delivery reclaimed and retried. Design for duplicates: deduplicate on `udibo-webhook-id`. **One case breaks that pairing, and it favours the audit log.** If writing the deliveries fails after the event itself was written, the event is re-recorded on its own, without them. The audit log usually keeps the event; the fan-out is dropped, and because no delivery row was ever created there is nothing in the delivery log and nothing to redrive. It is rare, but it means **the audit log outlives the webhook stream** — reconcile against the [audit log](https://www.udibo.com/docs/identity/audit-log) if you need certainty rather than treating deliveries as complete. Capture is best-effort by design, though, so the audit log is the better record rather than a perfect one: if that second write also fails the event is lost, because recording is never allowed to fail the operation that produced it. A sign-in succeeds even when nothing could be written about it. **Not ordered.** Retries mean a later event can arrive before an earlier one. Use `occurredAt` if you need ordering. **Queued, then drained by a worker on a schedule.** The originating request never waits on your server. The worker drains continuously within a run rather than stopping after a fixed batch, and each round takes a bounded number of deliveries **per tenant**, so another customer's traffic spike cannot push your events behind theirs. If your own volume outruns delivery for a sustained period the queue lengthens — it is a queue, not a guarantee of constant latency — but nothing is dropped and the retry schedule below still applies. **Private-beta delivery cadence: every 15 minutes.** Budget up to about 15 minutes before the first attempt. The retry schedule and signature requirements below still apply; a failed attempt waits until a later worker run after its retry becomes due. **Up to ten of one tenant's deliveries are attempted at once**, and they can be for the same endpoint. Your consumer needs to be concurrency-safe, not merely idempotent. **Retry schedule — 8 attempts over about 45 hours:** | Attempt | Waits after the previous attempt | Elapsed since the event | | ------- | -------------------------------- | ----------------------- | | 1 | — | 0 | | 2 | 1 minute | ~1 minute | | 3 | 5 minutes | ~6 minutes | | 4 | 30 minutes | ~36 minutes | | 5 | 2 hours | ~2.6 hours | | 6 | 6 hours | ~8.6 hours | | 7 | 12 hours | ~20.6 hours | | 8 | 24 hours | ~44.6 hours | **Dead letter, never discard.** A delivery that uses up all 8 attempts moves to `exhausted` and stays in your delivery log. It is not dropped, and we do not silently give up: **redrive** it from the endpoint page or `POST …/deliveries/{id}/redrive` and it re-enters the queue with a full retry budget. That is the recovery path after an outage longer than the retry window. Redrive **reuses the same row** rather than adding one, so the log shows one delivery that was re-driven rather than two, and it accepts only an `exhausted` delivery — anything else answers `404`. **Replay** (`POST …/deliveries/{id}/replay`) is the other half — it queues a fresh delivery of an already-delivered event without disturbing the original log entry. Draining the dead-letter queue after your consumer is healthy again: ```ts interface Delivery { id: string; status: string; attempts: number; error: string | null; } async function redriveExhausted( tenantId: string, token: string, endpointId: string, ): Promise { const base = `https://www.udibo.com/api/identity/${tenantId}/webhooks/${endpointId}`; const authorization = `Bearer ${token}`; let redriven = 0; let cursor: string | null = null; do { const url = new URL(`${base}/deliveries`); url.searchParams.set("status", "exhausted"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const listed = await fetch(url, { headers: { authorization } }); if (!listed.ok) throw new Error(`list failed: ${listed.status}`); const page = await listed.json() as { data: Delivery[]; cursors: { next: string | null }; }; for (const delivery of page.data) { const response = await fetch( `${base}/deliveries/${delivery.id}/redrive`, { method: "POST", headers: { authorization } }, ); if (response.ok) redriven += 1; } cursor = page.cursors.next; } while (cursor); return redriven; } ``` Redriving moves a delivery out of `exhausted`, so re-listing the same filter after a pass returns what is still stuck rather than what you just queued. **Non-production environments retry on exactly the production schedule.** The schedule above is a single constant with no environment branch, so a staging consumer fails the same way production would instead of hiding the problem until launch. **A delivery stops early when retrying it cannot help.** Three things do that, and each lands in `exhausted` immediately with the reason recorded: - the endpoint was **disabled or deleted** — including deliveries already queued for it, which exhaust rather than draining first; - its URL is **no longer a permitted destination** — it stopped parsing, it carries embedded credentials, or it now resolves somewhere we refuse; - its **signing secret could not be opened** on our side. This one is ours, not yours: the endpoint is healthy and the URL is fine, and your events dead-letter anyway. Watch your delivery log for `exhausted` rows whose error says so, and raise it with us — redriving will not help until we have fixed it. A host that merely fails to resolve is **not** in that set: it is a normal failure and is retried on the schedule above. ## Delivery log Every attempt-set is visible per endpoint with its status, attempt count, last response code, last error, and timestamps — in the dashboard and through `GET …/deliveries` (cursor-paginated, newest first, 20 per page by default and at most 100). A delivery's `status` is one of `pending`, `delivering`, `succeeded` or `exhausted`; `?status=exhausted` is the dead-letter view, and a value outside that set is a `400` rather than a filter we ignore. The recorded `error` is **truncated at 300 characters**, so treat it as a lead rather than the whole message. **Delivery records are not purged today.** There is no retention window on them and no job that removes them, so your delivery log keeps growing and old records stay readable. That is a gap rather than a promise: when a retention schedule does arrive it will be announced, so do not build on records being there forever. _Last verified 2026-09-11._ --- Source: https://www.udibo.com/docs/identity/audit-log # Audit log **Purpose:** the append-only record of what happened in your tenant — who did it, to whom, from where, and when. Read it in the dashboard, page it through the management API, or pull the whole matching set down as a CSV or NDJSON attachment. The log is **append-only in the strict sense**: the service writes events, and nothing edits or deletes one. There is no update or delete verb on the resource and no "clear log" button, in the dashboard or the API. The only thing that ever removes a row is the retention purge described at the end of this page. ## What one event carries | Field | What it holds | | ------------ | ------------------------------------------------------------------- | | `id` | The event's own id | | `createdAt` | When it was recorded | | `eventType` | Its name in the event taxonomy, e.g. `auth.sign_in.failed` | | `actorType` | One of `user`, `client` (a machine token), `system`, or `anonymous` | | `actorId` | Who acted, when there is a who | | `targetType` | What kind of thing was acted on | | `targetId` | Which one | | `ipAddress` | The request's client address, where one was captured | | `userAgent` | The request's user agent, where one was captured | | `metadata` | A per-event-type object of details, or `null` | `actorId`, `targetType`, `targetId`, `ipAddress`, `userAgent` and `metadata` are **nullable, not optional**. An event with no such thing — a scheduled job has no user agent, a system event has no actor id, and an event with nothing extra to say stores `metadata` as `null` rather than `{}` — carries the key with a `null` value. So `"actorId" in event` is true on every event and tells you nothing; check the value. Type them nullable and a `metadata?.someField` reader will not throw on the events that have none. ## Reading it in the dashboard **Tenant** → **Audit log** lists the tenant's events newest first, with the filters below, an infinite-scrolling table, and a **CSV** and an **NDJSON** download button that apply whatever filters are on screen. A user's own page in the dashboard carries the same log narrowed with `involving` — everything that person did **or** had done to them, which is why an admin action on their account shows up there too. Your end users see a redacted slice of their own account's events on their `/security` page, on your tenant's own host. That view is a different, narrower surface with its own allowlist of event types, and what it redacts is **an administrator's identity and context** — an admin action shows up as having happened without naming who did it, and with no IP address or user agent attached. The system's own actions are redacted the same way. It does **not** redact the person's own context: their own actions, and anonymous attempts against their account, come back with the IP address and user agent intact. That is deliberate — those are the rows someone reads to recognise a sign-in that was not theirs. ## Reading it through the API | Method | Path | Scope | | ------ | ---------------------------------------------- | ---------------------------- | | `GET` | `/api/identity/{tenantId}/audit-events` | `identity:audit-events:read` | | `GET` | `/api/identity/{tenantId}/audit-events/export` | `identity:audit-events:read` | Both take the same filters. The difference is shape, not scope: the list answers one page of JSON, the export streams every matching event as an attachment. **Administrator credentials required.** These management calls use the credential associated with an authorized Udibo dashboard session. A machine credential issued to an application in your own tenant is not accepted on `/api/identity/…`. Use the dashboard or an approved administrator workflow; keep administrator credentials out of your application integration. ### The list Newest first, cursor-paginated. The default page is **20 events** and the largest you may ask for is **100**; a `limit` outside that range is clamped rather than refused. ```ts interface AuditEvent { id: string; createdAt: string; eventType: string; actorType: string; actorId: string | null; targetType: string | null; targetId: string | null; ipAddress: string | null; userAgent: string | null; metadata: Record | null; } interface AuditPage { data: AuditEvent[]; cursors: { next: string | null; prev: string | null }; hasMore: boolean; } async function signInFailures( tenantId: string, token: string, ): Promise { const found: AuditEvent[] = []; let cursor: string | null = null; do { const url = new URL( `https://www.udibo.com/api/identity/${tenantId}/audit-events`, ); url.searchParams.set("eventType", "auth.sign_in.failed"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, }); if (!response.ok) throw new Error(`list failed: ${response.status}`); const page = await response.json() as AuditPage; found.push(...page.data); cursor = page.hasMore ? page.cursors.next : null; } while (cursor); return found; } ``` **Stop on `hasMore`, never on `cursors.next`.** A page that returned rows carries a `next` cursor even when it is the last page — that is on purpose, so a poller can hold the cursor and ask again later for events that have not happened yet. Only a page with **no rows at all** comes back with `next: null`. A loop that runs until the cursor goes null does terminate — the page after the last one is empty and nulls it — but it always pays one extra request to find that out. Reading `hasMore` saves the round trip. The live cursor is not a mistake to work around: hold it and poll it, and it answers with the events recorded since, which is the cheap way to tail the log. The cursor carries its own direction, so there is no separate direction parameter, and ordering is fixed at newest-first — there is no `orderBy`. ### The export `GET …/audit-events/export` streams **every** event matching the filters, newest first, as a downloadable attachment. It is a streamed response rather than a built-then-sent file, so a large window starts arriving immediately instead of buffering server-side. | Query | Values | Default | | -------- | ----------------- | ------- | | `format` | `csv` or `ndjson` | `csv` | An unrecognized `format` is a `400` naming the two it accepts, and it is refused **before** anything streams — so a typo costs you nothing and records nothing. - **`csv`** — `text/csv; charset=utf-8` - **`ndjson`** — `application/x-ndjson` Both arrive as `content-disposition: attachment; filename="audit-events-YYYY-MM-DD."`, dated in UTC at export time, with `cache-control: no-store`. **`limit` is ignored here.** The export is defined by its filters, not by a page size; passing one changes nothing. `cursor` **is** honoured, and it is the way to resume: hand it a previous list page's `cursors.next` and the export starts below that position. **There is no cap on how many events an export returns**, no truncation, and no row-limit constant — a full retention window comes down whole. There is also no queued-job, signed-URL or emailed-file variant: the request streams the answer synchronously, so budget the connection time for a large window rather than expecting a job id back. #### The CSV Ten columns, in this fixed order, with a header row and CRLF line endings: ``` id,createdAt,eventType,actorType,actorId,targetType,targetId,ipAddress,userAgent,metadata ``` Absent values are empty fields, `createdAt` is ISO 8601, and `metadata` is its JSON serialization in a single field. Fields are quoted per RFC 4180 when they contain a comma, a quote or a line break, and any field beginning with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with a single quote so a spreadsheet reads it as text rather than a formula — the log records attacker-supplied strings like `userAgent`, and this is what keeps opening the file safe. **The CSV has no `tenantId` column.** You asked for one tenant by id, so every row is that tenant's. #### The NDJSON One JSON object per line, `\n`-separated, no header. The keys are the fields in the table above **plus `tenantId`**, which NDJSON carries as provenance because these files get concatenated and moved around in a way a spreadsheet does not. ```ts async function* exportedEvents( tenantId: string, token: string, since: string, ): AsyncGenerator> { const url = new URL( `https://www.udibo.com/api/identity/${tenantId}/audit-events/export`, ); url.searchParams.set("format", "ndjson"); url.searchParams.set("createdAfter", since); const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, }); if (!response.ok) throw new Error(`export failed: ${response.status}`); if (!response.body) throw new Error("export returned no body"); const decoder = new TextDecoder(); let buffered = ""; for await (const chunk of response.body) { buffered += decoder.decode(chunk, { stream: true }); const lines = buffered.split("\n"); buffered = lines.pop() ?? ""; for (const line of lines) { if (line) yield JSON.parse(line) as Record; } } if (buffered) yield JSON.parse(buffered) as Record; } ``` Read the records by key. **Key order is not part of the contract** — nothing pins it, so parse each line as an object rather than positionally. ## Filters Every filter below works identically on the list, the export and the dashboard view. | Query | Takes | Repeatable | | --------------- | ------------------------------------------------------------------ | ---------- | | `eventType` | An exact name (`auth.sign_in.failed`) or a whole category (`auth`) | Yes | | `actorId` | A user id | No | | `targetId` | The id of the thing acted on | No | | `involving` | An id that must appear as **either** the actor or the target | No | | `targetType` | The kind of thing acted on | No | | `createdAfter` | An ISO instant, or a bare `YYYY-MM-DD` meaning that whole UTC day | No | | `createdBefore` | The same | No | Filters combine with **AND**. Repeated `eventType` values are the exception — they OR with each other, so naming three types returns all three. **Two filters refuse and two do not, and the difference will surprise you.** - **`eventType`, `actorId`, `targetId` and `involving` fail closed.** An unknown event type, or a subject id that is not a UUID, is a `400` that streams nothing. - **`createdAfter` and `createdBefore` fail open.** A date the parser cannot read is dropped rather than refused, and the request answers as though you had not sent it — which on an export means a **wider** result than you asked for, not an error. Send an ISO instant or a bare `YYYY-MM-DD`, and check the earliest `createdAt` you got back before trusting a window. - **`targetType` is not validated against anything.** A value matching no rows returns an empty result rather than an error, so a typo here reads as "nothing happened". The dashboard treats a refused filter differently from the API, deliberately: a bad value in the browser's URL strips that filter and reloads the page telling you which one it dropped, rather than showing you an error page. **The dashboard's download link does not do that** — it is the raw surface, so a bad id there is the same `400` the API gives. ## Exporting is itself audited Every export writes an `admin.audit_events.exported` event **into the log it exported**, carrying who ran it, the format, and the filters. Reading tenant B's log is something that happened to B, so that is where the record lands — the acting administrator's own tenant is not where you look for it. **That record is the query string, not the query that ran**, and the difference matters if anyone audits it. The recorded `filters` are the raw values from the request URL for each recognized key — `eventType`, `actorId`, `targetId`, `targetType`, `involving`, `createdAfter`, `createdBefore`, plus `limit` and `cursor`. It records them whether or not they took effect, so the two behaviours above show up here as a trap: a `createdAfter` the parser silently dropped is still written to the record, and a reviewer reading `createdAfter: ["lastweek"]` would conclude a one-week export when the full retention window actually streamed. `limit` is recorded on exports too, where it does nothing. **Reconcile an export's scope against the events it returned, not against this record's filters.** Values are capped for the record's own safety — at most 25 per key and 256 characters each — so a very long filter can be truncated in the record while having applied in full. A refused export writes nothing. The format check and the filter validation both run before the event is recorded, so a `400` leaves no trace and no partial file. ## Retention Audit events are kept for **365 days** and then hard-deleted by a purge that runs daily. That number is configuration and can move; what is promised is the floor beneath it — **at least 90 days**, which the purge is not permitted to go below. The dashboard states the floor rather than the configured window, so build anything you depend on against 90 days and treat the rest as headroom. Purged means gone: these rows are deleted outright, not soft-deleted, so nothing brings them back. **If you need events for longer than the window, export them on a schedule** — the export is the archival path, and there is no separate archive we keep for you. ## Limits today - **No customer-mintable machine credential.** See the caveat above; scheduled exports are a dashboard-credential script today, not an unattended service. - **No streaming-out integration.** There is no log drain, no S3 destination and no SIEM connector; the export is a pull. - **No `orderBy` and no full-text search.** Newest-first with the filters above is the whole query surface. - **No JSON export format.** The list returns JSON but is paginated; the export is CSV or NDJSON. - **The export is not rate-limited**, which also means nothing throttles a runaway loop of them on your behalf. - **`usage.*` events are the usage-alert ladder**, one per step per meter per window per line (`services/udibo/billing/limit-alerts.ts`); they are what a webhook subscribed to your limits receives, and the `email.cap_reached` event beside them is the send path's own record of a refusal. Both carry the meter and window as `targetType` (`emails:month`, `emails:day`, `own_sender_emails:month`, `own_sender_emails:day`, `users:month`) with the tenant as `targetId`, so `targetType` filters one meter; the count (`used`), the bound and the alert line are in `metadata`. _Last verified 2026-09-08._ --- Source: https://www.udibo.com/docs/identity/production # Get ready for production A successful local login is the starting point. Before inviting real users, verify the deployed application can preserve sessions, refuse unauthorized requests, and recover when a credential expires or a dependency fails. **Hosted access is private beta.** Agree on the intended launch, service availability, support expectations, and commercial terms with Udibo. This checklist does not imply a production SLA or certification. ## Separate the environments - [ ] Create a production application registration with an exact HTTPS callback - [ ] Keep development and production client secrets separate - [ ] Decide whether test users and policy need their own tenant - [ ] Set the issuer, callback, audience, and application origin from trusted deployment configuration - [ ] Test through your real domain, TLS termination, and reverse proxy See [application configuration](https://www.udibo.com/docs/identity/applications) and the package's [environment guide](https://www.udibo.com/docs/oauth2/guides/deploy-across-environments). ## Make sessions durable - [ ] Replace every in-memory user, session, token, and application-data store that your chosen integration uses - [ ] Share pending-login encryption keys and session storage across instances - [ ] Keep application cookies HttpOnly, secure in production, and appropriately scoped; retain the BFF's CSRF protection - [ ] Test callback requests landing on a different instance from the login request - [ ] Prove a revoked or expired session cannot be recreated by an in-flight refresh - [ ] Define what signing out means for both the app session and tenant session Run the package's persistent-store contract tests against your implementation. A stateless encrypted cookie has different revocation properties from a stored session; do not promise immediate revocation of a copied cookie without a mechanism that enforces it. The [deployment guide](https://www.udibo.com/docs/oauth2/guides/production-deployment) covers the app-owned pieces in detail. ## Test authorization with the wrong user - [ ] Reject invalid, expired, wrong-issuer, and wrong-audience credentials - [ ] Refuse a signed-in user who lacks the required scope or permission - [ ] Refuse access to another user's record and another organization's data - [ ] Resolve resource ownership from trusted application records - [ ] Test a role being granted and revoked while the user remains signed in - [ ] Account for JWT expiry and any introspection caching in freshness promises - [ ] Keep dashboard administrator credentials out of application request paths Use [permissions and roles](https://www.udibo.com/docs/identity/permissions) for organization and resource checks. Some management operations require administrator credentials and are not available to a machine application in your tenant. Confirm the supported provisioning path before automating invitations, assignments, or resource grants. ## Exercise the account lifecycle - [ ] Configure sign-up mode deliberately: open, waitlisted, or closed - [ ] Check the user-facing sign-in, sign-up, recovery, and verification paths - [ ] Test each enabled social provider with its production callback - [ ] Test MFA enrollment and recovery under the intended tenant policy - [ ] Check branding and the support destination on the hosted pages - [ ] Verify email delivery and recovery with real test inboxes The managed service owns its hosted pages. If you chose to run authentication yourself, you also own credential storage, email delivery, rate limiting, lockout, and the MFA lifecycle. Use the package's [hardening checklist](https://www.udibo.com/docs/oauth2/guides/hardening-checklist). ## Prepare operations and rollback - [ ] Monitor authentication failures and recovery requests without logging passwords, codes, tokens, or complete callback query strings - [ ] Record enough context to correlate an application failure with the tenant's [audit log](https://www.udibo.com/docs/identity/audit-log) - [ ] Verify [webhook signatures](https://www.udibo.com/docs/identity/webhooks) against the raw request body, deduplicate deliveries, and store work before acknowledging it - [ ] Test secret rotation and the application's response to a temporary issuer or storage outage - [ ] Back up the application-owned data and rehearse restoring it - [ ] For migrations, rehearse the [rollback plan](https://www.udibo.com/docs/identity/migrate) with accounts created or changed after cutover **Ready for the first cohort:** sign-in, renewal, sign-out, recovery, and cross-user refusals all behave as promised in the deployed environment. If something fails, use the [troubleshooting guide](https://www.udibo.com/docs/identity/troubleshooting) to isolate the boundary before changing configuration. _Last verified 2026-09-06._ --- Source: https://www.udibo.com/docs/identity/troubleshooting # Find the next check Follow the request from the browser to the identity service, back through your callback, and into the protected API. Fix the first boundary that fails. Keep state, PKCE, token verification, and CSRF enabled while diagnosing the problem. ## The sign-in page does not open | Symptom | Check next | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | No tenant or application access | Confirm beta access and the tenant your administrator granted you | | Discovery fails | Check the configured issuer and fetch its `/.well-known/oauth-authorization-server` document from the backend | | Redirect URI rejected | Compare scheme, host, port, path, and query in the registration and application configuration | | Local HTTP callback refused | Use a Development registration for a localhost web callback | | `invalid_client` | Check the client ID, secret, confidential-client setting, and environment; creating the registration does not create its secret | | Sign-up unavailable | Check the tenant's sign-up mode and enabled sign-in methods; do not bypass a closed or waitlisted policy | The issuer is not the dashboard URL. A social provider's callback returns to the identity service; your application's callback receives the result from that service. They are two different registrations. ## The user signs in but the callback fails **Unknown state or missing transient cookie:** check whether the callback uses the same browser and application host that started the flow. Check cookie attributes, the pending-state secret, and whether every backend instance uses the same configuration and shared storage. Expired state should start a new login rather than being accepted without verification. **`invalid_grant` during the code exchange:** an authorization code may be expired, already consumed, or paired with the wrong callback or PKCE verifier. Start a fresh flow and inspect the request's configuration. Do not replay a captured code or weaken verification to make it pass. **Login works on one instance only:** the callback may arrive at a different instance. A process-local session or pending-state store cannot reliably serve that flow. See [session readiness](https://www.udibo.com/docs/identity/production#make-sessions-durable). ## The app looks signed in but the API refuses | Result | Check next | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `401` | Confirm the request carries the application's session cookie and the backend can validate its access token | | `403` on a BFF request | Use `BffClient` or supply the configured CSRF header from a same-origin client | | Valid token but missing API scope | Compare the requested scope with the client's allowed scope and the route's requirement | | Valid token but missing application permission | Check the role assignment and the organization or resource the operation actually targets | | A profile field is missing | Read UserInfo with `profile` or `email` as appropriate; an access token is not a full profile | | A JWT is rejected | Check the expected issuer, audience, signing key, algorithm, and expiry | Do not copy a bearer token into browser storage to work around a cookie problem. The [BFF integration guide](https://www.udibo.com/docs/oauth2/guides/use-udibo) shows how the browser calls the application while tokens stay on the backend. ## A role change seems to do nothing Check **where the answer came from**. The browser's session projection may still contain the values captured during login. Authorize from the validated request or an appropriate live permission check, not that UI copy. An offline JWT carries the claims from issuance until it expires. An opaque token can be introspected for current state, but your own caching can make that answer older. An active-organization claim is scoped to that organization; it does not answer a question about a different organization or a specific resource. See [permissions and roles](https://www.udibo.com/docs/identity/permissions). ## Refresh or logout behaves unexpectedly For `invalid_grant` during refresh, check expiry, revocation, and whether two requests tried to rotate the same credential. Preserve atomic store updates and the package's rotation behavior. End the failed session and start login again instead of retrying a rejected refresh token indefinitely. For sign-out, distinguish the application session from the identity service's session. A new login can return without another password prompt if the tenant's session is still valid. Test the exact local or SSO logout behavior your UI promises. ## Bring useful information to support Include the environment, the failing step, the HTTP status or error code, approximate time, runtime and package versions, and a minimal reproduction. Use your agreed beta support channel. Redact secrets, passwords, cookies, authorization codes, and token values. Callback URLs can contain credentials in the query string; report the registered URL rather than a captured login URL. **Next:** return to [your first sign-in](https://www.udibo.com/docs/identity/get-started) or [production readiness](https://www.udibo.com/docs/identity/production). _Last verified 2026-09-06._