Skip to content
Udibo
On this page
View Markdown

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:

VariableValue
UDIBO_ISSUERYour tenant's issuer origin, https://<tenant-id>.udibo.com
APP_ORIGINhttp://localhost:8008; the registered callback is {APP_ORIGIN}/auth/callback
UDIBO_CLIENT_IDThe registered application's client ID
UDIBO_CLIENT_SECRETA secret created for that application
DATABASE_URLThe Postgres database the notes, notification addresses and sessions live in
SESSION_SECRETAt least 32 bytes of entropy that encrypts sessions and sign-ins in progress; new,old rotates it
PORTOptional. 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:

Shell
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, 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.