HIP-111: Hanzo IAM Authentication Standard. Status Active. Hanzo's own standard — read this before implementing against it.
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 (the clean-room hanzoai/iam2 implementation). This HIP specifies the wire contract — how everything talks to it. Where the two touch (endpoint paths, discovery), this HIP is authoritative and HIP-0026 follows it.
Hanzo IAM (hanzoai/iam2) is a clean-room, standards-based OAuth 2.0 + OpenID Connect + SCIM 2.0 provider — original expression, no upstream fork — deployed once per brand:
| Brand | IAM origin (serverUrl) | Login UI | |-------|--------------------------|----------| | Hanzo | https://iam.hanzo.ai | hanzo.id | | Lux | https://lux.id | lux.id | | Zoo | https://zoo.id | zoo.id | | Bootnode | https://id.bootno.de | id.bootno.de | | Pars | https://pars.id | pars.id |
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
Every authentication regression in the estate has had one of three root causes:
/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.better-auth genericOAuth({ discoveryUrl }). Discovery resolution landed on the SPA catch-all HTML and the client wired itself to garbage endpoints.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.
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_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:
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.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.id) 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.
@hanzo/iamJavaScript 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 |
import { validateToken } from "@hanzo/iam/server";
const result = await validateToken(accessToken, {
serverUrl: process.env.IAM_ENDPOINT!, // e.g. https://iam.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.
import { getServerSession } from "@hanzo/iam/server";
const session = await getServerSession({ serverUrl: process.env.IAM_ENDPOINT! });
if (!session) redirect("/login");
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.
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"],
}),
],
});
import { IAM } from "@hanzo/iam/browser";
const iam = new IAM({
serverUrl: "https://iam.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://iam.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.
import passport from "passport";
import { createIamPassportStrategy } from "@hanzo/iam/passport";
passport.use("iam", createIamPassportStrategy({
serverUrl: "https://iam.hanzo.ai",
clientId: "hanzo-myservice",
clientSecret: process.env.IAM_CLIENT_SECRET!,
callbackUrl: "https://myservice.hanzo.ai/v1/sso/oidc/callback",
}));
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 |
z@<domain> / Ilove<App>2026!! (e.g. z@hanzo.ai). No built-in admin — the seeded superuser is the only privileged account.These break in production and are not permitted under any circumstance:
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.aud, S256, or refresh rotation wrong./v1/iam/oauth/... (or, worse, /oauth/...) itself. The path lives in OIDC_PATHS inside the SDK; applications pass only serverUrl./oauth/, /api/login/oauth/, anything /api/-prefixed. Gone. No backward compatibility.originFrontend in production — produces a split-origin discovery document that breaks strict clients./api/ on the front-door 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 front-door.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.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.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.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-0044) 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.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 portal at hanzo.id/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-0044) 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 front-door Worker. Client apps use only the standard surface (§1) through the SDK; the login API is internal to the AS.
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, capability-gated by IAM_KEY_MINT_ALLOWED_APPS), 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.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.admin/built-in) subject requires the separate IAM_ADMIN_MINT_ALLOWED_APPS capability (defense in depth: a leaked general-exchange credential can never reach a SuperAdmin identity). Every exchange is audit-logged.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 |
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.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).owner; a SuperAdmin may filter across tenants. Same authorization model as every other surface — bearer-authenticated, owner-scoped, fail-closed.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).
https://iam.<brand>/callback); the provider hop happens inside IAM, not in the app. The app only ever sets its own redirect_uri (its /auth/callback).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.@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.The prohibitions in §Anti-patterns are not yet met by the deployment. Measured against https://hanzo.id on 2026-07-28; unauthenticated probes, so a 401 means the route exists and demands auth, and a 404 would mean it is genuinely gone:
| Surface | This HIP says | Production returns | |---|---|---| | /v1/iam/get-users | gone | 401 — route live | | /v1/iam/get-user | gone | 401 — route live | | /v1/iam/add-user | gone | 401 — route live | | /v1/iam/get-organizations | gone | 401 — route live | | /v1/iam/get-application | gone | 401 — route live | | /v1/iam/issue-user-token | gone | 401 — route live | | /v1/iam/get-records | gone | 401 — route live | | /v1/iam/get-account | gone | 200 with {"status":"error","msg":"please sign in first"} |
Two further deviations:
/v1/iam/oauth/access_token answers alongside the standard /v1/iam/oauth/token. Only the standard one appears in OIDC discovery, so the alias is live but undiscoverable — the worst of both.
200 carrying an error. get-account returns HTTP 200 with an error envelope where the standard requires 401. This is the vendor error shape this HIP exists to eliminate, and a client that branches on the status code reads "signed in".
The standard surfaces this HIP mandates are all present and correct (/v1/iam/scim/v2/Users, /v1/iam/oauth/{token,userinfo,introspect}, jwks at /v1/iam/.well-known/jwks, and grant_types_supported including RFC 8693 token-exchange and device_code). So this is not a gap in the implementation of the standard — both surfaces are live at once, which is precisely the "two ways to do one thing" the HIP forbids.
Removal order matters. The compat aliases cannot simply be deleted: hanzoai/cloud calls get-application today, and internal/authz carries an entityNoun fold specifically so capability checks keep working on the alias path. Retire in this order, or the deletion is an outage:
/v1/iam/applications, SCIM for users, oauth/token for tokens).
internal/compat/aliases.go and the access_token alias, and drop the entityNoun fold that exists only to serve them.
Until step 3 lands, this HIP describes the intended contract, not the deployed one, and that difference is the point of recording it here rather than leaving the prohibition list looking satisfied.
S256 mandatory for all flows. Authorization-code interception is the most common OAuth attack; PKCE eliminates it.validateToken verifies the JWKS signature and iss/aud/exp. Never accept a token without these checks.localStorage.client_secret_basic over TLS.X-Org-Id propagation at the gateway@hanzo/iam — the SDK; src/paths.ts is the canonical path sourceS256issue-user-token)resource/audience)get-users/add-user verbs)Copyright and related rights waived via CC0.