Authentication
The one way to authenticate against Hanzo IAM — canonical OIDC endpoints, the @hanzo/iam SDK, and per-framework integration.
Authentication
Hanzo uses one identity provider per brand, a standards-compliant OIDC provider. You integrate through exactly one library — @hanzo/iam — against one set of endpoints. There is no second way.
| 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 SDK is brand-agnostic. Select the brand with serverUrl; nothing else changes.
OIDC Endpoints
These /v1/iam/oauth/* paths are the only endpoints. There is no /oauth/*, no /api/login/*, no /api/ prefix. They are relative to the brand serverUrl (shown here for Hanzo).
| Purpose | Path |
|---|---|
| Discovery | https://iam.hanzo.ai/.well-known/openid-configuration |
| Authorize | https://iam.hanzo.ai/v1/iam/oauth/authorize |
| Token | https://iam.hanzo.ai/v1/iam/oauth/token |
| UserInfo | https://iam.hanzo.ai/v1/iam/oauth/userinfo |
| JWKS | https://iam.hanzo.ai/v1/iam/.well-known/jwks |
| Logout | https://iam.hanzo.ai/v1/iam/oauth/logout |
Every flow uses PKCE S256, client_secret_basic for confidential clients, and scopes openid profile email. The flow is Authorization Code + PKCE; implicit grant is not supported.
You never write these paths yourself. @hanzo/iam holds them in one place and every entry point reads from there, so a misconfiguration can't drift onto a wrong URL.
The SDK: @hanzo/iam
Install once, import the entry point that matches your runtime:
pnpm add @hanzo/iam| Subpath | Surface | Use |
|---|---|---|
@hanzo/iam | IamClient, types | conditional Node/browser entry |
@hanzo/iam/server | validateToken, getServerSession | server-side JWT validation + session |
@hanzo/iam/betterauth | iamProvider | better-auth apps |
@hanzo/iam/nextauth | IamProvider | NextAuth / Auth.js apps |
@hanzo/iam/react | hooks, OrgProjectSwitcher | React SPAs |
@hanzo/iam/browser | IAM (PKCE client) | browser PKCE login |
@hanzo/iam/passport | createIamPassportStrategy | Node / Express + Passport |
Server-Side Token Validation
Any backend validates a bearer token like this:
import { validateToken } from '@hanzo/iam/server'
const result = await validateToken(accessToken, {
serverUrl: process.env.IAM_ENDPOINT!, // https://iam.hanzo.ai
clientId: process.env.IAM_CLIENT_ID!,
})
if (!result.ok) {
// result.reason explains why
return unauthorized()
}
const { userId, email, owner } = result
// owner is the org slug — scope every query to itvalidateToken discovers the JWKS, caches the key set per issuer, and verifies the signature plus iss, aud, and exp. The owner claim is the organization; all multi-tenant data access must be scoped to it.
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 authorize, token, and userinfo endpoints — it never relies on discovery resolution. Register the redirect URI https://<app-host>/api/auth/oauth2/callback/hanzo.
Do not use raw genericOAuth({ discoveryUrl }). Discovery resolves to the IAM SPA catch-all HTML and the client dies with content-type must be application/json. Always use iamProvider().
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'],
}),
],
})Register the redirect URI https://<app-host>/api/auth/callback/iam.
React SPA (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 login
const token = await iam.handleCallback() // on /auth/callback
const access = await iam.getValidAccessToken() // auto-refreshesWith React context and hooks:
import { IamProvider, useIam } from '@hanzo/iam/react'
function Root() {
return (
<IamProvider serverUrl="https://iam.hanzo.ai" clientId="hanzo-myspa">
<App />
</IamProvider>
)
}
function Profile() {
const { user, signIn, signOut } = useIam()
return user ? <button onClick={signOut}>{user.email}</button> : <button onClick={signIn}>Sign in</button>
}The browser client uses PKCE S256, holds tokens in memory, and refreshes silently. Register the redirect URI https://<app-host>/auth/callback. Never store access tokens in localStorage.
Node / Express + Passport
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',
}))Register the redirect URI https://<app-host>/v1/sso/oidc/callback.
App Registration
Every app is registered once per brand in IAM before it can authenticate:
client_idis named<org>-<app>(e.g.hanzo-console,lux-wallet). One ID per app per brand.redirectUrismust contain the exact callback the framework above uses. There is no wildcard.- Client secret is KMS-managed. Never in Git, env files, or images.
- Superuser convention:
z@<domain>/Ilove<App>2026!!.
Forbidden
These break in production and must never be used:
- Hand-rolled OAuth, PKCE, or JWKS validation — use the SDK.
- Any per-app OIDC path string — the path lives in the SDK; pass only
serverUrl. - Legacy paths (
/oauth/*,/api/login/oauth/*, anything/api/-prefixed).
Gotcha: the SPA catch-all
IAM serves a 200 text/html page for any unregistered path. A wrong path is not a 404 — it is a 200 with an HTML body, which then fails downstream as a confusing content-type error. This is the single most common integration mistake. Use @hanzo/iam and the exact /v1/iam/* paths; never let discovery resolution drift.
Multi-Tenancy
The JWT owner claim is the organization slug. Scope every database query to it.
Behind the API gateway (api.hanzo.ai), the gateway validates the token, strips any client-supplied identity headers, and re-injects X-Org-Id (from owner), X-User-Id (from sub), and X-User-Email (from email). Services behind the gateway trust those headers and do not re-parse the JWT. A service that talks to IAM directly uses validateToken as shown above. These are the only two patterns.
Security
- PKCE
S256is mandatory for all flows. Implicit grant is not supported. - Validate the signature against JWKS and check
iss,aud, andexp—validateTokendoes this. - Tokens live in memory or httpOnly cookies, never
localStorage. - Confidential-client secrets come from KMS and use
client_secret_basicover TLS. - All endpoints enforce TLS; plaintext is rejected.
How is this guide?
Last updated on