Hanzo

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.

BrandIAM origin (serverUrl)Login UI
Hanzohttps://iam.hanzo.aihanzo.id
Luxhttps://lux.idlux.id
Zoohttps://zoo.idzoo.id
Bootnodehttps://id.bootno.deid.bootno.de
Parshttps://pars.idpars.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).

PurposePath
Discoveryhttps://iam.hanzo.ai/.well-known/openid-configuration
Authorizehttps://iam.hanzo.ai/v1/iam/oauth/authorize
Tokenhttps://iam.hanzo.ai/v1/iam/oauth/token
UserInfohttps://iam.hanzo.ai/v1/iam/oauth/userinfo
JWKShttps://iam.hanzo.ai/v1/iam/.well-known/jwks
Logouthttps://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
SubpathSurfaceUse
@hanzo/iamIamClient, typesconditional Node/browser entry
@hanzo/iam/servervalidateToken, getServerSessionserver-side JWT validation + session
@hanzo/iam/betterauthiamProviderbetter-auth apps
@hanzo/iam/nextauthIamProviderNextAuth / Auth.js apps
@hanzo/iam/reacthooks, OrgProjectSwitcherReact SPAs
@hanzo/iam/browserIAM (PKCE client)browser PKCE login
@hanzo/iam/passportcreateIamPassportStrategyNode / Express + Passport

Server-Side Token Validation

Any backend 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 it

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

With 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_id is named <org>-<app> (e.g. hanzo-console, lux-wallet). One ID per app per brand.
  • redirectUris must 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:

  • Raw better-auth genericOAuth({ discoveryUrl }) — resolves to the SPA catch-all HTML.
  • 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 S256 is mandatory for all flows. Implicit grant is not supported.
  • Validate the signature against JWKS and check iss, aud, and expvalidateToken does this.
  • Tokens live in memory or httpOnly cookies, never localStorage.
  • Confidential-client secrets come from KMS and use client_secret_basic over TLS.
  • All endpoints enforce TLS; plaintext is rejected.

How is this guide?

Last updated on

On this page