Hosted Login Guidelines

How to use Hosted Login: the optional mode in which the auth platform (HAP) hosts the login experience at the auth domain, so your web app ships no login UI of its own — no sign-in, sign-up, OTP, or passkey screens. Your app redirects the browser to HAP, the user signs in on a HAP-hosted page, and the browser comes back authenticated.

Status: available now. Hosted Login shipped in specs/007-hosted-login and is wired into two relying-party apps in specs/008-connect-clients-hosted-login (the WBSP Website and the AI-Native CRM, both on tenant wbsp). Embedded login (your app renders the UI) and the SSO broker remain available and unaffected. The client code is the same across modes — adopting one is a config change, not a rewrite (see "Three modes, one client contract" below). A concrete, end-to-end worked example is at the end of this guide.


The three login modes (and why your client doesn't care which)

ModeWho renders the login UIClient points its authorization_endpoint at
Embedded (today)your appn/a — the app calls the auth API directly for login, then /authorize
SSO broker (today)a sibling "broker" appthe broker's handoff endpoint
Hosted Login (target)the platformthe tenant's hosted authorize endpoint

Hosted Login and the SSO broker present the identical client-facing contract — standard OpenID Connect Authorization-Code + PKCE. The only difference is which URL the client treats as its authorization endpoint. So a standard OIDC client switches between them by configuration, not code (ideally by reading the authorization endpoint from discovery). Embedded differs only in that the app also drives the login API itself; the /authorize/token half is the same.

Recommendation: build your client as a plain, standards-compliant OIDC client (use an off-the-shelf library). Then embedded/broker/hosted are deployment choices, not code branches. See the Client Cookbook for ready-to-adapt code.


How a client uses Hosted Login

It is ordinary OIDC Authorization-Code + PKCE; only the source of the login page is HAP.

1. (server) Make a PKCE pair (verifier + S256 challenge) and a random `state`; keep them in a
   pending-login server-side session. KEEP THE VERIFIER SERVER-SIDE.

2. Redirect the browser to the tenant's authorization endpoint (from discovery):
     {HAP_BASE}/t/{tenant}/oauth2/authorize
       ?response_type=code&client_id=<your client_id>
       &redirect_uri=<your callback, exact match>
       &scope=openid%20profile%20email
       &code_challenge=<challenge>&code_challenge_method=S256
       &state=<state>&nonce=<nonce>

3. The platform shows the HOSTED login page, authenticates the user (any method the tenant enables),
   then redirects the browser back to:
     <your callback>?code=<CODE>&state=<state>

4. (callback, server) Verify `state`, then exchange the code at {HAP_BASE}/t/{tenant}/oauth2/token
   with code_verifier + client_id + client_secret.

5. Verify the id_token against {HAP_BASE}/t/{tenant}/oauth2/jwks (iss = the public issuer,
   aud = your client_id, nonce). Key your user on `sub`; mint your own session.

That's it — no login screens in your app.

Cross-app SSO comes for free

After a hosted login, the platform holds a browser session at the auth domain. A second app in the same tenant that requests prompt=none gets a code without the user re-authenticating — seamless SSO. With no session, prompt=none redirects back with error=login_required; show your "sign in" entry point (which is just a redirect to the authorize endpoint without prompt=none). This auth-domain session exists only with Hosted Login; the SSO Broker and SSO Client guidelines cover the cookie-less direct-integration model, where HAP holds no session — which is why prompt=none returns login_required there.


Discovery: make the mode a config value

Read the per-tenant discovery document and use the endpoints it gives you, rather than hard-coding:

GET {HAP_BASE}/t/{tenant}/.well-known/openid-configuration
→ { "issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint",
    "jwks_uri", "end_session_endpoint", ... }

To switch a client between Hosted Login and the SSO broker, change only the configured authorization_endpoint (hosted endpoint vs broker handoff URL). Everything else — client_id, callback, token exchange, token verification — is unchanged.


Rules & expectations

  • Opt-in: Hosted Login is enabled per tenant; embedded clients are unaffected and remain the default. Both can coexist in one tenant on the same identities.
  • Same tenant for SSO: silent cross-app SSO only spans applications registered in the same tenant.
  • Standards: OIDC Core authorization-code + PKCE; exact redirect_uri match; verify state, iss, aud, nonce, exp. Use a maintained OIDC library rather than rolling your own.
  • client_secret (confidential web apps): inject from your platform secret store; never commit.
  • Key on sub (stable identity id), not email.
  • Lifecycle: a suspended/deleted/revoked identity stops yielding silent sign-ins; re-validate via refresh (refresh failure ⇒ sign out) and/or subscribe to identity-lifecycle webhooks.
  • In-cluster: if your app runs in the same cluster as HAP, use split-horizon — back-channel calls (token, JWKS, discovery) over the in-cluster Service, but iss/aud and the browser redirect use the public issuer/URL (see API Reference §1).
  • Branding: the hosted pages can carry your tenant's name/logo so the experience still feels like your product.
  • Somewhere to land: a person who reaches the sign-in page with no application behind them (a bookmark, the signed-out page) lands on the application selector at /t/{slug} — previously this request was refused. Register a home_url on every application so the selector can forward to it, and consider linking to that address from inside your app as "choose another application". /t/{slug}/menu and /menu/{slug} are shorter aliases that redirect to it, for a person to read out or type — application code should link to /t/{slug} itself. (The older /t/{slug}/hosted/apps still serves the same page but is being retired.)

Worked example: two apps on tenant wbsp (feature 008)

A concrete, end-to-end setup connecting two relying parties to Hosted Login on one tenant, so a single sign-in serves both (cross-app SSO). This is the reference local-dev configuration.

Endpoints (tenant wbsp, local base http://localhost:8000)

PurposeURL
Issuer (iss/aud base)http://localhost:8000/t/wbsp
Discoveryhttp://localhost:8000/t/wbsp/.well-known/openid-configuration
Authorize (cookie-aware)http://localhost:8000/t/wbsp/oauth2/authorize
Tokenhttp://localhost:8000/t/wbsp/oauth2/token
JWKShttp://localhost:8000/t/wbsp/oauth2/jwks
End-session (logout)http://localhost:8000/t/wbsp/hosted/logout

The client redirects the browser to authorize; with no hap_session cookie it renders the Hosted Login OTP page, then bounces back and issues the code to your redirect_uri.

Per-app configuration

WBSP Website (:3000)AI-Native CRM (:30041)
client typeconfidentialconfidential
redirect_urihttp://localhost:3000/api/auth/callbackhttp://localhost:30041/api/auth/callback/hap
post_logout_redirect_urihttp://localhost:3000/http://localhost:30041/login
scopesopenid profile emailopenid profile email offline_access

Enabling it (platform side)

  1. On the tenant, set hosted_login_enabled = true and passwordless_jit_provisioning = true (the latter lets a first-time user be provisioned at the OTP step).
  2. Register each app with its exact redirect_uris + post_logout_redirect_uris. For local dev there is a one-shot seed: target/scripts/seed_hosted_login.py.

Three gotchas worth knowing

  • You no longer need to send consent=granted. The Hosted Login surface now renders a consent screen: a person who has not consented is asked once, and continues. If you already send consent=granted, keep sending it — nothing changes for you, and no screen is shown. Both paths are supported.

    This used to be the biggest footgun on this surface. Until 2026-08, omitting consent=granted produced an infinite login loop with no error: authorize raised consent_required, the browser was bounced to the sign-in page, which cannot record consent, so the next authorize raised it again. Three teams hit it. If you are reading older notes or code comments that say consent is mandatory, they predate the fix.

    Two triggers used to cause it, and both are now handled: a first sign-in to an application, and a scope increase — asking for more than the person previously agreed to, which looped users who had been working fine.

  • Step-up (acr_values) is still not offered here. If a client asks for a higher assurance level than the session holds, the hosted surface has no way to raise it. That used to loop as well; it now ends on a page explaining the application needs stronger authentication than the sign-in provides. Do not request acr_values above aal1 on the hosted surface expecting the user to be prompted.

  • Cookie Secure must match the transport. The hap_session cookie is Secure; browsers (notably Safari) drop a Secure cookie over http://localhost, so the session is never stored and authorize loops. The platform now sets Secure only when the issuer is https — keep this in mind if you front HAP with plain http anywhere.

  • Exact redirect_uri + correct port. Run each app on the port its redirect_uri was registered with (the CRM on :30041, not :3000).

Single logout (FR-015)

Sign-out should clear the local app session and redirect the browser to the end-session endpoint (/t/wbsp/hosted/logout?client_id=…&post_logout_redirect_uri=…) to revoke the shared hap_session. Otherwise the next sign-in silently re-authenticates from the surviving platform session. The post_logout_redirect_uri must be registered on the app.

When the platform renders its own signed-out page instead of redirecting (no post_logout_redirect_uri supplied, or the supplied one is not registered), the page is not a dead end: with a known client_id it links back to the app's first registered post-logout URL ("Return to app"); with no usable app context it offers Sign in again through Hosted Login. Prefer passing both parameters so your users skip that page entirely — but registering post_logout_redirect_uris still pays off even if you only pass client_id, because it is what gives the signed-out page a way back to your app.

Revocation (US4)

When an identity is suspended/deleted at the platform, propagate it two ways:

  • Pull (backstop): re-validate periodically with the refresh token — a refresh failure (invalid_grant, "identity unavailable") means suspended/deleted/revoked → sign the user out. (The CRM does this hourly via Auth.js.)
  • Push (prompt): subscribe to identity-lifecycle webhooks (identity.suspended/deleted/reinstated/email_changed), verify the X-HAP-Signature: t=<unix>,v1=<hmac-sha256(secret,"<t>.<body>")> header, and update your local user mirror so the existing session is cut off on the next request. (The website does this.)