---
hip: "0111"
title: Hanzo IAM Authentication Standard
author: Hanzo AI Team
type: Standards Track
category: Infrastructure
status: Final
implementation-rust: partial
created: 2026-06-16
requires: HIP-0026, HIP-0068
---



# HIP-0111: Hanzo IAM Authentication Standard

## Abstract

This is the one and only way an application authenticates a user, provisions an identity, or validates a token against Hanzo IAM. It defines the canonical IETF/RFC endpoint surface, the single approved client library (`@hanzo/iam`), the integration pattern for every supported framework, the application-registration rules, and the anti-patterns that are forbidden.

**RFC-standard only — no vendor compat.** Every wire contract on this surface is an IETF RFC or OpenID Connect standard. There are NO verb aliases (`get-users`, `add-user`, `get-account`, `issue-user-token`, …), no bespoke "verb" REST, and no backward-compat shims, on iam or on any client. Where a capability has a standard, the standard IS the surface: identity provisioning is **SCIM 2.0** (RFC 7644/7643), delegated/on-behalf-of tokens are **OAuth 2.0 Token Exchange** (RFC 8693), account claims are **OIDC UserInfo**, token validation is **Introspection** (RFC 7662) + JWKS (RFC 7517). A client that needs a capability uses its RFC; if no RFC covers it, it is the authorization server's internal concern (§6), never a new public "verb".

HIP-0026 specifies the IAM **server** — the provider itself. This HIP specifies the **wire contract** — how everything talks to it. Where the two touch (endpoint paths, discovery, the token exchange), this HIP is authoritative and HIP-0026 follows it.

Hanzo IAM (`hanzoai/iam`) is a clean-room, standards-based OAuth 2.0 + OpenID Connect + SCIM 2.0 provider — original expression, no upstream fork — deployed once per brand. The Beego/xorm fork it replaced is retired to `hanzoai/iam-v1` and is out of every graph; a HIP describing Beego, xorm or a Postgres schema is describing that repository, not this one. IAM is deployed once per brand:

| Brand | IAM origin (`serverUrl`) | Login UI |
|-------|--------------------------|----------|
| Hanzo | `https://hanzo.ai` | `hanzo.ai` |
| Lux | `https://lux.id` | `lux.id` |
| Zoo | `https://zoolabs.id` | `zoolabs.id` |
| Pars | `https://pars.id` | `pars.id` |
| Osage | `https://osage.id` | `osage.id` |
| Adnexus | `https://id.adnexus.dev` | `id.adnexus.dev` |
| Bootnode | `https://id.bootno.de` | `id.bootno.de` |

The two columns hold the same host on every row, and that is the rule, not a
coincidence: the authorization server answers on the origin it names as `issuer`,
so `serverUrl` is always the brand's identity host. Hanzo's is `https://hanzo.ai`
from the cutover in §10; until it runs, the deployment answers as
`https://hanzo.id`, and after it `hanzo.id` is a redirect to `hanzo.ai`.
`iam.hanzo.ai` is not an identity host — it serves the `200 text/html` SPA on
every path, `/v1/iam/*` included, so a client pointed there resolves the
catch-all described below and fails on content type. Discovery is the check: the
identity host's `/.well-known/openid-configuration` returns `application/json`
whose `issuer` is that host, and so does the copy served from `api.hanzo.ai`.

The library is brand-agnostic. You select the brand by setting `serverUrl`; nothing else changes.

**SDK**: [`@hanzo/iam`](https://github.com/hanzo-js/iam) (npm, v0.11.0+)
**Source of truth for paths**: `@hanzo/iam` → `src/paths.ts` → `OIDC_PATHS`

## Motivation

Every authentication regression in the estate has had one of three root causes:

1. **Path drift** — a client invented its own OIDC path (`/oauth/authorize`, `/api/login/oauth/access_token`, `/api/...`). IAM serves a **200 `text/html` SPA catch-all for any unregistered path**, so a wrong path returns an HTML body with a `200`, not a `404`. The OAuth library then dies on `content-type must be application/json` and the failure looks like a server bug. It is not; it is a client hitting the wrong URL.

2. **Discovery drift** — a client used raw `better-auth` `genericOAuth({ discoveryUrl })`. Discovery resolution landed on the SPA catch-all HTML and the client wired itself to garbage endpoints.

3. **Hand-rolled OAuth** — a team reimplemented PKCE, token exchange, or JWKS validation and got the audience check, the `S256` challenge, or the refresh rotation subtly wrong.

All three vanish if there is exactly one library that owns exactly one set of paths, and every application uses it. That is this standard.

## Specification

### 1. The canonical OIDC endpoints

These are the **only** paths. There is no `/oauth/*`, no `/api/login/*`, no `/api/` prefix. They are relative to the brand `serverUrl`.

| Purpose | Path | RFC / spec |
|---------|------|------------|
| OIDC discovery | `/.well-known/openid-configuration` | OIDC Discovery 1.0 |
| AS metadata | `/.well-known/oauth-authorization-server` | RFC 8414 |
| Authorize | `/v1/iam/oauth/authorize` | RFC 6749 §3.1 |
| Token | `/v1/iam/oauth/token` | RFC 6749 §3.2 |
| UserInfo | `/v1/iam/oauth/userinfo` | OIDC Core §5.3 |
| Introspection | `/v1/iam/oauth/introspect` | RFC 7662 |
| Revocation | `/v1/iam/oauth/revoke` | RFC 7009 |
| JWKS | `/v1/iam/.well-known/jwks` | RFC 7517 |
| Logout | `/v1/iam/oauth/logout` | OIDC RP-Initiated Logout |
| Provisioning (SCIM) | `/v1/iam/scim/v2/{Users,Groups,…}` | RFC 7644/7643 (§8) |

The **token endpoint** (`/v1/iam/oauth/token`) dispatches ONLY standard `grant_type`s — `authorization_code` (RFC 6749 §4.1, always PKCE-bound), `refresh_token` (§6, rotating), `client_credentials` (§4.4), `password` (§4.3, confidential first-party only), and `urn:ietf:params:oauth:grant-type:token-exchange` (RFC 8693, §7 — delegation / on-behalf-of). There is exactly one token endpoint and one spelling of it; the legacy `access_token` alias is **gone** (a client posts to `token`, never `access_token`).

Mandatory parameters, everywhere:

- **PKCE `S256`** on every authorization request. `plain` is not permitted. Public clients (SPAs, native) require it; confidential clients use it too.
- **`client_secret_basic`** for confidential clients. HTTP Basic, not body params.
- **Scopes** `openid profile email` (+ `offline_access` for a refresh token).
- **`resource` / `audience`** (RFC 8707) name the resource server a token is minted for; the AS stamps `aud` accordingly and validators fail closed on a mismatch.
- **`iss`** is pinned per deployment (`IAM_ISSUER`, e.g. `https://hanzo.ai`) so every token and the discovery document advertise ONE stable issuer regardless of request host — never steerable by `X-Forwarded-Host`.

The discovery document MUST be self-consistent: `issuer`, `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, and `jwks_uri` all share one origin (host-relative to the brand). The IAM knob that controls this is `originFrontend` in `app.prod.conf` — it MUST be empty so discovery is host-relative. A split-origin discovery document breaks strict OIDC clients (`openid-client`, NextAuth) that pin the issuer.

### 2. The only integration: `@hanzo/iam`

JavaScript and TypeScript applications integrate **only** through `@hanzo/iam`. No application writes an OIDC path string. No application calls these endpoints by hand. The SDK holds the paths in one place (`OIDC_PATHS`) and every entry point reads from it; a failed discovery round-trip degrades to these same hard-coded values, so a client can never resolve to the SPA catch-all.

The SDK is split into per-environment entry points. Import the one that matches your runtime:

| Subpath | Surface | Use |
|---------|---------|-----|
| `@hanzo/iam` | `IamClient`, types | conditional Node/browser entry |
| `@hanzo/iam/server` | `validateToken`, `getServerSession` | server-side JWT validation + session |
| `@hanzo/iam/betterauth` | `iamProvider` | better-auth apps |
| `@hanzo/iam/nextauth` | `IamProvider` | NextAuth / Auth.js apps |
| `@hanzo/iam/react` | hooks, `OrgProjectSwitcher` | React SPAs |
| `@hanzo/iam/browser` | `IAM` (PKCE client) | browser PKCE login |
| `@hanzo/iam/passport` | `createIamPassportStrategy` | Node/Express + Passport |

#### Server-side token validation (any backend)

```ts
import { validateToken } from "@hanzo/iam/server";

const result = await validateToken(accessToken, {
  serverUrl: process.env.IAM_ENDPOINT!, // e.g. https://hanzo.ai
  clientId: process.env.IAM_CLIENT_ID!,
});

if (!result.ok) return unauthorized(result.reason);
const { userId, email, owner } = result; // owner = org slug → scope every query to it
```

`validateToken` discovers JWKS from `/.well-known/openid-configuration`, caches the key set per issuer, and verifies signature, `iss`, `aud`, and `exp`. Scope all multi-tenant data access to `owner`.

#### Server session (App Router / RSC)

```ts
import { getServerSession } from "@hanzo/iam/server";

const session = await getServerSession({ serverUrl: process.env.IAM_ENDPOINT! });
if (!session) redirect("/login");
```

#### better-auth

```ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { iamProvider } from "@hanzo/iam/betterauth";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        iamProvider({
          serverUrl: process.env.IAM_ENDPOINT!,
          clientId: process.env.IAM_CLIENT_ID!,
          clientSecret: process.env.IAM_CLIENT_SECRET!,
        }),
      ],
    }),
  ],
});
```

`iamProvider()` returns a config with **explicit** `authorization`, `token`, and `userinfo` endpoints (the canonical `/v1/iam/oauth/*` paths) — it never relies on discovery resolution. The registered redirect URI for this provider is `https://<app-host>/api/auth/oauth2/callback/hanzo`.

#### NextAuth / Auth.js

```ts
import { IamProvider } from "@hanzo/iam/nextauth";

export default NextAuth({
  providers: [
    IamProvider({
      serverUrl: process.env.IAM_ENDPOINT!,
      clientId: process.env.IAM_CLIENT_ID!,
      clientSecret: process.env.IAM_CLIENT_SECRET!,
      checks: ["state", "pkce"],
    }),
  ],
});
```

#### React SPA (PKCE)

```ts
import { IAM } from "@hanzo/iam/browser";

const iam = new IAM({
  serverUrl: "https://hanzo.ai",
  clientId: "hanzo-myspa",
  redirectUri: `${location.origin}/auth/callback`,
});

await iam.signinRedirect();              // start
const token = await iam.handleCallback(); // on /auth/callback
const access = await iam.getValidAccessToken(); // auto-refresh
```

```tsx
import { IamProvider, useIam } from "@hanzo/iam/react";

<IamProvider serverUrl="https://hanzo.ai" clientId="hanzo-myspa">
  <App />
</IamProvider>;
```

The browser client uses PKCE `S256`, holds tokens in memory, and refreshes silently. Never persist access tokens in `localStorage`.

#### Node / Express + Passport

```ts
import passport from "passport";
import { createIamPassportStrategy } from "@hanzo/iam/passport";

passport.use("iam", createIamPassportStrategy({
  serverUrl: "https://hanzo.ai",
  clientId: "hanzo-myservice",
  clientSecret: process.env.IAM_CLIENT_SECRET!,
  callbackUrl: "https://myservice.hanzo.ai/v1/sso/oidc/callback",
}));
```

### 3. Application registration

Every application is registered once per brand in IAM before it can authenticate.

- **`client_id` naming**: `<org>-<app>` (e.g. `hanzo-console`, `lux-wallet`, `zoo-research`). One ID per app per brand.
- **`redirectUris`**: MUST contain the **exact** callback the SDK/framework uses. There is no wildcard. Per framework:

| Framework | Registered redirect URI |
|-----------|-------------------------|
| better-auth (`genericOAuth` + `iamProvider`) | `https://<app-host>/api/auth/oauth2/callback/hanzo` |
| NextAuth / Auth.js | `https://<app-host>/api/auth/callback/iam` |
| React SPA (`@hanzo/iam/browser`) | `https://<app-host>/auth/callback` |
| Passport | `https://<app-host>/v1/sso/oidc/callback` |

The first two carry an `/api/` segment because better-auth and NextAuth mount
their own handler there and the path is not ours to choose — it is a route on the
*app's* host, in a third-party framework's namespace. The no-`/api/` rule (§4.4,
§4.7) is about surfaces **we** serve; it is not weakened by a callback we merely
register. A redirect URI that does not match the framework's actual mount point
byte for byte fails the flow, so this table records what the framework does, not
what we would prefer.

- **Grant**: Authorization Code + PKCE. Implicit grant is not used, and `implicit` is not a registered grant type on any application.
- **Client secret**: KMS-managed (HIP-0027, HIP-0136). Never in Git, init data, env files, or images.
- **Superuser convention**: no built-in admin — the seeded superuser `z@<domain>` is the only privileged account, and it is a member of the reserved `admin` org (HIP-0118). The password is the org's, not the app's.
- **Machine identity**: a service authenticates as itself with `client_credentials` and RFC 8707 `resource` naming the resource server it is calling — for example `resource=hanzo-egress` (HIP-0143). There is no service token, no shared secret and no per-app auth; a bearer that does not come from this endpoint is not an identity.

### 4. Forbidden anti-patterns

These break in production and are not permitted under any circumstance:

1. **Raw `better-auth` `genericOAuth({ discoveryUrl })`** — discovery resolves to the SPA catch-all HTML and the client dies with `content-type must be application/json`. Use `iamProvider()`, which pins explicit endpoints.
2. **Hand-rolled OAuth / PKCE / JWKS** — use the SDK. Reimplementation gets `aud`, `S256`, or refresh rotation wrong.
3. **Any per-app OIDC path string** — no application writes `/v1/iam/oauth/...` (or, worse, `/oauth/...`) itself. The path lives in `OIDC_PATHS` inside the SDK; applications pass only `serverUrl`.
4. **Legacy paths** — `/oauth/*`, `/api/login/oauth/*`, anything `/api/`-prefixed. Gone. No backward compatibility.
5. **Non-empty `originFrontend`** in production — produces a split-origin discovery document that breaks strict clients.
6. **Per-app social OAuth clients** — an app registering its own Google/GitHub (or Web3) OAuth client. Social providers are configured ONCE per network, org-level, and shared (§7). A per-app client re-creates the shared one N times and drifts.
7. **`/api/` on the login entry point too** — the IAM's own login UI / portal Worker uses the native login API under `/v1/iam/*` (§6), never `/api/login`, `/api/get-app-login`, `/api/signup`. The "no `/api/`" rule is absolute, including the login entry point.
8. **"Verb" aliases / bespoke REST for a standardized capability** — `get-users`, `get-user?id=`, `add-user`, `update-user`, `delete-user`, `get-organizations`, `get-records`, `issue-user-token`, `get-account`, `mint-user-keys`, and every other non-standard verb are **gone**, on iam and on every client. Each has an RFC that IS the surface: identity provisioning → **SCIM 2.0** (§8), delegated/on-behalf-of tokens → **Token Exchange** (§7), account claims → **UserInfo** (§1). A client that reaches for a verb is reaching for the wrong contract; there is no compat layer that will answer it.
9. **A duplicate spelling of a standard endpoint** — one `token` endpoint, not `token` + `access_token`; one `userinfo`, not `userinfo` + `get-account`. An alias is two ways to do one thing; the standard path is the only one served.

### 5. Gotchas (call out explicitly)

- **SPA catch-all** — IAM returns a `200 text/html` page for ANY unregistered path. A wrong path is not a `404`; it is silent breakage. Clients MUST hit the exact `/v1/iam/*` paths. This is why the SDK centralizes paths and degrades discovery to hard-coded canonical values.
- **Discovery self-consistency** — issuer/authorize/token/userinfo/jwks share one origin (host-relative). Keep `originFrontend` empty in `app.prod.conf`.
- **`owner` is the tenant** — the org slug. IAM emits **`owner`** (and the standard-name alias **`organization`**) in BOTH the OIDC userinfo response AND the JWT, in every token format, scope-independent — so a consumer reading either claim off either surface gets the tenant. Scope every data query to it. The gateway (HIP-0519) propagates it as `X-Org-Id`; backends behind the gateway trust that header and do not re-parse the JWT. A consumer that reads org from a non-standard field (e.g. a legacy `groups` claim) and finds nothing MUST fail closed, never silently fall back to a `"default"`/`"personal"` org — that is a tenant-isolation defect.

### 6. The login entry point — the AS's own concern, not a client surface

OAuth 2.0 / OIDC deliberately do **not** specify how the authorization server authenticates the end user (the credential-entry step). That is the AS's internal concern. So the hosted login page (the per-brand ID portal at `hanzo.ai`/`lux.id`/… and its Worker) has a small first-party API it — and ONLY it — calls, under the canonical `/v1/iam/*` prefix:

| Purpose | Path |
|---------|------|
| App/org resolution before login | `/v1/iam/get-app-login` |
| Password login (mints the code) | `/v1/iam/login` |
| Signup | `/v1/iam/signup` |
| Verification code | `/v1/iam/send-verification-code` |
| Logout | `/v1/iam/oauth/logout` (§1) |

This is NOT a client integration surface and NOT a set of "verbs" a client may call — it is the AS's own login UI talking to the AS. **Account claims are NOT here**: there is no `get-account` and no second `userinfo` — every consumer (including the gateway admin-guard, HIP-0118) reads the standard **OIDC UserInfo** (`/v1/iam/oauth/userinfo`, §1), which carries `sub`, `owner`/`organization`, `email`, `email_verified`, and the `isAdmin` claim the SuperAdmin predicate derives from. One account contract, and it is the RFC one.

Same rule as §1: `/v1/iam/*` only — no `/api/`, anywhere, including the login Worker. **Client apps use only the standard surface (§1) through the SDK**; the login API is internal to the AS.

### 7. Delegation / on-behalf-of — OAuth 2.0 Token Exchange (RFC 8693)

A trusted first-party backend that must call a downstream API **as** an end user (the console BFF forwarding a request on the signed-in user's behalf, the keyless AI proxy) obtains that token through **RFC 8693 Token Exchange** on the token endpoint — never a bespoke `issue-user-token` verb.

- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, `client_secret_basic` (confidential clients only), `subject_token` naming the target user (or a `requested_subject`), `requested_token_type=urn:ietf:params:oauth:token-type:access_token`, and `resource`/`audience` (RFC 8707) pinning the downstream resource server.
- The issued token carries the **target user's** subject + `owner` (so a resource server that scopes on the validated `owner` claim scopes to the user's tenant), an `act` claim recording the acting client, and the requested `aud`. It is signed by the same trusted key the JWKS publishes — indistinguishable from a token the user obtained directly, which is the point.
- **Two conditions gate it, both required** (`internal/oidc/issuetoken.go`, `mintAllowed`): the acting application's **owning org** must be a reserved platform signing owner (`admin`/`built-in`), AND its `client_id` must appear on `IAM_TOKEN_EXCHANGE_APPS`. The owner-pin is the decisive one. `client_id` and secret are body-supplied at registration, so a tenant could register an app whose `client_id` collides with a listed one and, on a backend whose duplicate-row order is unspecified, have its row resolve and its known secret authenticate — but its owner is its own tenant, never a signing owner, so it acts on nobody. An empty or unset list allows nothing.
- Acting on behalf of a **reserved-org (`admin`/`built-in`)** subject requires the strictly narrower, separately granted `IAM_ADMIN_TOKEN_EXCHANGE_APPS`, under the same owner-pin (`adminMintAllowed`). A leaked general-exchange credential can therefore never reach a SuperAdmin identity (HIP-0118). Every exchange is audit-logged.
- **`IAM_KEY_MINT_ALLOWED_APPS` is a different authority and MUST NOT be conflated with these.** It gates credential administration (`authz.CapKeyMint`) and is keyed on the application **name**, not the `client_id`, so an app permitted to exchange tokens is not thereby granted the credential-administration capability nor its reach across the entity registry. Two capabilities, two lists, two keys.

### 8. Identity provisioning — SCIM 2.0 (RFC 7644 / RFC 7643)

Creating, reading, updating, and deleting identities is **SCIM 2.0** — the IETF standard for cross-domain identity management — under `/v1/iam/scim/v2/`. There are NO `get-users`/`add-user`/`get-organizations` verbs.

| Resource | Path | Maps to |
|----------|------|---------|
| Service provider config | `/v1/iam/scim/v2/ServiceProviderConfig` | supported features |
| Schemas / resource types | `/v1/iam/scim/v2/{Schemas,ResourceTypes}` | discovery |
| Users | `/v1/iam/scim/v2/Users` (+ `/{id}`) | the user entity |
| Groups | `/v1/iam/scim/v2/Groups` (+ `/{id}`) | organizations, roles |

- Standard verbs are HTTP: `GET` (list with `filter`/`startIndex`/`count`, or by id), `POST` (create), `PUT`/`PATCH` (RFC 7644 §3.5.2 patch ops), `DELETE`. Lists return the SCIM `ListResponse` envelope (`totalResults`/`Resources`), not a `{status,data,data2}` one.
- A User is the SCIM core schema (`urn:ietf:params:scim:schemas:core:2.0:User`) plus a Hanzo enterprise extension for `owner`/`isAdmin`/credential metadata. Passwords are write-only (`password` attribute in), never returned. Secrets never cross a SCIM response (the AS masks on read).
- Tenant scope: a non-super caller's SCIM view is pinned to its own `owner`; a SuperAdmin may `filter` across tenants. Same authorization model as every other surface — bearer-authenticated, owner-scoped, fail-closed.
- Clients provision through the SDK's SCIM client (or any conformant SCIM library); no client writes SCIM URLs by hand, same as §2/§3.

### 9. Social & Web3 — one shared provider, never per-app

Google, GitHub, and Web3 are configured **once per network** as org-level providers in IAM (`admin/provider-google`, `admin/provider-github`, …). Every app reuses them via a per-app `canSignIn` toggle — an application **never** registers its own social OAuth client (§4.6).

- The shared social OAuth client's redirect URI is **IAM's own callback** on the brand's sign-in origin (`https://<sign-in origin>/v1/iam/oauth/callback`; Hanzo's is `https://hanzo.ai/v1/iam/oauth/callback`); the provider hop happens inside IAM, not in the app. The app only ever sets *its own* `redirect_uri` (its `/auth/callback`).
- An app selects a method with one knob: `startLogin({ provider })` adds `&provider=<name>` to `/v1/iam/oauth/authorize`. Omit `provider` for the IAM login page (password + whatever it offers). **One flow; the provider is a parameter, not a separate code path** — adding a provider is a config entry plus a button, and every app inherits it.
- Login buttons are presentation, wired through `@hanzo/ui` `<SignIn providers={…}>` to the SDK. A surface that lacks a working button has it *disabled in config* — it is never *deleted from code*, because the shared provider is always available.

### 10. Hanzo's identity host is `hanzo.ai`

Hanzo's issuer is `https://hanzo.ai`. Its sign-in pages are the ID portal (§6, HIP-0449) on `hanzo.ai`, and login and the core apps — chat and dev — are served on `hanzo.ai` as well. The cutover that makes it so has not run: production pins `IAM_ISSUER=https://hanzo.id` (`hanzoai/universe`, `charts/app/values/hanzo/cloud.yaml`), and every Hanzo token and discovery document says `iss: https://hanzo.id` until it does. The cutover changes these things and nothing else:

- **Issuer.** `IAM_ISSUER` and every Hanzo host in `IAM_ISSUER_MAP` name `https://hanzo.ai`, and discovery is served at `https://hanzo.ai/.well-known/openid-configuration` with `issuer: https://hanzo.ai`. Sign-in origin and issuer are one origin, so Hanzo needs no `IAM_FEDERATION_ORIGIN_MAP` entry: a host the map does not name signs in at its issuer.
- **`hanzo.id` is a redirect.** After cutover it serves no identity surface; every request is redirected to the same path on `https://hanzo.ai`. It is not an issuer, and a token whose `iss` is `https://hanzo.id` fails validation (§1). Hanzo has one issuer, never two accepted at once.
- **Brand issuers are unchanged.** `lux.id`, `pars.id`, `zoolabs.id`, `osage.id`, `id.adnexus.dev` and `id.bootno.de` keep their issuer, sign-in pages, sessions, passkeys and tokens. Only Hanzo's rows change.
- **Refresh tokens are revoked at cutover.** A refresh token is a grant from the issuer that minted it, and `https://hanzo.ai` honors none of `https://hanzo.id`'s. The session cookie is host-only (`__Host-hanzo_session`, `internal/sessions/cookie.go`), so a `hanzo.id` session is never presented to `hanzo.ai` either. Every Hanzo account signs in once on `hanzo.ai`.
- **Passkeys are re-enrolled.** The WebAuthn relying-party id is the issuer's host (`relyingParty`, `internal/oidc/webauthn.go`), and a passkey is bound to one relying-party id for life. The cutover moves Hanzo's from `hanzo.id` to `hanzo.ai`, so a `hanzo.id` passkey no longer signs; each is enrolled again on `hanzo.ai`.
- **Clients set `serverUrl: https://hanzo.ai`.** The paths (§1), the redirect URIs on the app's own host (§3) and the SDK are unchanged.

**Same site.** With login, chat and dev on `hanzo.ai`, every `hanzo.ai` and `*.hanzo.ai` page is same-site with the identity host. `SameSite=Lax` keeps the session cookie off requests from other sites, not from these pages. Three rules hold the boundary, and each is required:

1. **A strict CSP on `hanzo.ai`.** Login, chat and dev share one origin, so a script that runs on any of them runs as the identity host: `HttpOnly` stops it reading the session cookie, not sending same-origin requests that carry it. Every `hanzo.ai` response carries a strict `Content-Security-Policy` — script only by nonce or hash, no `'unsafe-inline'`, no `'unsafe-eval'`, `object-src 'none'`, `base-uri 'none'`.
2. **Rendered content runs on a separate origin.** HTML, SVG, markdown, artifacts, previews and uploads that a user or a model authored are served from a separate origin and sandboxed there. They never execute on `hanzo.ai`.
3. **Cookie-authenticated identity writes are same-origin JSON.** A write the session cookie authorizes is accepted only when `Sec-Fetch-Site` is `same-origin`, or `none` for an address the person typed, and a body, if the write has one, is `application/json` (`sessions.Forgeable`, `internal/sessions/resolve.go`). `same-site` is refused, an HTML form cannot send JSON, and a cross-origin JSON `fetch` must pass a CORS preflight. The rule covers login (SSO, device approval, code minting), the account (profile, password, terms, consent, preferences, onboarding, link, unlink, passkey enrollment) and invitation accept; a refused write is `403` before the session is read. Sign-out asks the same rule, except for a top-level navigation, which is how a relying party signs a person out. A bearer from a client that is not a browser needs no fetch metadata.

## Conformance status

The prohibitions in §Anti-patterns are **met by the deployment.** Measured against
`https://hanzo.id` on 2026-09-09, before the issuer move (§10); unauthenticated
probes, so a `401` would mean the route still exists and demands auth, and
`410 Gone` means it was removed on purpose
and the server says what replaced it:

| Surface | This HIP says | Production returns |
|---|---|---|
| `/v1/iam/get-users` | gone | `410` → `{"successor":["/v1/iam/users"]}` |
| `/v1/iam/get-user` | gone | `410` → `{"successor":["/v1/iam/users","/v1/iam/keys/principal"]}` |
| `/v1/iam/add-user` | gone | `410` → `{"successor":["/v1/iam/users"]}` |
| `/v1/iam/get-organizations` | gone | `410` → `{"successor":["/v1/iam/organizations"]}` |
| `/v1/iam/get-application` | gone | `410` → `{"successor":["/v1/iam/applications"]}` |
| `/v1/iam/issue-user-token` | gone | `410` → `{"successor":["/v1/iam/tokens/issue"]}` |
| `/v1/iam/get-records` | gone | `410` → `{"successor":["/v1/iam/audit-logs"]}` |
| `/v1/iam/get-account` | gone | `410` → `{"successor":["/v1/iam/account"]}` |

The two further deviations are also closed. `/v1/iam/oauth/access_token` returns
`404`; the standard `/v1/iam/oauth/token` is the only spelling, and it answers a
`POST` with `400` on an empty body rather than a `200` carrying an error envelope.
The `get-account` shape that returned `200` with `{"status":"error"}` is gone with
the rest.

`410` rather than `404` is the stronger result, and the retirement order in this
section is why it was reachable. A `404` says only that nothing is there; `410`
with a `successor` says the route was removed deliberately and names the RFC route
that replaced it, so a client still on an alias gets told where to go instead of
guessing. The order held: callers moved to the native routes first, the aliases
went second, and no deletion was an outage.

The standard surfaces this HIP mandates remain present and correct
(`/v1/iam/scim/v2/Users` and `/v1/iam/scim/v2/ServiceProviderConfig` answer `401`
as `application/json`; `/v1/iam/.well-known/jwks` serves an RS256 key set;
`/v1/iam/oauth/{authorize,token,introspect}` are live routes, not the SPA;
discovery is single-origin on `https://hanzo.id` and advertises RFC 8693
token-exchange and device_code). One surface, one spelling — which is what this
HIP asked for.

The catch-all still discriminates, and that is how these probes are read: the
uncanonical `/scim/v2/Users` returns `200 text/html`, the canonical
`/v1/iam/scim/v2/Users` returns `401 application/json`. Content type, not status,
is the test.

## Security Considerations

- **PKCE `S256` mandatory** for all flows. Authorization-code interception is the most common OAuth attack; PKCE eliminates it.
- **Signature + claim validation** — `validateToken` verifies the JWKS signature and `iss`/`aud`/`exp`. Never accept a token without these checks.
- **Token storage** — in-memory or httpOnly cookies. Never `localStorage`.
- **Confidential-client secrets** — KMS only (HIP-0027). `client_secret_basic` over TLS.
- **Refresh rotation** — handled by the SDK; refresh tokens rotate on use and the previous token is invalidated.
- **TLS everywhere** — IAM rejects plaintext. The gateway and ingress (HIP-0519, HIP-0068) terminate and re-encrypt.
- **Same-site apps** — login, chat and dev share `hanzo.ai`, and every `*.hanzo.ai` page is same-site with it. The strict CSP, the separate origin for rendered content and the same-origin JSON rule for cookie writes (§10) are what keep a sibling page, or content a user or a model authored, from acting as the identity host.
- **Cutover** — Hanzo refresh tokens are revoked and Hanzo passkeys re-enrolled when the issuer moves (§10); no grant or credential crosses from `https://hanzo.id` to `https://hanzo.ai`.

## References

1. [HIP-0026: Identity & Access Management Standard](./hip-0026-identity-access-management-standard.md) — the IAM server
2. [HIP-0519: One Identity Boundary](./hip-0519-one-identity-boundary.md) — JWT validation + `X-Org-Id` minted once at the edge
3. [HIP-0068: Ingress Standard](./hip-0068-ingress-standard.md) — edge TLS and routing
4. [HIP-0027: Secrets Management Standard](./hip-0027-secrets-management-standard.md) — KMS-managed client secrets
5. [HIP-0134: One Process, One Socket, One Identity](./hip-0134-one-process-one-socket-one-identity.md) — how IAM fits the estate
6. [HIP-0118: SuperAdmin & Tenant Isolation Model](./hip-0118-superadmin-and-tenant-isolation-model.md) — the reserved-org predicate `IAM_ADMIN_TOKEN_EXCHANGE_APPS` defends
7. [HIP-0136: One Secret, One Path](./hip-0136-one-secret-one-path.md) — where a client secret is addressed
8. [HIP-0143: Egress — The Outbound Trust Boundary](./hip-0143-egress-outbound-trust-boundary.md) — the machine identity a caller presents in order to spend
9. [HIP-0449: hanzo-login](./hip-0449-hanzo-login.md) — the ID portal that serves the sign-in pages (§6, §10)
10. [`@hanzo/iam`](https://github.com/hanzo-js/iam) — the SDK; `src/paths.ts` is the canonical path source
11. Standards this surface implements (the wire contract, in full):
   - [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) OAuth 2.0 — authorize, token (authorization_code / refresh_token / client_credentials / password grants)
   - [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) PKCE `S256`
   - [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517) JWK / JWKS
   - [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) Token Introspection
   - [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) Token Revocation
   - [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) Authorization Server Metadata
   - [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) OAuth 2.0 Token Exchange — delegation / on-behalf-of (replaces `issue-user-token`)
   - [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) Resource Indicators (`resource`/`audience`)
   - [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) Device Authorization Grant (optional, for input-constrained clients)
   - [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644) SCIM 2.0 Protocol + [RFC 7643](https://datatracker.ietf.org/doc/html/rfc7643) SCIM Core Schema — identity provisioning (replaces the `get-users`/`add-user` verbs)
   - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) + [Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) — id_token, UserInfo, discovery, RP-initiated logout

## Copyright

Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/).
