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. For runnable public package examples, use 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 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://<tenant-id>.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=<new>,<old> 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:
pnpm install --frozen-lockfile
cp .env.example .env # then fill it in
pnpm db:migrate
pnpm dev # http://localhost:8008The 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/accountandPATCH /api/accountread and write the person'suserMetadata. The patch merges — a key it does not name is left alone, and a key set tonullis 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/sessionslists the person's live logins with device details and no secrets.currentmarks 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 andPOST /api/account/sessions/revoke-othersends 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-accountslists 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, passwordless options, and MFA policy. 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. For existing users, begin with migration planning.
Last verified 2026-10-13.

