HIP-26: Identity & Access Management Standard. Status Draft. Hanzo architectural specification.
Hanzo IAM is the identity and access management provider for the Hanzo ecosystem and the sole authority for identity and tokens. Nothing else in the estate issues a credential, validates one, or keeps a session: there is no service token, no shared secret and no per-app auth stack. This HIP specifies the server; HIP-0111 specifies the wire contract every client speaks to it, and where the two touch, HIP-0111 is authoritative.
IAM is a clean-room native rewrite on the Hanzo stack — zip over hanzoai/orm, with no Beego and no xorm. Storage is one orm.DB abstraction with the backend chosen at boot (--store): embedded SQLite by default (hanzoai/sqlite, pure-Go, WAL), or the shared sql or datastore over ZAP (HIP-0144). Every handler is written once against orm.DB and never against a driver.
It implements OAuth 2.0, OpenID Connect, and SCIM 2.0, and provides multi-tenant authentication with per-organization white-label identity domains. Any organization registered in IAM can be assigned a branded login page and identity domain; the default deployment ships hanzo.id, lux.id, zoo.id, pars.id and id.ad.nexus.
Repository: github.com/hanzoai/iam Image: ghcr.io/hanzoai/iam Retired: the Beego/xorm fork this replaced is hanzoai/iam-v1, out of every graph. A description of Beego, xorm, or a Postgres schema is a description of that repository.
Every service in the Hanzo ecosystem needs authentication. Without a centralized IAM, each team independently builds login flows, token validation, user storage, and session management. This leads to:
[email protected]) must hold memberships across all of them.A single IAM instance at hanzo.id eliminates all five problems. Services delegate authentication entirely. The OAuth application model provides per-service isolation (each app has its own client ID, redirect URIs, and scopes). Multi-org support is built into the data model. And the user entity in IAM carries a balance field, so balance checks are a single query against the same database that validates the token.
This section explains the why behind each major design decision. Good infrastructure decisions compound; bad ones metastasize. Understanding the rationale prevents future engineers from "fixing" things that are not broken.
A managed identity service prices per monthly active user and puts the most sensitive part of the stack behind somebody else's export path: migrating password hashes out is not a routine operation, and an air-gapped or sovereign deployment cannot use a hosted provider at all. Those are the constraints, and they are why identity is ours.
Owning it is not the same as forking it. The predecessor was a fork, and the fork is what a clean-room rewrite replaced — hanzoai/iam owns its source outright and collapses to one way of doing each thing. What that buys is the ability to delete: the vendor error envelope, the verb aliases, the second spelling of the token endpoint, and the second storage engine all go away because nothing upstream requires them (HIP-0111 §4 lists what is gone and §Conformance status records what is still live).
The cost is stated plainly: a rewrite carries no upstream community and no inherited security review, so the surface is RFC-standard precisely so that review can be done against the RFCs rather than against us.
Each organization gets its own white-label identity domain. The system supports an arbitrary number of tenants — any organization registered in IAM can be assigned a custom domain. The default deployment includes:
| Organization | Domain | Primary Color | Description | |-------------|--------|---------------|-------------| | Hanzo | hanzo.id | #fd4444 (red) | AI infrastructure | | Lux | lux.id | #e4e4e7 (zinc) | Blockchain network | | Zoo | zoo.id | #10b981 (emerald) | Research foundation | | Pars | pars.id | #3b82f6 (blue) | Regional platform | | AdNexus | id.ad.nexus | #3b82f6 (blue) | Advertising platform |
Adding a new tenant requires:
IngressRoute served by Hanzo Ingress, HIP-0068, or a DNS record)hanzo/id middleware tenant map, or deploy a forked instance with IAM_ORIGIN, NEXT_PUBLIC_ORG, and NEXT_PUBLIC_CLIENT_ID environment variablesHanzo Ingress (HIP-0068) routes every tenant domain to the same IAM process. IAM resolves the organization from the request's Host header via the origin configuration and the application's organization field. Organizations are fully isolated — different themes, different OAuth applications, different password policies, different MFA requirements — while sharing one IAM process and one database.
The hanzo/id login UI is designed to be forked for deep customization. Organizations can:
hanzoai/id to luxfi/id, zoofdn/id, etc. for fully custom brandinghanzoai/id image with per-tenant env vars for lightweight white-labelingThe alternative (path-based multi-tenancy like hanzo.id/lux/login) is fragile. It leaks the organizational structure into URLs, makes CORS configuration harder, and prevents each org from having a clean, branded identity domain that users can trust.
An earlier revision of this HIP argued that the user's balance belongs on the user record, so that "is this caller authenticated?" and "does this caller have credit?" answer in one token validation. That is not what shipped, and the argument does not survive contact with the money.
A balance is a position in a ledger. It is derived from transactions that Commerce records, that a payment processor confirms, and that a metered usage record debits — three systems with their own ordering, retries and reconciliation (HIP-0018, HIP-1220, HIP-1313, HIP-1001). Putting the authoritative copy on the identity record makes identity a participant in settlement: a failed debit becomes an identity write, and a disagreement between the two copies is resolved by whichever one a reader happened to ask.
The balance fields IAM carries are read-only mirrors, and the code says so. The authoritative balance lives in Commerce. A service that gates on credit reads the entitlement surface, not the identity record; a token is proof of who is calling, never of what they can afford.
Internet
│
┌─────────┴─────────┐
│ Hanzo Ingress │
│ (TLS termination) │
│ HIP-0068 │
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
│ │ │
hanzo.id lux.id zoo.id ...
│ │ │
└───────────────┼───────────────┘
│
┌─────────┴─────────┐
│ Hanzo IAM │
│ zip over orm.DB │
│ HTTP edge + ZAP │
└─────────┬─────────┘
│ --store
┌─────────┴─────────┐
│ sqlite (default) │
│ sql | datastore │
│ HIP-0144 │
└───────────────────┘
Every Hanzo application uses Authorization Code Grant with PKCE (RFC 7636). Implicit grant is not supported. This is the flow:
1. Client generates code_verifier (random 43-128 chars)
2. Client computes code_challenge = BASE64URL(SHA256(code_verifier))
3. Client redirects user to:
GET https://iam.hanzo.ai/v1/iam/oauth/authorize
?client_id=hanzo-app-client-id
&redirect_uri=https://hanzo.ai/callback
&response_type=code
&scope=openid profile email
&state=<random>
&code_challenge=<code_challenge>
&code_challenge_method=S256
4. User authenticates at the brand login UI (password, WebAuthn, or social login)
5. IAM redirects back:
GET https://hanzo.ai/callback
?code=<authorization_code>
&state=<random>
6. Client exchanges code for tokens:
POST https://iam.hanzo.ai/v1/iam/oauth/token
grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=https://hanzo.ai/callback
&client_id=hanzo-app-client-id
&code_verifier=<code_verifier>
7. IAM returns:
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 604800,
"refresh_token": "eyJhbGciOi...",
"id_token": "eyJhbGciOi...",
"scope": "openid profile email"
}
Access tokens are JWTs signed with the application's certificate (e.g., cert-hanzo). Token lifetime defaults to 168 hours (7 days). Refresh token lifetime defaults to 720 hours (30 days).
When a request arrives, IAM resolves the organization context through the following chain:
/v1/iam/get-app-login: The login UI (hosted at hanzo.id, served by the hanzo.id-worker Cloudflare Worker) calls this endpoint with the clientId from the OAuth authorize URL. IAM returns the application name and organization name. This is the source of truth./v1/iam/login: The payload includes application and organization fields. These must match the application's configured organization. Hardcoding organization: "hanzo" for all requests breaks scoped SSO clients (e.g., KMS has its own client ID and expects the correct org context).Host header against known origins. Each application configures origin and originFrontend to enable this.Each service in the ecosystem registers as an OAuth application with its own client credentials, redirect URIs, and scopes:
| Application | Client ID | Organization | Redirect URIs (production) | |------------|-----------|--------------|---------------------------| | app-hanzo | hanzo-app-client-id | hanzo | hanzo.ai/callback, hanzo.app/callback, cloud.hanzo.ai/callback | | app-cloud | hanzo-cloud-client-id | hanzo | cloud.hanzo.ai/callback | | app-commerce | hanzo-commerce-client-id | hanzo | commerce.hanzo.ai/callback | | app-console | hanzo-console-client-id | hanzo | console.hanzo.ai/api/auth/callback/hanzo-iam | | app-platform | hanzo-platform-client-id | hanzo | platform.hanzo.ai/callback | | app-zoo | zoo-app-client-id | zoo | zoo.ngo/callback, zips.zoo.ngo/callback | | app-lux | lux-app-client-id | lux | lux.network/callback, wallet.lux.network/callback | | app-pars | pars-app-client-id | pars | pars.ai/callback | | app-adnexus | adnexus-app-client-id | adnexus | ad.nexus/callback |
All applications use:
authorization_code, refresh_token, client_credentials, implicit, passwordcode, token, id_tokenClient secrets use KMS-managed placeholders (${IAM_APP_HANZO_CLIENT_SECRET}) resolved at startup via the resolveSecrets() function. Plaintext secrets never appear in configuration files or init_data.json.
Every user and organization carries balance fields. They are a cache with a publisher, not a ledger:
number without a second round trip.
payout — MUST read the authoritative surface. Gating spend on a mirror gates it on a value that can be stale in the direction that costs us.
There is no add-balance and no add-transaction verb on IAM (HIP-0111 §4.8). The ledger's shape is specified where the ledger is: HIP-1001 for double-entry, HIP-1313 for the metered record, HIP-1220 for the merchant half.
IAM bootstraps from init_data.json on first startup. This file defines the initial state of the system:
{
"organizations": [
{
"name": "hanzo",
"displayName": "Hanzo",
"websiteUrl": "https://hanzo.ai",
"passwordType": "argon2id",
"defaultApplication": "app-hanzo",
"themeData": {
"themeType": "dark",
"colorPrimary": "#fd4444"
}
},
{ "name": "zoo", "displayName": "Zoo Labs", "colorPrimary": "#10b981" },
{ "name": "lux", "displayName": "Lux Network", "colorPrimary": "#e4e4e7" },
{ "name": "pars", "displayName": "Pars", "colorPrimary": "#3b82f6" },
{ "name": "adnexus", "displayName": "AdNexus", "colorPrimary": "#3b82f6" }
],
"applications": [
{
"name": "app-hanzo",
"organization": "hanzo",
"clientId": "hanzo-app-client-id",
"clientSecret": "${IAM_APP_HANZO_CLIENT_SECRET}",
"grantTypes": ["authorization_code", "refresh_token", "client_credentials", "implicit", "password"],
"tokenFormat": "JWT",
"expireInHours": 168,
"refreshExpireInHours": 720
}
],
"users": [
{
"name": "admin",
"email": "[email protected]",
"type": "normal-user",
"isAdmin": true,
"balance": 10000
}
],
"certs": [
{
"name": "cert-hanzo",
"cryptoAlgorithm": "RS256",
"bitSize": 4096
}
]
}
The initDataNewOnly configuration flag controls whether init_data.json overwrites existing records (false) or only creates missing ones (true). Production uses initDataNewOnly = false to ensure configuration drift is corrected on restart.
HIP-0111 §1 is the one table of endpoints. It is not repeated here, because two tables of one surface is how the second one goes stale — which is exactly what happened: this section previously listed get-account, get-user, add-user, update-user, delete-user, add-balance, add-transaction and get-transactions as the user-management and billing surface, and every one of those is a verb alias HIP-0111 §4.8 forbids. Identity provisioning is SCIM 2.0 (§8); account claims are OIDC UserInfo (§1); delegation is RFC 8693 token exchange (§7); balances are Commerce's, not IAM's.
What this HIP states about the surface, as the server's own concern:
/v1/iam/. There is no /oauth/, no /api/login/*, no /api/ prefix anywhere, and no v2 (HIP-0119).
200 text/html SPA catch-all for any unregistered path, so a wrong path is silent breakage rather than a 404. That is why clients reach the surface only through @hanzo/iam, which holds the paths in one place.
get-app-login, login, signup, send-verification-code) is the authorization server's own concern — OAuth deliberately does not specify how an AS authenticates the end user — and is called by the hosted login UI alone. It is not a client integration surface. HIP-0111 §6 is normative for it.
/healthz, /readyz. Not /api/health.The discovery document is host-relative and self-consistent — issuer, authorize, token, userinfo and jwks share one origin, which requires originFrontend to be empty:
{
"issuer": "https://iam.hanzo.ai",
"authorization_endpoint": "https://iam.hanzo.ai/v1/iam/oauth/authorize",
"token_endpoint": "https://iam.hanzo.ai/v1/iam/oauth/token",
"userinfo_endpoint": "https://iam.hanzo.ai/v1/iam/oauth/userinfo",
"jwks_uri": "https://iam.hanzo.ai/v1/iam/.well-known/jwks",
"end_session_endpoint": "https://iam.hanzo.ai/v1/iam/oauth/logout",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic"]
}
A split-origin discovery document breaks strict OIDC clients that pin the issuer.
The client contract is HIP-0111. JS/TS applications integrate only through @hanzo/iam; Go services use iamsdk. No application writes an OIDC path string.
import "github.com/hanzoai/iam/iamsdk"
func init() {
iamsdk.InitConfig(
"https://iam.hanzo.ai", // IAM endpoint
"hanzo-app-client-id", // Client ID
"client-secret-here", // Client secret
"cert-hanzo", // Certificate name
"hanzo", // Organization
"app-hanzo", // Application
)
}
// Validate a JWT access token
func validateToken(token string) (*iamsdk.Claims, error) {
claims, err := iamsdk.ParseJwtToken(token)
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
return claims, nil
}
// Get user info from token
func getUserInfo(token string) (*iamsdk.User, error) {
return iamsdk.GetUserByAccessToken(token)
}
@hanzo/iam)Server-side token validation:
import { validateToken } from "@hanzo/iam/server";
const result = await validateToken(accessToken, {
serverUrl: "https://iam.hanzo.ai",
clientId: "hanzo-app-client-id",
});
if (result.ok) {
const { userId, email, owner } = result; // owner = org slug; scope queries to it
}
Framework providers (@hanzo/iam/betterauth, @hanzo/iam/nextauth), the React SPA client (@hanzo/iam/react, @hanzo/iam/browser), and Passport (@hanzo/iam/passport) are specified in HIP-0111.
IAM runs on the hanzo-k8s cluster, one process per brand origin, behind Hanzo Ingress (HIP-0068), which terminates TLS and routes every tenant domain to it. The image is ghcr.io/hanzoai/iam, built by Hanzo Git Actions from .hanzo/workflows/ (HIP-0036); there is no second registry.
Health is at the root — /healthz, /readyz — never under a version prefix and never under /api/ (HIP-0119 §Health).
Configuration is flags with environment fallbacks; there is no app.conf and no Beego runmode.
| flag | what it decides | |---|---| | --store | sqlite (default), sql, or datastore — see Storage below | | --db | SQLite path, when the store is sqlite | | --zap | the ZAP listener for service-to-service calls | | --http | the external HTTP edge | | --init-data | seed file, new entities only |
Deployment environment: IAM_ISSUER pins the issuer per brand (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 three capability allow-lists — IAM_TOKEN_EXCHANGE_APPS, IAM_ADMIN_TOKEN_EXCHANGE_APPS and IAM_KEY_MINT_ALLOWED_APPS — are specified in HIP-0111 §7, which is the one description of them.
The seed file expands ${VAR} from the environment and creates only what is missing. It never deletes or overwrites an existing user, application or org, so a restart cannot reset a password, an MFA enrolment or any other user data.
One orm.DB abstraction, backend chosen at boot, per HIP-0144:
sqlite (default) — embedded, pure-Go, WAL. No server, no credential.sql — the one shared hanzoai/sql, reached over ZAP.datastore — hanzoai/datastore over ZAP, with snapshots, at no code change.There is no hanzo_iam database, no per-app Postgres instance, no Redis, and no MySQL path. Sessions are IAM's own state in that store, not a second engine: an external cache for sessions was a property of the retired fork.
The entities are organization, user, application, token, session, cert, provider, permission and role. Balance fields on an organization are read-only mirrors; the authoritative balance lives in Commerce (HIP-0018, HIP-1220) and the metered record in HIP-1313. IAM is not a billing engine and is not the source of truth for spend.
Client secrets and signing material are KMS references, never values, and never plaintext in Git, a manifest, the seed file or an image. The path is derivable from the app that reads the secret and the variable it becomes — HIP-0136 is the one statement of that convention, and it also records why base keeps its own KMS project rather than being folded into the shared one.
Passwords are hashed, never stored or transmitted in the clear. Verification is algorithm-resolved from the stored row (argon2id and bcrypt), verify-only and fail-closed: an unrecognised algorithm is a refusal, not a fallback.
| Standard | Status | Notes | |----------|--------|-------| | RFC 6749 (OAuth 2.0) | Full | Authorization Code + PKCE; client_secret_basic | | RFC 7636 (PKCE) | Full | S256 only | | OIDC Core 1.0 | Full | Discovery, UserInfo, ID Tokens | | OIDC Discovery 1.0 | Full | /.well-known/openid-configuration (host-relative) | | OIDC RP-Initiated Logout | Full | /v1/iam/oauth/logout | | RFC 7517 (JWK) | Full | /v1/iam/.well-known/jwks | | RFC 7519 (JWT) | Full | RS256 today; ML-DSA-65 hybrid JWT and JWKS from the Cert entity is the direction (HIP-0005) | | RFC 7662 / RFC 7009 | Full | Introspection and revocation | | RFC 8414 | Full | Authorization server metadata | | RFC 8693 (Token Exchange) | Full | Delegation, gated per HIP-0111 §7 | | RFC 8707 (Resource Indicators) | Full | resource/audience pins aud; validators fail closed | | RFC 7644 / RFC 7643 (SCIM 2.0) | Full | Identity provisioning |
The hanzo/id repository provides a forkable, white-label Next.js login UI that serves as the frontend for all identity domains. It includes:
.well-known host-relative to the tenant domainS256 code challenge generation and verificationluxfi/id, zoofdn/id, etc. for org-specific brandingThe client contract is HIP-0111. JS/TS uses @hanzo/iam; Go uses iamsdk. All hit the canonical /v1/iam/oauth/* endpoints.
| SDK | Package | Authorize | Token | |-----|---------|-----------|-------| | JS/TS | @hanzo/iam | /v1/iam/oauth/authorize | /v1/iam/oauth/token | | Go | github.com/hanzoai/iam/iamsdk | /v1/iam/oauth/authorize | /v1/iam/oauth/token |
There are no legacy paths. /oauth/, /api/login/oauth/, and /api/-prefixed auth paths are not served and not supported. The OIDC discovery document returns only the canonical /v1/iam/oauth/* endpoints.
inactiveTimeoutMinutes = 30 in production. Idle sessions expire after 30 minutes.authState configuration pins sessions to the IAM origin.orm.DB store with a TTL and registered for revocation. There is no external session cache: a second engine for sessions was a property of the retired fork, and it made "log everyone out" an operation on infrastructure rather than on the identity service.origin and originFrontend settings restrict which origins can interact with IAM APIs. Cross-origin requests from unknown origins are rejected./healthz is unauthenticated (load-balancer probes require it) and returns only a boolean status, leaking no internal state. It is at the root, not under /api/ and not under a version prefix (HIP-0119).enableErrorMask = true in production ensures internal errors (database errors, stack traces) are never exposed to clients. Clients receive generic error messages; details are logged server-side.admin. Production deployments MUST rotate this immediately. The HANZO_INIT_USER_EMAIL bootstrap flow creates admin users with KMS-managed passwords.IAM handles both authentication (identity verification) and authorization (access control), but they are distinct concerns:
Authentication (AuthN) — "Who are you?"
Authorization (AuthZ) — "What can you do?"
invalid_scope per RFC 6749 §4.1.2.1 if the client requests scopes not configured for its application.permission and role tables enforce this.app-console cannot be used at app-commerce (different aud claim).isAdmin flag on the user entity grants full API access within the organization. Non-admin users are restricted to self-service operations.balance > 0 from the JWT claims or userinfo endpoint to gate access to paid features (AI inference, compute).The key design principle: IAM authenticates users and issues scoped tokens. Services authorize requests by validating token claims. IAM does not make fine-grained authorization decisions for downstream services — it provides the identity and claims that services use to make their own authorization decisions.
admin org and the one SuperAdmin predicateX-Org-Id is mintedCopyright and related rights waived via CC0.