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 provisioningOrganizations = 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.
| Legacy | IAM |
|---|---|
wl-tenants._id (ObjectId) | organization_id (string) |
tenantId field on records | organization_id field on records |
client_id + client_secret per tenant | OAuth2 client credentials per org |
JWT signed with client_secret | JWT signed with IAM RSA key, verified via JWKS |
checkTenatJWT middleware | Standard 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
└── clientsEvery 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.idTokenWebAuthn / 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
}| Claim | Description | Gateway Header |
|---|---|---|
sub | User ID | X-IAM-User-Id |
owner | Organization ID (tenant) | X-IAM-Org |
roles | RBAC roles in this org | X-IAM-Roles |
scope | OAuth2 scopes granted | X-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
| Role | Permissions |
|---|---|
investor | View portfolio, place orders, view statements |
trader | All investor permissions + advanced order types, margin |
admin | All trader permissions + user management, compliance review |
operator | All admin permissions + system configuration, reporting |
api-key | Scoped 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 Claim | HTTP Header | Example Value |
|---|---|---|
sub | X-IAM-User-Id | usr_a1b2c3d4e5f6 |
owner | X-IAM-Org | org_liquidity |
roles | X-IAM-Roles | trader,investor |
scope | X-IAM-Scopes | trading,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)
| Export | Use Case |
|---|---|
IamClient | Server-side JWT validation, user management |
BrowserIamSdk | SPA 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.
| Step | Action |
|---|---|
| 1 | Replace checkTenatJWT middleware with OrgMiddleware (reads X-IAM-Org) |
| 2 | Replace tenantId field with organization_id in all database models |
| 3 | Replace client_id / client_secret auth with OAuth2 client credentials |
| 4 | Remove ACCESS_TOKEN_SECRET env var — JWTs are now validated via JWKS |
| 5 | Replace 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
| Environment | IAM URL | Gateway URL |
|---|---|---|
| Development | https://iam.dev.liquidity.io | https://api.dev.liquidity.io |
| Next | https://iam.next.liquidity.io | https://api.next.liquidity.io |
| Production | https://iam.liquidity.io | https://api.liquidity.io |