Liquidity Docs

IAM Multi-Tenancy

IAM replaces legacy auth — OAuth2/OIDC, organization-scoped RBAC, JWKS validation, Gateway claim propagation

Liquid IAM (id.liquidity.io) is the identity provider for the Liquidity.io platform. It replaces the legacy wl-tenants / tokens / auth authentication system with a standards-based OAuth2/OIDC identity layer.

Every service authenticates via IAM-issued JWTs. Every database query is scoped to an organization_id extracted from the JWT owner claim. There is one way to do auth on this platform.

Architecture

Browser / API client

        │  Authorization: Bearer <JWT>

┌─────────────────────┐
│   API Gateway       │  ← JWT validation via JWKS
│   (KrakenD)         │  ← Claims → X-IAM-* headers
└──────────┬──────────┘
           │  X-IAM-User-Id: <sub>
           │  X-IAM-Org: <owner>
           │  X-IAM-Roles: <roles>

┌──────────────────────────────────────┐
│  ATS  │  BD  │  TA  │  Commerce ... │
│  (all services trust X-IAM-* headers │
│   or validate JWT directly)          │
└──────────────────────────────────────┘


   Liquid IAM (id.liquidity.io)
   ├── OAuth2 / OIDC
   ├── SAML / LDAP
   ├── WebAuthn / Passkeys
   ├── 40+ social providers
   ├── RBAC via Casbin
   └── SCIM provisioning

Organizations = Tenants

The legacy system used wl-tenants with a MongoDB ObjectId tenantId. IAM replaces this with organizations. Each tenant (Liquidity, VCC, MLC, or any white-label partner) is an IAM organization.

LegacyIAM
wl-tenants._id (ObjectId)organization_id (string)
tenantId field on recordsorganization_id field on records
client_id + client_secret per tenantOAuth2 client credentials per org
JWT signed with client_secretJWT signed with IAM RSA key, verified via JWKS
checkTenatJWT middlewareStandard OIDC token validation

Organization Structure

Liquid IAM
├── org: liquidity          ← Liquidity.io (primary)
│   ├── users (investors, traders)
│   ├── roles (investor, trader, admin, operator)
│   └── clients (exchange-frontend, mobile-app, api-keys)
├── org: vcc                ← VCC white-label tenant
│   ├── users
│   ├── roles
│   └── clients
└── org: mlc                ← MLC white-label tenant
    ├── users
    ├── roles
    └── clients

Every user belongs to exactly one organization. Cross-org access is not permitted. All data queries MUST include WHERE organization_id = ? scoping.

Authentication Flows

Client Credentials (Service-to-Service)

Used by backend services and BD partner integrations.

const response = await fetch('https://iam.liquidity.io/api/login/oauth/access_token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.IAM_CLIENT_ID,
    client_secret: process.env.IAM_CLIENT_SECRET,
    scope: 'trading market_data reporting',
  }),
});

const { access_token, expires_in } = await response.json();
// access_token is a JWT signed by IAM RSA key
// expires_in is typically 7200 (2 hours)

Authorization Code + PKCE (Browser SPA)

Used by the exchange frontend and admin panel.

import { BrowserIamSdk } from '@hanzo/iam';

const iam = new BrowserIamSdk({
  serverUrl: 'https://iam.liquidity.io',
  clientId: 'exchange-frontend',
  redirectUri: window.location.origin + '/callback',
});

// Redirect to IAM login page
await iam.signIn('code');

// After redirect back, exchange code for tokens
const tokens = await iam.handleCallback();
// tokens.accessToken, tokens.refreshToken, tokens.idToken

WebAuthn / Passkeys

IAM supports passwordless authentication via WebAuthn. Users register a passkey on their device and authenticate with biometrics or a hardware key.

// Registration
await iam.webauthn.register();

// Authentication
const tokens = await iam.webauthn.authenticate();

JWT Structure

IAM-issued JWTs contain the following claims.

{
  "iss": "https://iam.liquidity.io",
  "sub": "usr_a1b2c3d4e5f6",
  "owner": "org_liquidity",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "roles": ["trader", "investor"],
  "scope": "trading market_data",
  "iat": 1711100000,
  "exp": 1711107200
}
ClaimDescriptionGateway Header
subUser IDX-IAM-User-Id
ownerOrganization ID (tenant)X-IAM-Org
rolesRBAC roles in this orgX-IAM-Roles
scopeOAuth2 scopes grantedX-IAM-Scopes

JWKS Validation

All services validate JWTs against IAM's JWKS endpoint. The Gateway does this automatically. Services behind the Gateway can trust the X-IAM-* headers. Services exposed directly (internal mesh, cron jobs) must validate JWTs themselves.

GET https://iam.liquidity.io/.well-known/jwks
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "iam-rsa-2026-03",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

Go Validation

package auth

import (
    "context"
    "fmt"
    "net/http"

    "github.com/liquidityio/iam/sdk"
)

var iamClient = sdk.NewClient("https://iam.liquidity.io")

func ValidateToken(ctx context.Context, token string) (*sdk.Claims, error) {
    claims, err := iamClient.ValidateToken(ctx, token)
    if err != nil {
        return nil, fmt.Errorf("iam: invalid token: %w", err)
    }
    if claims.Owner == "" {
        return nil, fmt.Errorf("iam: missing organization claim")
    }
    return claims, nil
}

// Middleware extracts organization_id for DB scoping
func OrgMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Behind Gateway: trust X-IAM-Org header
        orgID := r.Header.Get("X-IAM-Org")
        if orgID == "" {
            http.Error(w, "missing organization", http.StatusForbidden)
            return
        }
        ctx := context.WithValue(r.Context(), "organization_id", orgID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

TypeScript Validation

import { IamClient } from '@hanzo/iam';

const iam = new IamClient({
  serverUrl: 'https://iam.liquidity.io',
});

// Validate JWT and extract claims
const claims = await iam.validateToken(token);
// claims.sub       → user ID
// claims.owner     → organization ID
// claims.roles     → ["trader", "investor"]

RBAC via Casbin

IAM uses Casbin for role-based access control. Roles are assigned per organization. Permissions are defined as Casbin policies.

Built-in Roles

RolePermissions
investorView portfolio, place orders, view statements
traderAll investor permissions + advanced order types, margin
adminAll trader permissions + user management, compliance review
operatorAll admin permissions + system configuration, reporting
api-keyScoped to specific OAuth2 scopes (machine-to-machine)

Policy Enforcement

// In any service handler
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    orgID := r.Context().Value("organization_id").(string)
    userID := r.Header.Get("X-IAM-User-Id")
    roles := r.Header.Get("X-IAM-Roles") // comma-separated

    // Casbin enforcement happens at Gateway level
    // By the time the request reaches the service, it is authorized
    // Service only needs to scope data to organization_id

    order, err := h.ats.CreateOrder(r.Context(), orgID, req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    json.NewEncoder(w).Encode(order)
}

Gateway Claim Propagation

The API Gateway (KrakenD) validates every inbound JWT and propagates claims as HTTP headers to backend services. Services never parse JWTs themselves when behind the Gateway.

JWT ClaimHTTP HeaderExample Value
subX-IAM-User-Idusr_a1b2c3d4e5f6
ownerX-IAM-Orgorg_liquidity
rolesX-IAM-Rolestrader,investor
scopeX-IAM-Scopestrading,market_data

Gateway Configuration (excerpt)

{
  "endpoint": "/v1/orders",
  "method": "POST",
  "backend": [
    {
      "url_pattern": "/v1/orders",
      "host": ["http://ats.backend.svc.cluster.local:8080"]
    }
  ],
  "extra_config": {
    "auth/validator": {
      "alg": "RS256",
      "jwk_url": "https://iam.liquidity.io/.well-known/jwks",
      "propagate_claims": [
        ["sub", "X-IAM-User-Id"],
        ["owner", "X-IAM-Org"],
        ["roles", "X-IAM-Roles"]
      ]
    }
  }
}

SDK Reference

@hanzo/iam (npm)

ExportUse Case
IamClientServer-side JWT validation, user management
BrowserIamSdkSPA login flows (Authorization Code + PKCE)
useIam()React hook — current user, org, roles
useAuth()React hook — login/logout/refresh
validateToken()Standalone JWT validation function

React Integration

import { useIam, useAuth } from '@hanzo/iam/react';

function TradingDashboard() {
  const { user, organization, roles } = useIam();
  const { logout } = useAuth();

  if (!user) return <LoginPage />;

  return (
    <div>
      <h1>{organization.name} Exchange</h1>
      <p>Welcome, {user.name}</p>
      <p>Roles: {roles.join(', ')}</p>
      {roles.includes('admin') && <AdminPanel />}
      <button onClick={logout}>Sign Out</button>
    </div>
  );
}

Migration from Legacy Auth

Services migrating from the legacy wl-tenants / tokens system.

StepAction
1Replace checkTenatJWT middleware with OrgMiddleware (reads X-IAM-Org)
2Replace tenantId field with organization_id in all database models
3Replace client_id / client_secret auth with OAuth2 client credentials
4Remove ACCESS_TOKEN_SECRET env var — JWTs are now validated via JWKS
5Replace wl-tenants MongoDB collection with IAM organization API

Backward Compatibility

During migration, IAM supports legacy JWT validation by adding the old signing key to its JWKS. This allows services to be migrated incrementally without a hard cutover.

{
  "keys": [
    { "kid": "iam-rsa-2026-03", "kty": "RSA", "alg": "RS256", "..." : "..." },
    { "kid": "legacy-hmac", "kty": "oct", "alg": "HS256", "..." : "..." }
  ]
}

Environments

EnvironmentIAM URLGateway URL
Developmenthttps://iam.dev.liquidity.iohttps://api.dev.liquidity.io
Nexthttps://iam.next.liquidity.iohttps://api.next.liquidity.io
Productionhttps://iam.liquidity.iohttps://api.liquidity.io

On this page