# Build a consumer application **Availability:** this walkthrough describes a service template available through repository access arranged with Udibo. 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). The template is a React Router application served by Hono, with its notes, settings and sessions in Postgres through drizzle. It needs Node.js 26, pnpm and a Postgres database. ## What the walkthrough covers One person has a verified email and can create, edit and publish their own notes. Another has an unverified email and can read their own notes but cannot create, edit or delete one until the address is verified. Email, hosted account pages and MFA need a real tenant. ## Explore the product pages The signed-out landing offers **Get started**, which opens sign-up inside the same sign-in, and **Sign in**. After sign-in, **Notes** contains a composer and your own notes, and each note's page edits, publishes and deletes it. **Settings** separates Profile, Notifications and Security. Signing out another device and disconnecting a sign-in method ask for confirmation. A failed save keeps your draft so you can retry. Password, two-factor and connected-app links return to `/settings/security`. The application shows loading, empty and retry states without displaying authorization internals to its users. ## Before you start You need an accessible Udibo Identity tenant, a development application configured as a first-party, 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` as the redirect URI and `http://localhost:8008/` as a post-logout redirect URI; signing out ends the login at your tenant too and returns there. Configure these local runtime values: | Variable | Value | | --------------------- | -------------------------------------------------------------------------------------------------- | | `UDIBO_ISSUER` | Your tenant's issuer origin, `https://.udibo.com` | | `APP_ORIGIN` | `http://localhost:8008`; the registered callback is `{APP_ORIGIN}/auth/callback` | | `UDIBO_CLIENT_ID` | The registered application's client ID | | `UDIBO_CLIENT_SECRET` | A secret created for that application | | `DATABASE_URL` | The Postgres database the notes, notification addresses and sessions live in | | `SESSION_SECRET` | At least 32 bytes of entropy that encrypts sessions and sign-ins in progress; `new,old` rotates it | | `PORT` | Optional. Where the server listens; `8008` by default | Every required value is checked at startup, and a missing one stops the server with its name. Keep the client secret, the session secret and tokens on the server. `SESSION_SECRET` is required. It encrypts the sessions the app stores in its database and the browser cookie that holds a sign-in in progress, between **Sign in** and your tenant's redirect back. Generate one with `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`. It accepts a comma-separated list, and every entry needs at least 32 bytes. The first entry encrypts new sessions and the sign-in cookie; the entries after it only open sessions created before a rotation. To rotate without signing everyone out, set `SESSION_SECRET=,` and drop the old entry after 14 days, the session lifetime. A sign-in started before the rotation and finished after it fails, and the person signs in again. In your copy of the template, install, migrate the database and start it: ```bash pnpm install --frozen-lockfile cp .env.example .env # then fill it in pnpm db:migrate pnpm dev # http://localhost:8008 ``` 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. Tokens stay in the server-side session, stored encrypted in the app's database, and the browser receives only an HttpOnly 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 notes table with your own: a filtered list does not protect a direct request for somebody else's note ID. Creating, editing and deleting a note also require a verified email address. This is an account requirement separate from being authenticated. The server asks UserInfo on each write and treats a missing answer as unverified. Until the address is verified, **Settings → Profile** links to your tenant's hosted profile page, where the person can resend the verification email. **Checkpoint:** one person's note never appears in another person's list or direct lookup, and another person's note answers 404 exactly like one that does not exist. An unverified user cannot create a note. ## 3. Add a permission-gated 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. ## 4. Build the account area into your own screens The account endpoints on your tenant's host answer for whoever's access token is on the request. They take no subject, no user ID and no tenant, they need no scope and no dashboard seat, and a machine credential is refused. So your application renders the account area itself and forwards the person's token: - **Preferences.** `GET /api/account` and `PATCH /api/account` read and write the person's `userMetadata`. The patch merges — a key it does not name is left alone, and a key set to `null` is removed — so two screens editing different keys cannot overwrite each other. Administrator-owned metadata is a separate bucket this endpoint never returns. - **Signed-in devices.** `GET /api/account/sessions` lists the person's live logins with device details and no secrets. `current` marks the login the presented credential was issued from, so two of the person's own credentials list the same rows and disagree about which is current. Ending the current one is refused; that is sign-out, through the end-session endpoint. `DELETE /api/account/sessions/{sessionId}` ends one other device and `POST /api/account/sessions/revoke-others` ends the rest. Revoking a login stops the tokens it owns immediately. These mutations share one budget per person with the hosted security page. - **Linked accounts.** `GET /api/account/linked-accounts` lists the external accounts the person can sign in with, plus whether a password remains; `DELETE /api/account/linked-accounts/{identityId}` disconnects one. The tenant refuses to remove a person's last usable sign-in method, and a disabled provider does not count as another one — which is why the button needs both halves of that response. Neither `DELETE` needs a request body, and an empty one is accepted too. Treat a refusal the person caused — the session they are using, their last sign-in method, a spent attempt budget — as an answer to render with the reason the tenant gave. Everything else is a fault on your side of the call. **Checkpoint:** edit a preference and reload; sign in from a second browser, see two devices, and sign the second one out from the first. ## 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. Three account flows stay on your tenant's hosted pages, and the template links to them rather than rebuilding them: changing a password, enrolling or removing a second factor, and starting a link to an external account. Each one hands over a credential or ends at another identity provider, which is what hosted sign-in exists to keep off your origin. Two more stay hosted because no caller-relative endpoint answers them yet: revoking a third-party grant, and resending a verification email. Identity names and email addresses are still administrator-owned, changed from the dashboard. The starter's notification email is a separate application-owned setting, stored in the example's database; it never changes the sign-in or recovery address and sends no messages. For that sensitive setting, `GET` and `PUT /api/notification-email` require current-request introspection to report `acr=urn:udibo:acr:mfa` and an integer `auth_time` aged zero through 300 seconds. On refusal, **Confirm your identity** uses `/auth/reauthenticate`, whose server configuration pins `max_age=300` and that ACR preference. The ordinary BFF callback returns to the app. Declining optional MFA keeps the setting locked; completing hosted MFA permits the read and write. Every write checks the achieved evidence again, including after refresh or a delay while the form was open. Browser session projections are not proof. Replace the example's notes with your own tables, keep the owner's subject on every row and filter every query by it, 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-10-13._