IAM
Hanzo's identity provider: users, organizations, applications, and the OIDC/OAuth2 endpoints every Hanzo service authenticates against.
Also for this capability: API · CLI · MCP · SDKs
Hanzo's identity provider: users, organizations, applications, and the OIDC/OAuth2 endpoints every Hanzo service authenticates against.
| Base URL | https://api.hanzo.ai |
| Operations | 150 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-0026 · Identity & Access Management Standard — Active · read the specification →
Hanzo IAM is the unified identity and access management provider for the Hanzo ecosystem, serving production traffic at hanzo.id. It is a clean-room native rewrite on the Hanzo stack -- zip over hanzoai/orm -- and carries no Beego and no xorm. (This paragraph asserted a Go/Beego platform until 2026-08-13; the Casdoor-derived Beego/xorm tree is the retired v1 line at hanzoai/iam-v1. iam/go.mod requires no beego module and no Go file imports one -- the only occurrences in the tree are comments describing what v1 did.)
Hanzo IAM implements OAuth 2.0 and OpenID Connect (OIDC) — authorization code with PKCE, client credentials, the device grant, introspection and revocation — plus WebAuthn and TOTP MFA. (Earlier revisions also claimed SAML 2.0 and CAS; no SAML or CAS route exists in the served surface, plugin/iam/openapi.json, and the claim is withdrawn until one does.) It provides multi-tenant authentication with per-organization white-label identity domains — any organization registered in IAM can get its own branded login page and identity domain. The default deployment ships with hanzo.id, lux.id, zoo.id, pars.id, and id.ad.nexus, but the system supports arbitrary additional tenants via configuration.
IAM is the source of truth for identity. It is NOT the source of truth for spend: prepaid credit is the finance ledger's, read at the caller's own wallet address by the one spend predicate in hanzoai/cloud (spend.go), and IAM serves no balance or transaction route.
Repository: github.com/hanzoai/iam
Port: 8000
Docker: ghcr.io/hanzoai/iam:latest
Motivation
Keycloak is the most popular open-source IAM. It is also a 500MB+ Java application that requires a JVM, takes 30+ seconds to start, and consumes 512MB of heap at idle. In the Hanzo ecosystem, where the blockchain node, CLI tools, SDK, and wallet are all written in Go, introducing a Java dependency for IAM is a poor fit.
Hanzo IAM compiles to a single Go binary (~50MB), starts in under 2 seconds, and idles at ~50MB RSS. It ships a React frontend (easy to customize for branding) and serves OAuth 2.0, OIDC, WebAuthn and TOTP MFA. The tradeoff is a smaller community and fewer enterprise features (no fine-grained RBAC policies, no UMA). For our use case -- OAuth SSO across a handful of first-party services -- the Hanzo IAM feature set is sufficient, and the operational simplicity is decisive.
| Factor | Hanzo IAM | Keycloak |
|---|---|---|
| Language | Go | Java |
| Binary size | ~50 MB | ~500 MB+ |
| Idle memory | ~50 MB RSS | ~512 MB heap |
| Startup time | < 2s | 30-60s |
| Frontend | React (customizable) | Freemarker (limited) |
| Protocol support | OAuth2, OIDC, WebAuthn | OAuth2, OIDC, SAML, UMA |
| Stack alignment | Same as Lux node, CLI, SDK | Requires JVM |
Specification
Architecture
Internet
│
┌─────────┴─────────┐
│ Traefik │
│ (TLS termination) │
│ :80 → :443 │
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
│ │ │
hanzo.id lux.id zoo.id ...
│ │ │
└───────────────┼───────────────┘
│
┌─────────┴─────────┐
│ Hanzo IAM │
│ (zip + orm) │
│ :8000 │
└─────────┬─────────┘
│
┌─────────┴─────────┐
│ iam.db │
│ (SQLite, encrypted │
│ at rest) │
└───────────────────┘One store: {DataDir}/iam/iam.db, an encrypted-at-rest SQLite file opened by
IAM's own orm.DB, converted in place if it arrived plaintext — the same file
the standalone binary is pointed at with --db, so the graft in
hanzoai/cloud and the standalone process serve the same identities
(cloud apps/iam/iam.go:36-48). There is no external database and no
session cache beside it. (Earlier revisions drew SQL on :5432 and a KV
session store on :6379; both belonged to the retired v1 line.)
OAuth 2.0 Flow: Authorization Code Grant with PKCE
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). Lifetimes are per-application: expireInHours sets the access-token lifetime (1 hour when undeclared), and refreshExpireInHours sets the refresh lifetime, clamping to the access lifetime when unset (iam pkg/schema/application.go:28, internal/oidc/token.go:533-548). The example registration in Bootstrap below declares 168h/720h, which is what the flow example's expires_in reflects.
Multi-Tenant Domain Resolution
When a request arrives, IAM resolves the organization context through the following chain:
-
Application lookup via
/v1/iam/get-app-login: The login UI (hosted at hanzo.id, served by thehanzo.id-workerCloudflare Worker) calls this endpoint with theclientIdfrom the OAuth authorize URL. IAM returns the application name and organization name. This is the source of truth. -
Direct login via
/v1/iam/login: The payload includesapplicationandorganizationfields. These must match the application's configured organization. Hardcodingorganization: "hanzo"for all requests breaks scoped SSO clients (e.g., KMS has its own client ID and expects the correct org context). -
The Host decides the issuer, not the org: there is no Host-based org fallback —
get-app-loginrefuses a missingclientIdwith a 400 (iaminternal/oidc/frontdoor.go:101). What the requestHostdoes decide is the issuer the tokens carry, derived per request so each brand domain emits its own (internal/oidc/token.go:608-609).
Application Configuration
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:
- Grant types:
authorization_code,refresh_token,client_credentials,password, token exchange (RFC 8693), device code (RFC 8628) - Response types:
code(the only one the discovery document advertises) - Token format: JWT
- Password hashing: argon2id
- WebAuthn: Enabled
Client 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.
Identity, Not Money
IAM serves no balance and no transaction route (plugin/iam/openapi.json
carries neither noun), and earlier revisions of this section — a per-user
balance field, /api/add-balance, /api/add-transaction, a Transaction
model — described the retired v1 line. Prepaid credit is the finance ledger's:
the one spend predicate reads the caller's own wallet address, exact to the
atto-USD, and composes it with the subscription answer commerce resolves
(hanzoai/cloud spend.go). IAM's contribution to that decision is the
identity the wallet is derived from, nothing more.
Bootstrap: init_data.json
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", "password"],
"tokenFormat": "JWT",
"expireInHours": 168,
"refreshExpireInHours": 720
}
],
"certs": [
{
"name": "cert-hanzo",
"cryptoAlgorithm": "RS256",
"bitSize": 4096
}
]
}Seeding is new-only and idempotent: an entity that already exists is left untouched, and ${VAR} references are substituted from the environment before parsing (iam internal/seed/seed.go:10-13). Users are deliberately excluded from the seed — accounts and service-account applications are provisioned through the operator-driven bootstrap endpoints (POST /v1/iam/admin/{applications,users}/upsert, internal/bootstrap/bootstrap.go:4-16), which fail closed when no service token is configured.
API Endpoints
Authentication (canonical OIDC endpoints)
These /v1/iam/oauth/* paths are the only OIDC endpoints. There is no /oauth/*, no /api/login/*, no /api/-prefixed auth path. Clients reach them only through @hanzo/iam; see HIP-0111 (Hanzo IAM Authentication Standard), which is authoritative for the client contract. IAM serves a 200 text/html SPA catch-all for any unregistered path — a wrong path is silent breakage, not a 404.
| Method | Endpoint | RFC | Description |
|---|---|---|---|
| GET | /v1/iam/get-app-login | — | Resolve application and org from client ID |
| POST | /v1/iam/login | — | Password login (returns session or redirects) |
| GET | /v1/iam/oauth/authorize | RFC 6749 §3.1 | Authorization endpoint (PKCE S256 required) |
| POST | /v1/iam/oauth/token | RFC 6749 §3.2 | Token exchange (client_secret_basic for confidential clients) |
| GET | /v1/iam/oauth/userinfo | OIDC Core §5.3 | UserInfo endpoint |
| POST | /v1/iam/oauth/introspect | RFC 7662 | Token introspection |
| POST | /v1/iam/oauth/revoke | RFC 7009 | Token revocation |
| GET | /v1/iam/oauth/logout | OIDC RP-Initiated Logout | End session endpoint |
| GET | /v1/iam/.well-known/jwks | RFC 7517 | JSON Web Key Set |
| GET | /.well-known/openid-configuration | OIDC Discovery 1.0 | Discovery (host-relative; issuer derived from the request Host) |
User Management
| Method | Endpoint | Description |
|---|---|---|
| GET | /v1/iam/get-account | Get current user (from session/token) |
| GET | /v1/iam/oauth/userinfo | OIDC UserInfo endpoint |
| GET | /v1/iam/get-user | Get user by ID |
| POST | /v1/iam/update-user | Update user profile |
| POST | /v1/iam/add-user | Create new user (admin) |
| POST | /v1/iam/delete-user | Delete user (admin) |
(There is no billing table any more: earlier revisions listed
/api/add-transaction and /api/add-balance here, routes the served surface
does not carry — see Identity, Not Money above.)
Discovery
| Method | Endpoint | RFC | Description |
|---|---|---|---|
| GET | /.well-known/openid-configuration | OIDC Discovery 1.0 | OIDC discovery document (host-relative) |
| GET | /v1/iam/.well-known/jwks | RFC 7517 | JSON Web Key Set |
The OIDC discovery document is host-relative and self-consistent — issuer, authorize, token, userinfo, and jwks all share one origin, because every endpoint URL is built from the same request-derived issuer (iam internal/oidc/oidc.go:134-146):
{
"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", "password",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:device_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}SDK Integration
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.
Go SDK
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)
}JavaScript / TypeScript SDK (@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.
Implementation
Deployment Shapes
IAM ships two ways, one store either way. Standalone, the binary serves the
brand identity domains behind the platform ingress, opening iam.db at the
path --db names. Grafted, hanzoai/cloud composes the whole IAM surface
in-process — host.Use(iamserver.NewApp(db)), so cloud's router learns IAM's
route patterns AND its op registry while IAM's own router keeps IAM's
behaviour — and opens the same file under its data directory
(cloud apps/iam/iam.go:13-18, :36-48). A store that will not open is
handed to IAM as nil and every identity request answers 503 while co-resident
subsystems stay up; the degrade never moves the route table
(apps/iam/iam.go:56-69).
Production Configuration
Key configuration facts (the v1 config file and its knobs — enableErrorMask,
logPostOnly, initDataNewOnly, kmsUrl — retired with that line; v2 is
configured by flags and environment):
--dbnames the one store; grafted, cloud opens the same file under its data directory.--init-dataseeds on boot, new-only, with${VAR}substituted from the environment (iammain.go:88).- Bootstrap fails closed: the operator provisioning routes authenticate a service token from the first non-empty of
HANZO_API_KEY/KMS_SERVICE_TOKEN/IAM_SERVICE_TOKEN; unset means no bootstrap (internal/bootstrap/bootstrap.go:11-16).
The Entities
Every entity is addressed (owner, name) — the owner is the organization, and
that pair is the tenancy key on every row: organizations, users, applications,
tokens, sessions, signing certs, identity providers, roles and permissions.
(Earlier revisions described the schema as XORM auto-migration over SQL/MySQL
and listed a transaction table; all three belonged to the retired v1 line —
the store is hanzoai/orm over the one SQLite file above, and there is no
transaction entity.)
Secrets Management
IAM integrates with Hanzo KMS (HIP-27) for secret resolution. Configuration files and init_data.json use ${VARIABLE} placeholders:
{
"clientSecret": "${IAM_APP_HANZO_CLIENT_SECRET}"
}The placeholders resolve from the process environment at seed time (iam
internal/seed/seed.go:54-56); the environment itself is KMS-synced by the
deployment, so client secrets and signing keys never appear in Git, Docker
images, or config files. (The v1 line fetched from a kmsUrl at startup via
resolveSecrets(); v2 carries no runtime KMS client — the sync happens outside
the process.)
Standards Compliance
Standards Implemented
| 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 signing |
Custom Login UI
The hanzo/id repository provides a forkable, white-label Next.js login UI that serves as the frontend for all identity domains. It includes:
- OIDC discovery rewriting: serves
.well-knownhost-relative to the tenant domain - Multi-tenant detection: hostname-based tenant resolution (per-brand origin)
- PKCE support: built-in
S256code challenge generation and verification - White-label forkable: fork to
luxfi/id,zoofdn/id, etc. for org-specific branding
SDK Compliance
The 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 |
No Backward Compatibility
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.
The Capability Contract
What HIP-0139 §6 asks of every capability, answered for iam:
- Addresses. Everything is under
/v1/iam, plus the two families a protocol fixes at other roots:/.well-known/*(RFC 8615 — OIDC discovery, OAuth server metadata, JWKS) and/login/oauth/*, the browser authorize surface the/v1/iam/oauth/authorize302 targets (cloudmanifest/apps.go:70). Every operation is typed through IAM's own op registry, which the graft composes rather than wraps (apps/iam/iam.go:19-27). - Tenancy. IAM is the issuer, so it is the one capability whose tenant
does not arrive as another service's claim: the organization is the
ownerhalf of every entity key, resolved from the application the client authenticates as (or, for a direct login, the payload'sapplication+organization, which must match), and each brand emits its own issuer from the request Host. A request that resolves no organization is refused, not defaulted. - Meter. It is free, said in those words: no meter, no debit through any
plane (
cloudplugin/iam/main.go,Price: cloud.Free). Identity is what the paid planes charge AGAINST, not a thing charged for. - Events. It publishes none — a customer's webhooks receive nothing from
iam. Authentication events go to the audit log (below), not the bus. - Observability. Nothing beyond the request span every route already
gets; the audit trail of authentication events is IAM's own store, read
through
/v1/iam/audit-logs. - Stage.
ga. - Upstreams. None survive in HEAD. The v1 line was a fork (Beego/xorm
lineage, retired to
hanzoai/iam-v1); v2 is the clean-room rewrite this HIP's opening paragraph describes, andcloud's graph carries no v1 module (apps/iam/iam.go:8-13). - Attacker. The Security Considerations below are that analysis: the wrong implementation here is an estate-wide credential mint.
Security Considerations
Authentication Security
- PKCE required: All public clients (SPAs, mobile apps) MUST use PKCE (RFC 7636) with S256 challenge method. Authorization code interception is the most common OAuth attack vector; PKCE eliminates it.
- Token rotation: Refresh tokens are rotated on use. The previous refresh token is invalidated when a new one is issued. This limits the window of a leaked refresh token.
- Password hashing: argon2id with per-org salt configuration. argon2id is the winner of the Password Hashing Competition and is resistant to both GPU and side-channel attacks.
- WebAuthn: Enabled on all applications for phishing-resistant second-factor authentication.
Session Security
- Session lifetime: the portal session TTL is 14 days, matching the refresh window (
iaminternal/sessions/resolve.go:17-19). Logout revokes the sid server-side AND expires the cookie, so a copy of the cookie taken before logout does not still resolve. - Secure cookies: sessions use HttpOnly, SameSite=Lax cookies (
internal/oidc/challenge.go:124,internal/sessions/cookie.go). - Store-backed sessions: sessions are rows in the one identity store. (The v1 line held them in a KV side-store; there is no session cache beside
iam.dbany more.)
Network Security
- TLS everywhere: Traefik terminates TLS with Let's Encrypt certificates. HTTP is redirected to HTTPS. No plaintext traffic.
- CORS: only a registered browser client's origin is admitted; a reverse proxy MUST NOT append CORS headers of its own beside it (
iaminternal/cors/cors.go). - Signin throttle: failed signins are throttled per organization or per application —
failedSigninLimitandfailedSigninFrozenTime, clamped to safe bounds before persistence; zero inherits the application default (pkg/schema/organization.go:84-88). - Health endpoint isolation: the liveness probe is unauthenticated (required for load balancer probes) but returns only a boolean status, leaking no internal state.
Operational Security
- No seeded admin: init_data.json seeds no user at all (
iaminternal/seed/seed.go:28-29), so there is no default admin password to rotate. Admin accounts arrive only through the operator bootstrap upsert, under the service token. - Audit logging: authentication events are rows in IAM's own store, read back through
/v1/iam/audit-logs.
Authentication vs Authorization (AuthN vs AuthZ)
IAM handles both authentication (identity verification) and authorization (access control), but they are distinct concerns:
Authentication (AuthN) — "Who are you?"
- OAuth 2.0 flows (authorization code + PKCE, client credentials, device code)
- Password login with argon2id hashing
- Social login (GitHub, Google, etc.) via identity providers
- WebAuthn / FIDO2 for phishing-resistant MFA
- Session management (store-backed; see Session Security)
Authorization (AuthZ) — "What can you do?"
- OAuth scopes: Applications request scopes (openid, profile, email, custom). IAM validates requested scopes against the application's allowed scope set and returns
invalid_scopeper RFC 6749 §4.1.2.1 if the client requests scopes not configured for its application. - RBAC roles and permissions: IAM supports role-based access control. Roles are collections of permissions; users are assigned roles per-organization. The
permissionandroletables enforce this. - Organization isolation: Users can be members of multiple organizations (hanzo, lux, zoo, pars, adnexus) but each session is scoped to one organization context. Cross-org access requires switching context.
- Application-level isolation: Each OAuth application has its own client credentials, redirect URIs, grant types, and scopes. A token issued for
app-consolecannot be used atapp-commerce(differentaudclaim). - Admin vs normal user: The
isAdminflag on the user entity grants full API access within the organization. Non-admin users are restricted to self-service operations. - Spend is not a claim: whether a caller may spend is the finance ledger's answer, computed by the one predicate in
hanzoai/cloud(spend.go) — never a balance field read off a token IAM signed.
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.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | iam at its own prefix | 150 operations |
| CLI | hanzo iam … | 150 of 150 |
| SDK | IamApi in every published client | 135 of 150 — the clients are generated at their own release |
| MCP | tool iam on https://api.hanzo.ai/v1/mcp | 75 operations, 21 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/iam/keys, operation get_iam_keys:
hanzo iam keys listimport { Configuration, IamApi } from 'hanzoai';
const api = new IamApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getIamKeys();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import IamApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = IamApi(client).get_iam_keys()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.IamAPI.GetIamKeys(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, iam_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = iam_api::get_iam_keys(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.IamApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new IamApi(client).getIamKeys();curl https://api.hanzo.ai/v1/iam/keys \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches iam through the iam tool, which names its 75 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list__well_known_jwks"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/iam/.well-known/jwks | Publishes the public keys that verify the tokens issued here — the one URL you point a service at so it can check a token itself, offline, without… |
GET /v1/iam/.well-known/oauth-authorization-server | Returns the OpenID Connect discovery document — the one URL you point a standards-compliant client at so it can find every other endpoint on its own,… |
GET /v1/iam/.well-known/openid-configuration | Returns the OpenID Connect discovery document — the one URL you point a standards-compliant client at so it can find every other endpoint on its own,… |
GET /v1/iam/account | Returns the signed-in person's own account and the organization they belong to — what a console reads to draw the account menu. |
PUT /v1/iam/account | Saves the calling person's own profile — the name they are shown by, their picture, a line about themselves and a link. |
POST /v1/iam/admin/applications/upsert | Creates an application or updates it in place, so a deployment can declare the applications it needs and run the same declaration on every… |
POST /v1/iam/admin/provision | Sets up an account on someone's behalf — the same onboarding a person gets themselves, driven by one of your own services instead of by them. |
POST /v1/iam/admin/users/upsert | Creates a person or updates them in place, so a deployment can declare the accounts it needs and re-run that declaration safely. |
GET /v1/iam/applications/{owner}/{name} | Returns one application: its sign-in methods, its allowed redirect URIs and the client credentials your integration authenticates with. |
PUT /v1/iam/applications/{owner}/{name} | Changes an application's display, its sign-in methods and the redirect URIs it may return to — the call that makes login work from a new host. |
DELETE /v1/iam/applications/{owner}/{name} | Removes an application. |
GET /v1/iam/applications | Returns the applications in one organization, newest first — each product or site your people sign in to, with the sign-in methods and redirect URIs… |
POST /v1/iam/applications | Registers an application in your organization — one product or site your people sign in to, with its own client credentials, sign-in methods and… |
POST /v1/iam/assume | Steps a platform operator into an organization: it returns their own access token re-scoped to that tenant, so they see what the tenant sees. |
GET /v1/iam/audit-logs/{owner}/{name} | Returns one audit entry in full: the action, the person or key behind it, and the request it came in on. |
PUT /v1/iam/audit-logs/{owner}/{name} | Corrects an audit entry. |
DELETE /v1/iam/audit-logs/{owner}/{name} | Removes an audit entry. |
GET /v1/iam/audit-logs | Returns your organization's audit trail, newest first — who did what, when, and from where. |
POST /v1/iam/audit-logs | Records an audit entry, so activity from your own systems lands in the same trail as everything the Hanzo Cloud records for you. |
GET /v1/iam/auth/application | Returns everything a login screen needs to draw itself for one application: its branding, and each sign-in method it offers with the provider details… |
GET /v1/iam/auth/methods | Returns the sign-in methods one application actually has switched on, so a login screen can render the right buttons for it without you hard-coding a… |
GET /v1/iam/certs/{owner}/{name} | Returns one signing certificate — its algorithm, its validity window and its public half. |
PUT /v1/iam/certs/{owner}/{name} | Changes a signing certificate's settings. |
DELETE /v1/iam/certs/{owner}/{name} | Removes a signing certificate. |
GET /v1/iam/certs | Returns your organization's signing certificates, newest first — the keys the tokens your applications verify are signed with. |
POST /v1/iam/certs | Adds a signing certificate your applications can verify tokens against — the call you make to stage the next one before a rotation. |
GET /v1/iam/consent | Returns the calling person's own privacy and communication choices. |
PUT /v1/iam/consent | Records the calling person's privacy and communication choices. |
POST /v1/iam/delete-membership | Takes away a person's or an application's right to act in an organization. |
GET /v1/iam/invitations/{owner}/{name} | Returns one invitation: who it is for, what it grants on acceptance, and when it expires. |
PUT /v1/iam/invitations/{owner}/{name} | Changes an invitation's terms — the role it grants, how many may redeem it, or when it expires. |
DELETE /v1/iam/invitations/{owner}/{name} | Withdraws an invitation. |
GET /v1/iam/invitations | Returns your organization's invitations, newest first — who has been asked to join, on what terms, and how many seats each invitation still has left. |
POST /v1/iam/invitations | Issues an invitation to join your organization — the code or link a new member redeems, with the role they arrive holding and the date it stops… |
GET /v1/iam/keys/{owner}/{name} | Returns one API key: what it is called, what it may reach, and when it was issued. |
PUT /v1/iam/keys/{owner}/{name} | Changes what a key is called or what it may reach. |
DELETE /v1/iam/keys/{owner}/{name} | Revokes an API key. |
GET /v1/iam/keys/org | Resolve a PUBLISHABLE key to the organization that owns it |
GET /v1/iam/keys/principal | Resolve a SECRET key to the principal it authenticates |
GET /v1/iam/keys | Returns your organization's API keys, newest first — what each is called, what it may reach, and its publishable half. |
POST /v1/iam/keys | Issues an API key. |
POST /v1/iam/link | Starts connecting another sign-in identity to the account you are already signed in as. |
GET /v1/iam/linked-accounts | Returns the sign-in identities linked to the calling person's account — every provider they can currently sign in with. |
POST /v1/iam/login | Signs a person in with the credential they typed, and — when the request is part of an OAuth flow — hands back the one-time code that finishes it. |
GET /v1/iam/memberships | Answers either question about who belongs where: which organizations one person can act in, or who can act in one organization. |
POST /v1/iam/memberships | Lets a person or an application act in an organization. |
POST /v1/iam/mfa/preferred | Picks which second factor an account is asked for first when it has more than one. |
POST /v1/iam/mfa/setup/enable | Finishes the enrolment: from here the account's sign-ins ask for this factor. |
POST /v1/iam/mfa/setup/initiate | Starts enrolling a factor and hands over whatever the person needs to prove they hold it: app a fresh secret and the otpauth:// URL to render as a QR… |
DELETE /v1/iam/mfa | Turns a factor off, so sign-in stops asking for it. |
GET /v1/iam/oauth/authorize | Starts a sign-in — the address you send a browser to, and the beginning of every OAuth and OpenID Connect flow. |
POST /v1/iam/oauth/authorize | Starts a sign-in — the address you send a browser to, and the beginning of every OAuth and OpenID Connect flow. |
GET /v1/iam/oauth/callback | Completes the round-trip: it resolves and burns the single-use transaction (checking expiry + browser binding), exchanges and verifies the IdP… |
POST /v1/iam/oauth/device/info | Answers "what am I approving?" for a pending device code. |
POST /v1/iam/oauth/device | Starts a sign-in on a device with no browser and no keyboard — a TV, a CLI, a headless box. |
POST /v1/iam/oauth/federation/mfa | Completes a sign-in that came in through another identity provider and still owes a second factor. |
POST /v1/iam/oauth/introspect | Answers whether an access token is still good, and what it is good for — the check a resource server of yours makes before honouring a token it did… |
GET /v1/iam/oauth/logout | Ends a sign-in and sends the browser somewhere sensible. |
POST /v1/iam/oauth/logout | Ends a sign-in and sends the browser somewhere sensible. |
POST /v1/iam/oauth/revoke | Retires a token before it expires — what you call when someone signs out or a credential may have leaked. |
POST /v1/iam/oauth/token | Exchanges what your application is holding for the tokens it needs — the one-time code from a finished sign-in, a refresh token, or your own client… |
GET /v1/iam/oauth/userinfo | Returns the profile claims for whoever the access token belongs to — the standard OpenID Connect way to find out who is calling you without your… |
POST /v1/iam/oauth/userinfo | Returns the profile claims for whoever the access token belongs to — the standard OpenID Connect way to find out who is calling you without your… |
POST /v1/iam/onboard | Finishes setting up the account of whoever is calling — it creates their organization if they have none and puts them in it, so a person who has just… |
GET /v1/iam/organizations/{owner}/{name} | Returns one organization: its display, its defaults and the sign-in rules everyone in it inherits. |
PUT /v1/iam/organizations/{owner}/{name} | Changes an organization's display, its defaults and the sign-in rules everyone in it inherits. |
DELETE /v1/iam/organizations/{owner}/{name} | Removes an organization and everything named inside it. |
POST /v1/iam/organizations/avatar | Changes how an organization appears across Hanzo: the square mark beside its name, as an uploaded image or as a single emoji. |
GET /v1/iam/organizations | Returns the organizations you can act in, the ones you belong to first and the rest after, newest first, narrowed by an optional query against the… |
POST /v1/iam/organizations | Makes a new organization — the account your users, applications, roles, projects and workspaces are all named inside. |
PUT /v1/iam/password | Replaces the calling person's password. |
GET /v1/iam/permissions/{owner}/{name} | Returns one permission: who it grants to, what it allows, and the resources it covers. |
PUT /v1/iam/permissions/{owner}/{name} | Changes who a permission grants to, what it allows, or the resources it covers. |
DELETE /v1/iam/permissions/{owner}/{name} | Revokes a permission. |
GET /v1/iam/permissions | Returns the permissions in one organization, newest first — each one a grant saying which people or roles may do what, and to which resources. |
POST /v1/iam/permissions | Grants a permission — the call that gives a person or a role the ability to do something. |
POST /v1/iam/preferences | Saves the calling person's own settings and returns the full set afterwards. |
GET /v1/iam/projects/{owner}/{name} | Returns one project: what it is called and how it is set up. |
PUT /v1/iam/projects/{owner}/{name} | Changes a project's settings. |
DELETE /v1/iam/projects/{owner}/{name} | Removes a project. |
GET /v1/iam/projects | Returns your organization's projects, newest first — the scope people pick between when their work is separated by product or client rather than by… |
POST /v1/iam/projects | Makes a project inside your organization — the scope people pick between when their work is separated by product or client rather than by team. |
GET /v1/iam/providers/{owner}/{name} | Returns one provider: what it connects to and how it is configured. |
PUT /v1/iam/providers/{owner}/{name} | Changes a provider's settings or rotates the credentials it holds. |
DELETE /v1/iam/providers/{owner}/{name} | Removes a provider. |
GET /v1/iam/providers | Returns your organization's providers, newest first — the identity providers your people sign in with, and the senders and connectors your… |
POST /v1/iam/providers | Adds an identity provider your people can sign in with, or a service your applications send through — a social or enterprise login, an email or SMS… |
GET /v1/iam/registry/jwks | Publishes the public key your registry uses to verify the tokens issued above — the one URL to configure so the registry trusts logins without… |
GET /v1/iam/registry/token | Signs a container client in to your registry. |
POST /v1/iam/registry/token | Signs a container client in to your registry. |
POST /v1/iam/release | Steps a platform operator back out: it returns their own access token with no organization assumed, which is the credential they had before they… |
GET /v1/iam/roles/{owner}/{name} | Returns one role: who is in it, and the roles it includes. |
PUT /v1/iam/roles/{owner}/{name} | Changes who is in a role, or which roles it includes. |
DELETE /v1/iam/roles/{owner}/{name} | Removes a role. |
GET /v1/iam/roles | Returns your organization's roles, newest first — each a named group of people that permissions are granted to. |
POST /v1/iam/roles | Makes a role — a named group of people that permissions are granted to. |
GET /v1/iam/scim/v2/ResourceTypes/{name} | Returns one provisionable record kind in full. |
GET /v1/iam/scim/v2/ResourceTypes | Returns the kinds of record this directory provisions and the address of each, so your identity provider discovers them rather than having them… |
GET /v1/iam/scim/v2/Schemas/{id} | Returns one attribute definition in full. |
GET /v1/iam/scim/v2/Schemas | Returns the attribute definitions this directory understands, so your identity provider knows which fields it may send and what they mean before it… |
GET /v1/iam/scim/v2/ServiceProviderConfig | Tells your identity provider which parts of SCIM this directory supports, so it configures itself instead of you filling in a form. |
GET /v1/iam/scim/v2/Users/{owner}/{name} | Returns one person in the standard SCIM shape. |
PUT /v1/iam/scim/v2/Users/{owner}/{name} | Overwrites a person's SCIM attributes with what your identity provider sends — how a change made there lands here. |
PATCH /v1/iam/scim/v2/Users/{owner}/{name} | Applies a partial change from your identity provider — one attribute moved, not the whole record resent. |
DELETE /v1/iam/scim/v2/Users/{owner}/{name} | Deprovisions a person — how removing someone in your identity provider removes their access here. |
GET /v1/iam/scim/v2/Users | Returns the people in your organization to your identity provider, in the standard SCIM shape, so an IdP can reconcile its directory against ours. |
POST /v1/iam/scim/v2/Users | Provisions a person from your identity provider — how a new hire gets an account here automatically when they are added over there. |
POST /v1/iam/service-accounts/{name}/keys | Serves POST /v1/iam/service-accounts/:name/keys: mint a fresh key, invalidating the prior one, and return the new raw secret exactly once. |
DELETE /v1/iam/service-accounts/{name} | Serves DELETE /v1/iam/service-accounts/:name. |
GET /v1/iam/service-accounts | Returns your organization's service accounts — what each is called and when it was created. |
POST /v1/iam/service-accounts | Makes a service account — an identity for a program rather than a person, for a script, a bot or a deployment that has to authenticate on its own. |
GET /v1/iam/sessions/{owner}/{name}/{application} | Returns one person's session in one application — when it began and which browsers or devices are still carrying it. |
PUT /v1/iam/sessions/{owner}/{name}/{application} | Replaces the set of browsers a session covers — signing out the ones you leave off while the session itself stays live. |
DELETE /v1/iam/sessions/{owner}/{name}/{application} | Signs a person out of one application — the session ends and every browser carrying it stops being authenticated. |
GET /v1/iam/sessions | Returns who is currently signed in to your organization, newest first, and can be narrowed to one person or one application. |
POST /v1/iam/sessions | Records a sign-in. |
POST /v1/iam/signin | Completes a sign-in: it exchanges the one-time code your application was handed at the end of the login flow for a live session, and returns the… |
POST /v1/iam/signup | Creates an account from the sign-up form and applies the application's own sign-up rules — whether self-service registration is open at all, and… |
GET /v1/iam/tokens/{owner}/{name} | Returns one access token: who and what it was issued to, and when it expires. |
PUT /v1/iam/tokens/{owner}/{name} | Changes an access token's scope or expiry. |
DELETE /v1/iam/tokens/{owner}/{name} | Revokes an access token. |
POST /v1/iam/tokens/issue | Mints an access token for the ?id=<owner>/<name> target user (optional ?aud= resource, RFC 8707), issued by the authenticated + allow-listed… |
GET /v1/iam/tokens | Returns the access tokens issued in your organization, newest first, and can be narrowed to one organization. |
POST /v1/iam/tokens | Records an access token — the credential an application or integration presents on a caller's behalf. |
POST /v1/iam/unlink | Disconnects one sign-in identity from an account, so that provider can no longer be used to sign in as that person. |
POST /v1/iam/users/{owner}/{name}/keys | (re)generates the target user's key of the requested TYPE and returns it once, over the shared authorizeMinter + mintTarget seam. |
DELETE /v1/iam/users/{owner}/{name}/keys | Clears the target user's key of the requested TYPE (immediate revoke). |
GET /v1/iam/users/{owner}/{name} | Returns one person in your organization, addressed by their username or by their email address. |
PUT /v1/iam/users/{owner}/{name} | Changes a person's profile, their roles, or the credentials they sign in with. |
DELETE /v1/iam/users/{owner}/{name} | Removes a person from your organization. |
GET /v1/iam/users | Returns a page of the people in your organization, with the total so you can page through the rest. |
POST /v1/iam/users | Adds a person to your organization. |
POST /v1/iam/verification-codes | Validates the request and asks otp to get a code to the person. |
GET /v1/iam/web3/nonce | Starts a wallet sign-in: it returns a one-time challenge for the wallet to sign. |
POST /v1/iam/web3/verify | Completes a wallet sign-in: it verifies the signed challenge and, if it holds, signs the wallet's owner in. |
GET /v1/iam/webauthn-credentials/{owner}/{name} | Returns one passkey or security key: whose it is, what device it lives on, and when it was registered. |
PUT /v1/iam/webauthn-credentials/{owner}/{name} | Renames a registered passkey or security key, so a person can tell their devices apart. |
DELETE /v1/iam/webauthn-credentials/{owner}/{name} | Removes a passkey or security key — what you call when a device is lost. |
GET /v1/iam/webauthn-credentials | Returns the passkeys and security keys registered to one person, newest first — which device each lives on and when it was registered. |
POST /v1/iam/webauthn-credentials | Registers a passkey or security key for a person, so they can sign in with their device instead of a password. |
GET /v1/iam/webauthn/signin/begin | Starts a passkey sign-in: it returns the challenge the person's authenticator signs. |
POST /v1/iam/webauthn/signin/finish | Verifies the signed challenge and signs the person in. |
GET /v1/iam/webauthn/signup/begin | Starts enrolling a passkey for the signed-in person: it returns the options their browser hands to the authenticator. |
POST /v1/iam/webauthn/signup/finish | Verifies the newly created passkey and stores it, so the person can sign in with their device from then on. |
GET /v1/iam/whoami | Tells you who the current caller is — the lightweight check a page makes on load to decide whether to render signed-in or signed-out. |
GET /v1/iam/workspaces/{owner}/{name} | Returns one workspace: what it is called and how it is set up. |
PUT /v1/iam/workspaces/{owner}/{name} | Changes a workspace's settings. |
DELETE /v1/iam/workspaces/{owner}/{name} | Removes a workspace. |
GET /v1/iam/workspaces | Returns your organization's workspaces, newest first — the scope a team works in, alongside projects rather than instead of them. |
POST /v1/iam/workspaces | Makes a workspace inside your organization — the scope a team works in, alongside projects rather than instead of them. |
How is this guide?