HIPsHanzo Proposals
Back to HIPs
HIP-0111FinalStandards TrackInfrastructure

Hanzo IAM Authentication Standard

Hanzo AI Team
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:

BrandIAM origin (serverUrl)Login UI
Hanzohttps://hanzo.aihanzo.ai
Luxhttps://lux.idlux.id
Zoohttps://zoolabs.idzoolabs.id
Parshttps://pars.idpars.id
Osagehttps://osage.idosage.id
Adnexushttps://id.adnexus.devid.adnexus.dev
Bootnodehttps://id.bootno.deid.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 (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.

PurposePathRFC / spec
OIDC discovery/.well-known/openid-configurationOIDC Discovery 1.0
AS metadata/.well-known/oauth-authorization-serverRFC 8414
Authorize/v1/iam/oauth/authorizeRFC 6749 §3.1
Token/v1/iam/oauth/tokenRFC 6749 §3.2
UserInfo/v1/iam/oauth/userinfoOIDC Core §5.3
Introspection/v1/iam/oauth/introspectRFC 7662
Revocation/v1/iam/oauth/revokeRFC 7009
JWKS/v1/iam/.well-known/jwksRFC 7517
Logout/v1/iam/oauth/logoutOIDC 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_types — 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:

SubpathSurfaceUse
@hanzo/iamIamClient, typesconditional Node/browser entry
@hanzo/iam/servervalidateToken, getServerSessionserver-side JWT validation + session
@hanzo/iam/betterauthiamProviderbetter-auth apps
@hanzo/iam/nextauthIamProviderNextAuth / Auth.js apps
@hanzo/iam/reacthooks, OrgProjectSwitcherReact SPAs
@hanzo/iam/browserIAM (PKCE client)browser PKCE login
@hanzo/iam/passportcreateIamPassportStrategyNode/Express + Passport

Server-side token validation (any backend)

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)

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

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

better-auth

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

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)

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
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

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:
FrameworkRegistered redirect URI
better-auth (genericOAuth + iamProvider)https://<app-host>/api/auth/oauth2/callback/hanzo
NextAuth / Auth.jshttps://<app-host>/api/auth/callback/iam
React SPA (@hanzo/iam/browser)https://<app-host>/auth/callback
Passporthttps://<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:

PurposePath
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.

ResourcePathMaps to
Service provider config/v1/iam/scim/v2/ServiceProviderConfigsupported 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:

SurfaceThis HIP saysProduction returns
/v1/iam/get-usersgone410 → {"successor":["/v1/iam/users"]}
/v1/iam/get-usergone410 → {"successor":["/v1/iam/users","/v1/iam/keys/principal"]}
/v1/iam/add-usergone410 → {"successor":["/v1/iam/users"]}
/v1/iam/get-organizationsgone410 → {"successor":["/v1/iam/organizations"]}
/v1/iam/get-applicationgone410 → {"successor":["/v1/iam/applications"]}
/v1/iam/issue-user-tokengone410 → {"successor":["/v1/iam/tokens/issue"]}
/v1/iam/get-recordsgone410 → {"successor":["/v1/iam/audit-logs"]}
/v1/iam/get-accountgone410 → {"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 — the IAM server
  2. HIP-0519: One Identity Boundary — JWT validation + X-Org-Id minted once at the edge
  3. HIP-0068: Ingress Standard — edge TLS and routing
  4. HIP-0027: Secrets Management Standard — KMS-managed client secrets
  5. HIP-0134: One Process, One Socket, One Identity — how IAM fits the estate
  6. HIP-0118: SuperAdmin & Tenant Isolation Model — the reserved-org predicate IAM_ADMIN_TOKEN_EXCHANGE_APPS defends
  7. HIP-0136: One Secret, One Path — where a client secret is addressed
  8. HIP-0143: Egress — The Outbound Trust Boundary — the machine identity a caller presents in order to spend
  9. HIP-0449: hanzo-login — the ID portal that serves the sign-in pages (§6, §10)
  10. @hanzo/iam — the SDK; src/paths.ts is the canonical path source
  11. Standards this surface implements (the wire contract, in full):
  • RFC 6749 OAuth 2.0 — authorize, token (authorization_code / refresh_token / client_credentials / password grants)
  • RFC 7636 PKCE S256
  • RFC 7517 JWK / JWKS
  • RFC 7662 Token Introspection
  • RFC 7009 Token Revocation
  • RFC 8414 Authorization Server Metadata
  • RFC 8693 OAuth 2.0 Token Exchange — delegation / on-behalf-of (replaces issue-user-token)
  • RFC 8707 Resource Indicators (resource/audience)
  • RFC 8628 Device Authorization Grant (optional, for input-constrained clients)
  • RFC 7644 SCIM 2.0 Protocol + RFC 7643 SCIM Core Schema — identity provisioning (replaces the get-users/add-user verbs)
  • OpenID Connect Core 1.0 + Discovery 1.0 — id_token, UserInfo, discovery, RP-initiated logout

Copyright

Copyright and related rights waived via CC0.