NNO Docs
ArchitectureServices

NNO Multi-Tenant Auth Model

Documentation for NNO Multi-Tenant Auth Model

Date: 2026-03-30 Status: Implemented (P0–P3 RBAC fixes applied) Parent: System Architecture Package: services/iam (NNO global IAM Worker + auth-core) · services/auth (thin per-platform wrapper) · packages/ui-auth


Overview

Neutrino uses Better Auth as its authentication and authorisation framework. Each platform gets a dedicated Auth Worker backed by its own D1 database, providing full data isolation between platforms. The Better Auth Organization plugin maps directly onto NNO's tenant/entity model, and NNO adds a feature-permission layer on top of Better Auth's role system to support fine-grained, feature-scoped access control across tenants.


IAM Service (Global)

Architecture: Two-Service Auth Model

Authentication uses two distinct services:

ServicePackageRole
services/iam@neutrino-io/service-iamGlobal NNO IAM Worker. Hosts auth-core AND NNO-specific routes (/api/nno/session). One instance for the entire platform.
services/auth@neutrino-io/service-authPer-platform thin wrapper. Imports auth-core from @neutrino-io/service-iam/core. Deployed per client platform.

Important: /api/nno/session is available on both per-platform auth workers (services/auth) and the global IAM (services/iam) — it is mounted inside createAuthApp() which both services use. The routes that are IAM-only (not available on per-platform auth workers) are /api/nno/organizations, /api/nno/roles, /api/nno/grants, /api/nno/service-token, /api/nno/bootstrap, /api/nno/apikey, and /api/auth/profile — these are mounted separately in services/iam/src/index.ts.

  • /api/nno/service-token — Issues short-lived JWTs for gateway service-to-service authentication. Validates incoming requests using HMAC constant-time comparison via crypto.subtle. Used by the gateway's ServiceTokenCache to obtain tokens for upstream service calls.
  • /api/nno/bootstrap — NNO platform bootstrap endpoint. Handles initial platform setup operations at the IAM level.
  • /api/nno/apikey — API key management for programmatic access (create, list, revoke NNO API keys).
  • /api/auth/profile — User profile management endpoint (read/update authenticated user profile data).

TEMPLATE — Do NOT deploy directly: services/auth/ in this repository is a deployment template, not a live service. The wrangler.toml contains REPLACE_WITH_* placeholders. During platform onboarding, NNO CLI Service:

  1. Creates a new GitHub repo (nno-platform-\{platform-id\}) from the console template
  2. Copies services/auth/ source into that repo
  3. Substitutes all REPLACE_WITH_* placeholders (platform ID, tenant ID, D1 IDs, etc.)
  4. Deploys via wrangler deploy from the platform repo

Running wrangler deploy directly from services/auth/ in this repo will deploy with literal placeholder strings as the worker name and will overwrite a real client worker if one exists.


Auth Service (Per-Platform Template)

1. Architecture: One Auth Worker Per Platform [Phase 1]

NNO Platform k3m9p2xw7q (AcmeCorp)

├─ Auth Worker:  auth.svc.default.k3m9p2xw7q.nno.app  (CF resource: k3m9p2xw7q-default-auth)
│   └─ D1:      k3m9p2xw7q-default-auth-db
│       ├─ user, session, account, verification
│       ├─ organization, member, invitation     ← maps to tenants
│       ├─ two_factor, api_key
│       ├─ nno_roles, nno_permission_grants     ← NNO extensions
│       └─ audit_authentication, audit_authorization

└─ Console Shell: <name>.app.default.k3m9p2xw7q.nno.app
    VITE_AUTH_API_URL = https://auth.svc.default.k3m9p2xw7q.nno.app

Each platform's auth service is completely independent:

  • No shared user tables between platforms
  • No cross-platform session tokens
  • Platform-specific AUTH_SECRET per deployment
  • Migrations run independently per D1 instance

NNO's own internal services (Registry, Provisioning, CLI Service) authenticate using API keys issued by the NNO IAM service — not through any platform's auth worker.


2. Concept Mapping: Better Auth ↔ NNO [Phase 1]

Better Auth ConceptNNO ConceptNotes
userPlatform userScoped to one platform
organizationTenant or Sub-tenantBetter Auth org = NNO entity
memberEntity membershipUser belongs to one or more tenants
member.roleTenant roleowner / admin / member
user.rolePlatform roleplatform-admin / user
session.activeOrganizationIdActive tenant IDDrives shell context
apiKeyProgrammatic access tokenService-to-service calls

Organisation = Tenant

Better Auth's organization table is used directly as NNO's tenant layer. When NNO Provisioning creates a tenant in the Registry, it also creates a corresponding Better Auth organisation in that platform's auth D1:

// Called by NNO Provisioning after creating entity in Registry
async function createTenantInAuth(
  authServiceUrl: string,
  apiKey: string,
  entity: RegistryEntity
): Promise<void> {
  await fetch(`${authServiceUrl}/api/nno/organizations`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      // Note: the endpoint generates its own NanoID for the org internally.
      // Pass ownerId (the user who will be the org owner), name, and slug.
      name:    entity.name,
      slug:    entity.slug,
      ownerId: entity.ownerId,  // user ID of the owner (looked up from Registry)
      orgType: 'tenant',        // 'nno-operator' | 'tenant' — controls default role permissions seeded
    }),
  });
}

Using the same NanoID in both the Registry and Better Auth ensures consistent tenant identity across systems without a separate ID mapping table.

orgType field: The POST /api/nno/organizations endpoint accepts an orgType parameter ("nno-operator" | "tenant", defaults to "tenant"). After inserting the organization and owner member rows, the handler immediately seeds three nno_roles rows (owner/admin/member) with the appropriate default permission sets for that org type. This ensures every org has a functioning permission configuration from creation. See §4.2 for the permission sets.


3. Role Hierarchy [Phase 1]

NNO has two role scopes: platform-level (who can manage the platform) and tenant-level (what a user can do within a tenant).

3.1 Platform-Level Roles

Set on user.role via the Better Auth Admin plugin:

RoleDescriptionTypical Holder
platform-adminFull access to all tenants, platform settings, billingPlatform owner
userStandard user — access governed by tenant rolesRegular end user

platform-admin is the only role that can:

  • Create/delete tenants
  • Activate/deactivate features
  • View billing information
  • Access the platform admin panel

3.2 Tenant-Level Roles

Set on member.role via the Better Auth Organization plugin:

RoleDescription
ownerFull control of the tenant. Can invite, remove members, change roles
adminCan manage members. Cannot delete the tenant or change billing
memberStandard access. Permissions determined by feature permission grants

A user can have different roles in different tenants (e.g., owner in Tenant A, member in Tenant B).

3.3 Role Precedence

platform-admin
  → bypasses all tenant-level permission checks
  → has implicit access to every feature in every tenant

owner (in active tenant)
  → all feature permissions for that tenant

admin (in active tenant)
  → all feature permissions except platform-level actions

member (in active tenant)
  → only explicitly granted feature permissions

4. Feature Permission Layer [Phase 1]

Better Auth roles (owner, admin, member) are coarse-grained. NNO adds a feature permission layer that maps roles to specific permission keys (e.g., analytics:read, billing:manage) and supports per-user overrides.

4.1 NNO Schema Extensions

Two tables implement the feature permission layer. They are defined in:

  • services/iam/migrations/0002_nno_roles_permissions.sql — applied to the global IAM D1 (for NNO operator permissions)
  • services/auth/migrations/ — the same schema is applied to each per-platform auth D1 during platform provisioning

Two tables are added to the auth D1 alongside Better Auth's tables:

-- Maps tenant roles to feature permission sets
CREATE TABLE nno_roles (
  id          TEXT PRIMARY KEY,
  org_id      TEXT NOT NULL,  -- organization (tenant) ID
  role        TEXT NOT NULL,  -- 'owner' | 'admin' | 'member' | custom
  permissions TEXT NOT NULL,  -- JSON array of permission keys
  created_at  INTEGER NOT NULL,
  updated_at  INTEGER NOT NULL,
  UNIQUE(org_id, role)
);

-- Per-user permission overrides (grants or denials)
CREATE TABLE nno_permission_grants (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
  org_id      TEXT NOT NULL,
  permission  TEXT NOT NULL,  -- e.g. 'analytics:export'
  granted     INTEGER NOT NULL DEFAULT 1,  -- 1 = grant, 0 = deny
  granted_by  TEXT NOT NULL REFERENCES user(id),
  expires_at  INTEGER,        -- NULL = permanent
  created_at  INTEGER NOT NULL
);

CREATE INDEX idx_nno_roles_org ON nno_roles(org_id);
CREATE INDEX idx_nno_grants_user_org ON nno_permission_grants(user_id, org_id);

4.2 Default Role Permission Sets

nno_roles rows are seeded automatically on org creation by POST /api/nno/organizations. The permission set depends on orgType:

NNO Operator Org (orgType: "nno-operator") — full backoffice access:

const NNO_OPERATOR_ROLES = {
  owner:  ['*'],
  admin:  ['zero:access', 'zero:platform-manage', 'zero:tenant-manage',
           'zero:stack-manage', 'billing:read', 'billing:manage',
           'settings:read', 'settings:write'],
  member: ['zero:access',
           'billing:read', 'settings:read'],
};

Standard Customer Tenant Org (orgType: "tenant", default) — billing + settings:

const TENANT_ROLES = {
  owner:  ['*'],
  admin:  ['billing:manage', 'billing:read', 'settings:write', 'settings:read'],
  member: ['billing:read', 'settings:read'],
};

The * wildcard in owner grants all permissions. Per-user overrides are applied on top via nno_permission_grants.

Permission Catalog:

PermissionGatesGranted To
zero:accessEntire Zero backoffice featureNNO operator org: all roles
zero:platform-managePlatform CRUD mutationsNNO operator org: owner, admin
zero:tenant-manageTenant lifecycle mutationsNNO operator org: owner, admin
zero:stack-manageStack activation/deactivation on platformsNNO operator org: owner, admin
billing:readView billing dashboard + invoicesAll org types: all roles
billing:manageBilling mutations (plan change, payment)All org types: owner, admin
settings:readView all settings pagesAll org types: all roles
settings:writeMutate settings (org name, prefs)All org types: owner, admin

Bootstrap migration (services/iam/migrations/0003_seed_nno_operator_roles.sql): Idempotent INSERT OR IGNORE migration that backfills nno_roles for orgs created before the seeding fix was deployed. Identifies the NNO operator org via slug LIKE '%k3m9p2xw7q%'.

4.3 Permission Resolution Algorithm

When building the permissions array for a session:

async function resolvePermissions(
  db: D1Database,
  userId: string,
  orgId: string,
  memberRole: string
): Promise<string[]> {
  // 1. Get role-level permissions for this tenant
  const roleRow = await db
    .prepare('SELECT permissions FROM nno_roles WHERE org_id = ? AND role = ?')
    .bind(orgId, memberRole)
    .first<{ permissions: string }>();

  const rolePermissions: string[] = roleRow
    ? JSON.parse(roleRow.permissions)
    : [];

  // Shortcut: '*' means everything
  if (rolePermissions.includes('*')) return ['*'];

  // 2. Apply per-user grants and denials
  const grants = await db
    .prepare(`
      SELECT permission, granted FROM nno_permission_grants
      WHERE user_id = ? AND org_id = ?
        AND (expires_at IS NULL OR expires_at > ?)
    `)
    .bind(userId, orgId, Date.now())
    .all<{ permission: string; granted: number }>();

  const grantedSet = new Set(rolePermissions);

  for (const row of grants.results) {
    if (row.granted === 1) {
      grantedSet.add(row.permission);    // additional grant
    } else {
      grantedSet.delete(row.permission); // explicit deny
    }
  }

  return [...grantedSet];
}

5. Session Payload [Phase 1]

After authentication, the session endpoint returns a composite payload that the shell uses to populate ShellContext:

5.1 Raw Better Auth Session Response

{
  "session": {
    "id": "sess_abc123",
    "userId": "usr_xyz789",
    "token": "...",
    "expiresAt": "2026-03-01T10:34:00Z",
    "activeOrganizationId": "r8n4t6y1z5",
    "organizationId": "r8n4t6y1z5"
  },
  "user": {
    "id": "usr_xyz789",
    "email": "[email protected]",
    "name": "Alice",
    "role": "user",
    "twoFactorEnabled": false
  }
}

5.2 NNO-Extended Session (from /api/nno/session)

The auth Worker exposes an additional /api/nno/session endpoint that enriches the Better Auth session with NNO-specific fields:

{
  "userId":     "usr_xyz789",
  "email":      "[email protected]",
  "name":       "Alice",
  "platformId": "k3m9p2xw7q",
  "tenantId":   "r8n4t6y1z5",
  "tenantName": "Team Alpha",
  "platformRole": "user",
  "tenantRole":   "admin",
  "permissions":  ["analytics:read", "analytics:export", "billing:read", "settings:write"],
  "availableTenants": [
    { "id": "r8n4t6y1z5", "name": "Team Alpha", "role": "admin" },
    { "id": "w2q5m8n1p7", "name": "Team Beta",  "role": "member" }
  ],
  "sessionToken": "...",
  "expiresAt":    "2026-03-01T10:34:00Z"
}

platformId is injected from the auth Worker's environment variable (set at provisioning time):

# wrangler.toml (NNO-generated, per platform)
[vars]
NNO_PLATFORM_ID = "k3m9p2xw7q"

Note: Both services/auth/wrangler.toml and services/iam use NNO_PLATFORM_ID (with prefix) because those services embed the platform ID directly into session payloads and need a clearly scoped name. services/billing/wrangler.toml intentionally uses PLATFORM_ID (no prefix) — billing is a standalone NNO operator service; the shorter name is correct by design and does not conflict with any per-platform template variable.

5.3 Shell Context Population

// apps/console/src/providers/shell-provider.tsx
const session = await authClient.getSession();
const nnoSession = await fetch('/api/nno/session').then(r => r.json());

const shellContext: ShellContext = {
  platform: { id: nnoSession.platformId },
  tenant:   { id: nnoSession.tenantId, name: nnoSession.tenantName },
  user: {
    id:          nnoSession.userId,
    email:       nnoSession.email,
    name:        nnoSession.name,
    permissions: nnoSession.permissions,
  },
  availableTenants: nnoSession.availableTenants,
  switchTenant:     (tenantId) => switchActiveTenant(tenantId),
};

6. Tenant Switching [Phase 1]

Users who belong to multiple tenants can switch their active tenant without re-authenticating. This uses Better Auth's organization.setActive API:

// Shell: user switches from Team Alpha to Team Beta
async function switchActiveTenant(tenantId: string): Promise<void> {
  // 1. Tell Better Auth to change active organization
  await authClient.organization.setActive({ organizationId: tenantId });

  // 2. Fetch new NNO session (new permissions for the new tenant)
  const newSession = await fetch('/api/nno/session').then(r => r.json());

  // 3. Update ShellContext
  setShellContext(buildShellContext(newSession));

  // 4. Navigate to the default route of the new tenant
  router.navigate({ to: '/' });
}

The Better Auth session is updated server-side (a new activeOrganizationId is written to the session row). No new login is required. The shell re-renders with the new tenant's permissions, which may hide or reveal features.


7. API Key Authentication [Phase 1]

For programmatic access (CI/CD, integrations, NNO service-to-service), the auth service issues API keys via the Better Auth API Key plugin:

POST /api/auth/api-key/create
{
  "name": "CI Deploy Key",
  "expiresIn": 2592000,     // 30 days in seconds
  "permissions": ["analytics:read"],
  "rateLimit": { "window": 60000, "max": 100 }
}

→ { "key": "nno_abc123...", "id": "key_xyz", ... }

API keys are passed as x-api-key: nno_abc123... header. The auth Worker validates the key and resolves permissions identically to session-based auth.

Prefix: nno_ for all platform API keys.

Phase 2: IAM exposes an internal validation endpoint POST /api/nno/apikey/validate for Gateway to use. The endpoint accepts the raw API key value (from the x-api-key header), validates it by looking up the SHA-256 hash in Better Auth's key store, and returns \{ userId, role, platformId \} — the same session shape as POST /api/nno/session. This allows Gateway to authenticate API key requests without needing a separate token exchange. See §13.2 for the full consolidated list of all three Gateway authentication paths.


8. Two-Factor Authentication [Phase 2]

Better Auth's twoFactor plugin is enabled by default on all platform auth workers:

MethodConfiguration
TOTP30-second window, 6 digits, issuer = platform name
OTP via email5-minute validity, 6 digits, sent via email Worker
Backup codes10 codes × 10 chars each

2FA enrollment is optional per user but can be made mandatory for platform-admin role via an auth middleware check:

// Auth Worker middleware
app.use('/api/*', async (c, next) => {
  const session = c.get('session');
  if (session?.user.role === 'platform-admin' && !session.user.twoFactorEnabled) {
    return c.redirect('/auth/setup-2fa');
  }
  return next();
});

9. Auth Worker Configuration [Phase 1]

Wrangler.toml (NNO-generated per platform — post-substitution example)

The following shows what a deployed platform's wrangler.toml looks like after NNO CLI Service has replaced all REPLACE_WITH_* placeholders. The actual template with placeholders lives in services/auth/wrangler.toml in this repo.

name = "k3m9p2xw7q-r8n4t6y1z5-auth-prod"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding     = "DB"
database_name = "k3m9p2xw7q-r8n4t6y1z5-auth-db-prod"
database_id   = "3767d212-eb4f-4cb4-bbf9-0b84ca285805"

[vars]
NNO_PLATFORM_ID = "k3m9p2xw7q"
BASE_URL        = "https://auth.svc.default.k3m9p2xw7q.nno.app"

Secrets (set via Wrangler, per environment)

SecretDescription
AUTH_SECRET32+ byte random string for session signing
CORS_ORIGINSComma-separated allowed origins (shell URL)

[Updated for DNS architecture] Platform shells and auth workers now both live under .<platformId>.nno.app, enabling cookie sharing across stacks via the common ancestor domain.

Platform shells are hosted on <name>.app.<stackId>.<platformId>.nno.app while auth workers are on auth.svc.default.<platformId>.nno.app. Because all resources share the .<platformId>.nno.app ancestor, the auth cookie can be set with Domain=.<platformId>.nno.app and will be accessible to all apps on the platform.

EnvironmentShell OriginAuth OriginCookie DomainSameSiteSecure
Developmentlocalhost:5174localhost:8787localhostLaxfalse
Staging<name>.app.stg.<stackId>.<pid>.nno.appauth.svc.stg.default.<pid>.nno.app.<pid>.nno.appNonetrue
Production<name>.app.<stackId>.<pid>.nno.appauth.svc.default.<pid>.nno.app.<pid>.nno.appNonetrue

All deployed environments (staging and production) use SameSite=None; Secure=true because the shell and auth origins are on different subdomains. The shared .<pid>.nno.app cookie domain means the session cookie is accessible to every app on the platform regardless of which stack it belongs to.

See dns-naming.md for the full DNS hostname convention.


11. D1 Migration Strategy [Phase 1]

Auth D1 databases are provisioned with a base migration set that includes:

  1. Better Auth core tables (user, session, account, verification)
  2. Organization plugin tables (organization, member, invitation)
  3. Admin, twoFactor, apiKey plugin tables
  4. NNO extension tables (nno_roles, nno_permission_grants)
  5. Audit tables (audit_authentication, audit_authorization)

Migrations are applied via NNO Provisioning during platform setup, using wrangler d1 migrations apply. All platform auth workers share the same migration files from the services/auth package — they diverge only in data, not schema.


12. Auth Endpoints Summary [Phase 1]

EndpointMethodDescription
/api/auth/sign-in/emailPOSTEmail + password login
/api/auth/sign-up/emailPOSTRegister new user
/api/auth/sessionGETGet current session
/api/auth/sign-outPOSTInvalidate session
/api/auth/two-factor/*POST2FA enrollment and verification
/api/auth/organization/*GET/POSTTenant CRUD, membership, invitations
/api/auth/admin/*GET/POSTUser management (platform-admin only)
/api/auth/api-key/*GET/POST/DELETEAPI key management
/api/nno/sessionGETNNO-enriched session with permissions
/api/nno/organizationsPOSTCreate tenant from Registry (internal)
/api/nno/roles?orgId=\{id\}GETList role–permission mappings for an org (IAM only). orgId query param required.
/api/nno/rolesPOSTUpsert a role–permission mapping (IAM only)
/api/nno/grantsGET/POST/DELETEPer-user permission grant/deny overrides (IAM only)

All /api/auth/* endpoints are handled by Better Auth. /api/nno/session is a custom Hono route included in createAuthApp() and available on both per-platform auth workers and the global IAM. /api/nno/organizations, /api/nno/roles, and /api/nno/grants are mounted only on the global IAM (services/iam) and are not available on per-platform auth workers.


13. Service-to-Service Authentication [Phase 1]

All communication between NNO internal services is authenticated with a shared opaque bearer token — not JWTs, not mTLS, not per-service-pair keys. This section is the authoritative spec for how that mechanism works.

Cross-reference: Every service's wrangler.toml SECRETS block refers to AUTH_API_KEY and/or NNO_INTERNAL_API_KEY. Both resolve to values described in this section.


13.1 Token Format and Secret Naming Convention

Two secret names appear across all services:

Secret nameDirectionSet onPurpose
AUTH_API_KEYInboundEvery upstream serviceThe value each service checks on incoming requests. Callers must send this as Authorization: Bearer \{AUTH_API_KEY\}.
NNO_INTERNAL_API_KEYOutboundGateway + any service that calls another serviceThe value injected when a service makes outgoing calls to another NNO service.

The invariant: A caller's NNO_INTERNAL_API_KEY must equal the callee's AUTH_API_KEY. In practice, a single shared secret value is deployed to both variables across the NNO infrastructure — there are no per-service-pair keys in Phase 1.

Services that only receive calls (e.g., Registry) need AUTH_API_KEY only. Services that also make outbound calls to other services (e.g., Provisioning → Registry, Billing → Registry) need both.

Secret declarations per service (from wrangler.toml comments):

ServiceAUTH_API_KEYNNO_INTERNAL_API_KEY
Gateway✅ (validates NNO CLI / console inbound)✅ (injected on all upstream calls)
Registry
Billing✅ (calls Registry)
Provisioning✅ (calls Registry)
Stack Registry✅ (calls Registry)
IAM

13.2 Gateway Authentication Paths

The Gateway is the sole public ingress for all /api/* routes. It authenticates every request via the following paths before forwarding with a token swap:

PathMechanismHeaderUse Case
Path 1Static API keyAuthorization: Bearer \{AUTH_API_KEY\}Service-to-service calls, CLI
Path 2IAM session validationAuthorization: Bearer \{session_token\} (validated via IAM)Browser-based console users
Path 3NNO API keyx-api-key: nno_\{key\}External API consumers (Phase 2)

Path 1 and Path 2 are implemented in Phase 1. Path 3 (x-api-key header) is a Phase 2 addition — see §7 for API key format and validation details.

Path 1 — Static API key (service-to-service, e.g. NNO CLI)

  1. Validates the incoming Authorization: Bearer token against AUTH_API_KEY (constant-time).
  2. On match, forwards immediately with the token swapped to NNO_INTERNAL_API_KEY.

Path 2 — IAM session token (user browser sessions)

  1. If Path 1 fails and IAM is reachable, validates the token via GET /api/nno/session on the IAM service.

  2. On success, injects user context headers before forwarding:

    • x-nno-user-id, x-nno-role, x-nno-platform-id
  3. Downstream services use these headers for role-based access control.

  4. The Authorization header is still swapped to NNO_INTERNAL_API_KEY before forwarding.

  5. Request ID propagationX-Request-Id is injected on every upstream call for distributed tracing (see services/gateway/src/services/proxy.ts).

External caller (NNO CLI / console)
  Authorization: Bearer <token>


   NNO Gateway
   ├── Path 1: timingSafeEqual(token, AUTH_API_KEY) → match: forward
   ├── Path 2: GET /api/nno/session → inject x-nno-user-id, x-nno-role, x-nno-platform-id
   └── forward with header swap:
         Authorization: Bearer <NNO_INTERNAL_API_KEY>
         X-Request-Id: <requestId>


  Upstream service (Registry / Billing / etc.)
  └── validate: safeCompare(token, AUTH_API_KEY)

What is NOT forwarded: The caller's original Authorization header, the Host header (stripped for Cloudflare routing), and any hop-by-hop headers.


13.3 Upstream Service Validation

All upstream services (Registry, Billing, Provisioning, Stack Registry) apply the same auth middleware pattern. Implementation lives at services/\{service\}/src/middleware/auth.ts:

  • Parse Authorization: Bearer \{token\} — return 401 if header is missing or malformed.
  • Constant-time comparison (safeCompare) of token against AUTH_API_KEY environment secret.
  • If the key does not match — return 403 (header was well-formed, but token is wrong).
  • If AUTH_API_KEY is unset (local dev only) — the check is bypassed and the request passes through.

Error response shapes differ slightly between services:

// Gateway → external caller (401) — NNO error envelope
{ "error": { "code": "UNAUTHORIZED", "message": "Missing or invalid Authorization header...", "requestId": "..." } }

// Registry → gateway (401 / 403)
{ "error": { "code": "UNAUTHORIZED" | "FORBIDDEN", "message": "...", "details": {}, "requestId": "..." } }

13.4 Transport Layer

In deployed environments (dev / stg / prod), the Gateway uses Cloudflare Service Bindings for all upstream calls. Service Bindings are declared in services/gateway/wrangler.toml under [[env.\{env\}.services]]. Worker-to-Worker calls via Service Bindings:

  • Bypass the public internet (no HTTP round-trip)
  • Share the same Cloudflare account trust boundary
  • Do not require the *_URL secrets (those are local-dev fallbacks only)

In local development, the Gateway falls back to HTTP fetch using the NNO_*_URL environment variables (e.g., NNO_REGISTRY_URL=http://localhost:8788/api/v1/platforms). The same bearer token mechanism applies — AUTH_API_KEY and NNO_INTERNAL_API_KEY must still be set consistently across local processes, or left unset to enable the local dev bypass.


13.5 Error Handling and 401/403 Recovery

ScenarioHTTP responseRecovery
Missing Authorization header on inbound gateway request401 UnauthorizedCaller must include Authorization: Bearer <key>
Wrong token on inbound gateway request401 UnauthorizedCaller must use the correct AUTH_API_KEY
Gateway injects wrong NNO_INTERNAL_API_KEY to upstream403 Forbidden from upstreamGateway returns 502 Bad Gateway to original caller (upstream 5xx is surfaced as 502)
AUTH_API_KEY not set on upstream (misconfiguration)Request passes through (no auth)Set the secret via wrangler secret put AUTH_API_KEY --env \{env\}
Service Binding unavailable (local dev, binding not wired)Falls back to HTTP URL fetchEnsure NNO_*_URL is set for local dev

When a 5xx response is received from any upstream, the Gateway surfaces it as a structured 502 Bad Gateway — the upstream error body is not forwarded to the caller.


13.6 Phase 1 vs. Phase 2 [Phase 2]

AspectPhase 1 (current)Phase 2 (planned)
Token formatOpaque shared secret (random string)IAM-issued JWT with service identity and expiry claims
Key scopeSingle shared key across all servicesPer-service-pair scoped tokens
Token rotationManual (wrangler secret put across all services in sync)Automatic rotation via IAM with short-lived JWTs
ValidationLocal constant-time string compareGateway calls NNO_IAM_URL to validate and decode JWT
RevocationRequires rotating the secret in all services simultaneouslyIAM issues short-lived tokens (e.g., 5-minute TTL); no revocation needed

Phase 2 validation is noted in services/gateway/src/middleware/auth.ts: "Phase 2 (future): full IAM token validation via NNO_IAM_URL."


Related sections: §7 (API Key Authentication) · §12 (Auth Endpoints Summary) · §13.2 (Gateway Authentication Paths) · Gateway Architecture · Registry API · Provisioning


14. Platform Lifecycle & Auth Implications [Phase 2]

Platform status transitions affect authentication and authorization:

StatusAuth Behavior
activeNormal auth flow, all paths available
suspendedGateway returns 403 PLATFORM_SUSPENDED via KV lookup (NNO_PLATFORM_STATUS_KV). Operator-role users bypass suspension for admin access
pending_cancellationAuth continues until cancellation period ends
cancelledSame as suspended — 403 PLATFORM_SUSPENDED for all non-operator requests
deletedResources deprovisioned — auth Worker removed, 404

The Gateway checks platform status via KV (platform:\{platformId\}:status) on every authenticated request. Status is written-through to KV by the Registry on lifecycle transitions.

See concepts/platform-lifecycle.md for the full state machine.


15. Credential Security [Phase 2]

15.1 Brute-Force Protection & Account Lockout

Progressive response to failed authentication attempts, enforced at the IAM service level:

Failed AttemptsResponse
1–4Normal 401 response
5–9401 + Retry-After: \{exponential_delay\} header (2^n seconds, capped at 30s)
10+Account locked for 30 minutes. Returns 423 Locked with Retry-After

Implementation approach:

  • D1 table auth_failed_attempts: (user_email, attempt_count, last_attempt_at, locked_until)
  • Better Auth before hook on /api/auth/sign-in/email checks lockout state before credential validation
  • Successful login resets the counter
  • locked_until is nullable — null means not locked
  • Lockout is per-email, not per-IP (prevents credential stuffing across IPs)
  • Platform-admin can unlock accounts via Admin plugin's user management

Audit: All lockout events (lock and unlock) logged to audit_authentication with eventType: "account_lockout" / "account_unlock".

15.2 Password Policy

Enforced at signup and password change via Better Auth before hook on /api/auth/sign-up/email and /api/auth/change-password:

RuleRequirement
Minimum length10 characters
Character classesAt least 2 of: uppercase, lowercase, digit, special
Common password checkReject top-10k common passwords (static list, bundled at build time)
Reuse preventionNot applicable in Phase 2 (no password history stored)

Validation errors return 422 with field-level error details in the NNO error envelope.

15.3 Email Verification

Phase 2 enables requireEmailVerification: true in Better Auth config:

  • Signup sends a verification email via Resend (existing email infrastructure)
  • Unverified users can sign in but see a restricted UI with a verification banner
  • After 7 days without verification, account is soft-locked (session creation blocked until verified)
  • Verification tokens expire after 24 hours; re-send available via /api/auth/send-verification-email

16. Session Security [Phase 2]

16.1 Session Revocation

Mechanisms for invalidating sessions, beyond natural expiry:

ActionScopeTrigger
Sign outSingle sessionUser clicks sign out
Revoke all sessionsAll sessions for a userUser action, password change, account compromise
Revoke by deviceSingle session by IDUser removes a device from session list
Admin force-revokeAll sessions for a userPlatform-admin action via Admin plugin

Implementation:

  • DELETE /api/auth/sessions — revoke all sessions for the authenticated user (deletes all rows from session where userId matches)
  • DELETE /api/auth/sessions/:sessionId — revoke a specific session
  • Password change (/api/auth/change-password) automatically revokes all other sessions
  • Admin plugin's banUser already revokes sessions — extend with explicit revokeUserSessions admin endpoint
  • Platform suspension (§14) does not revoke sessions — it blocks at the Gateway level

16.2 Concurrent Session Limits

Configurable per-platform limits on active sessions:

RoleDefault Limit
platform-admin5 concurrent sessions
user10 concurrent sessions

When the limit is exceeded, the oldest session is automatically revoked (FIFO eviction). The evicted session returns 401 on its next request, prompting re-authentication.

Implementation: Better Auth after hook on session creation counts active sessions per user and evicts the oldest if over the limit.

16.3 Device & Session Visibility

Users can view and manage their active sessions:

  • GET /api/auth/sessions — list all active sessions for the authenticated user
  • Each session includes: id, createdAt, lastActiveAt (derived from updateAge), ipAddress, userAgent, parsed device info (OS, browser)
  • UI: "Active Sessions" panel in user settings, showing device name, location (derived from IP), last active time, with "Revoke" action per session

No new tables required — session table already stores ipAddress and userAgent. Device info parsing reuses the existing parseUserAgent() from services/iam/src/core/services/user-agent-parser.ts.


17. SSO & Identity Federation [Phase 2]

17.1 OAuth Social Providers

Better Auth's OAuth support is already scaffolded (SSO callback routes exist in the console, account table has OAuth token columns). Phase 2 wires up provider configuration:

ProviderConfig SourceNotes
GoogleGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET env varsWorkspace accounts for enterprise
GitHubGITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET env varsDeveloper-facing platforms

Provider enablement is per-platform — each platform's auth Worker configures only the providers relevant to its users. The NNO IAM Worker (operator console) enables both by default.

Implementation:

  • Add socialProviders config to createCloudflareAuth(), gated by env var presence (provider only registered if credentials are set)
  • Account linking: if a user signs up with email+password then later signs in with Google using the same email, Better Auth's accountLinking.enabled: true merges the accounts automatically
  • Console UI: social login buttons shown conditionally based on auth.config.ts feature flags. Note: auth.config.ts has two socialAuth settings — capabilities.socialAuth controls provider registration, features.socialAuth controls UI visibility. Both must be set to true, and individual provider booleans (github, google) flipped to true

17.2 Enterprise SSO (SAML/OIDC) [Phase 3]

For enterprise customers requiring SSO with their corporate identity provider:

  • Better Auth does not natively support SAML — this requires a custom plugin or proxy through a SAML-to-OIDC bridge (e.g., BoxyHQ SAML Jackson)
  • Alternative: support generic OIDC providers, which covers Azure AD, Okta, Auth0, and most enterprise IdPs
  • Configuration stored per-platform in the auth Worker's environment: SSO_ISSUER_URL, SSO_CLIENT_ID, SSO_CLIENT_SECRET
  • JIT (Just-In-Time) provisioning: first SSO login auto-creates a user record and assigns member role in the default tenant
  • Domain-verified enforcement: if a platform configures SSO for @acme.com, email+password login is disabled for @acme.com addresses (SSO-only)

This is Phase 3 because it requires SAML integration work and per-platform SSO configuration UI.


18. Security Hardening [Phase 2]

18.1 2FA Enforcement Policies

Phase 1 has 2FA backend ready but UI disabled. Phase 2 adds policy-driven enforcement:

PolicyScopeBehavior
OptionalDefault for all users2FA available but not required
Required for adminsplatform-admin and org owner rolesRedirect to 2FA setup on login if not enrolled
Required for allPlatform-wide settingAll users must enroll within a grace period

Implementation:

  • Platform-level setting stored in auth Worker env: TWO_FACTOR_POLICY=optional|admin-required|all-required
  • Better Auth after hook on sign-in checks policy against user role and twoFactorEnabled flag
  • Grace period: 7 days from first login after policy activation — user can dismiss the 2FA setup prompt during grace period, after which session creation is blocked until enrolled
  • Console UI: flip twoFactor: true in auth.config.ts, add 2FA setup flow in user settings

18.2 Production Rate Limiting

Phase 1's in-memory sliding window resets on Worker cold start. Phase 2 upgrades to durable rate limiting:

LayerMechanismScope
Gateway (global)Cloudflare Rate Limiting API (RATE_LIMITER binding)Per-IP, per-route
Auth endpointsStricter limits on sensitive pathsPer-email for login, per-IP for signup

Rate limit tiers:

Route PatternLimitWindow
POST /api/auth/sign-in/*10 requests5 minutes per email
POST /api/auth/sign-up/*5 requests15 minutes per IP
POST /api/auth/forgot-password3 requests15 minutes per email
POST /api/auth/two-factor/*5 requests5 minutes per session
All other /api/*120 requests1 minute per IP (unchanged)

The Gateway's existing X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After response headers are preserved.

18.3 Gateway Header Signing

Gateway injects x-nno-user-id, x-nno-role, x-nno-platform-id on authenticated requests. Upstream services trust these headers because traffic arrives via Service Bindings (same CF account trust boundary). However, in local dev (HTTP fallback), these headers can be spoofed.

Phase 2 adds HMAC signing:

  • Gateway computes X-NNO-Signature: HMAC-SHA256(NNO_INTERNAL_API_KEY, "user-id:role:platform-id:request-id:timestamp")
  • Upstream services verify the signature before trusting x-nno-* headers
  • X-NNO-Timestamp header added; signatures older than 5 minutes are rejected (replay protection)
  • In deployed environments (Service Bindings), signature verification is optional but recommended — defense in depth

18.4 Service-to-Service JWT [Phase 2] (extends §13.6)

Already documented in §13.6. This subsection adds implementation specifics:

  • IAM exposes POST /api/nno/service-token — accepts \{ serviceId: string, targetService: string \}, authenticated via AUTH_API_KEY, returns a short-lived JWT (5-minute TTL)
  • JWT claims: \{ iss: "nno-iam", sub: serviceId, aud: targetService, iat, exp, jti \}
  • Signed with AUTH_SECRET using HMAC-SHA256 (no asymmetric keys needed — all services are within the same trust boundary)
  • Gateway caches JWTs for TTL - 30s to avoid per-request IAM calls
  • Upstream services validate JWT signature + expiry + aud claim matching their own service ID
  • Fallback: if IAM is unreachable, Gateway falls back to opaque AUTH_API_KEY (Phase 1 behavior) with a warning logged

18.5 Bootstrap Secret Fix [Phase 1 — Bug Fix]

services/iam/src/routes/nno-bootstrap.ts validates X-Bootstrap-Secret using plain string equality (!==), which is vulnerable to timing attacks. Fix: replace with the WebCrypto HMAC constant-time comparison pattern used in services/iam/src/core/middleware/auth.ts — specifically the crypto.subtle.sign + crypto.subtle.verify approach (sign the expected value with HMAC-SHA256, then verify the input against it). Do not use timingSafeEqual directly as it is not available in all Workers runtimes.

This is a Phase 1 fix — it should be applied immediately, not deferred to Phase 2.


19. Operational Security [Phase 3]

19.1 Auth Event Webhooks

Allow platforms to subscribe to auth events for integration with external systems (SIEM, Slack alerts, custom automation):

EventPayload
user.loginuserId, email, ipAddress, method (password/sso/api-key), timestamp
user.login.failedemail, ipAddress, failureReason, attemptCount, timestamp
user.signupuserId, email, method, timestamp
user.lockeduserId, email, reason, lockedUntil, timestamp
user.password.changeduserId, email, timestamp
user.2fa.enrolled / user.2fa.removeduserId, email, method (totp/otp), timestamp
session.revokeduserId, sessionId, revokedBy (self/admin/system), timestamp
apikey.created / apikey.revokeduserId, keyId, keyPrefix, timestamp

Implementation:

  • Webhook URLs registered per-platform via Registry metadata (stored in platforms table, not auth D1)
  • Delivery: fire-and-forget fetch() from the auth Worker after the response is sent (using ctx.waitUntil())
  • Payload signed with HMAC-SHA256 using a platform-specific webhook secret, delivered in X-NNO-Webhook-Signature header (distinct from X-NNO-Signature used for internal gateway header signing in §18.3)
  • Retry: no automatic retry in Phase 3 — failed deliveries logged to audit_authentication. Phase 4 could add a DLQ-based retry mechanism via Cloudflare Queues (same pattern as Provisioning DLQ)

19.2 Audit Log Export

Extend the existing 90-day audit system with export capabilities:

FeatureDescription
On-demand export`GET /api/audit/export?from=&to=&format=csv
Scheduled exportCron-triggered daily export to R2 bucket (nno-audit-\{platformId\})
Extended retentionR2 exports retained for 1 year (vs 90 days in D1)
FilteringBy event type, user, date range, result (success/failure)

Access control: platform-admin only. Org owners can export their own org's events via orgId filter.

19.3 IP Allowlisting & Geo-Blocking

Enterprise access control at the Gateway level:

FeatureScopeStorage
IP allowlistPer-platformKV: platform:\{platformId\}:ip-allowlist (JSON array of CIDRs)
Geo-blockingPer-platformKV: platform:\{platformId\}:geo-policy (`{ mode: "allow"

Enforcement:

  • Checked at the Gateway after auth but before proxying (between auth middleware and route handler)
  • IP from CF-Connecting-IP header; country from cf.country property on the Cloudflare request object
  • Allowlist mode: if configured, only listed CIDRs can access. If not configured, all IPs allowed (open by default)
  • Geo-blocking: allow mode permits only listed countries; deny mode blocks listed countries
  • Operator-role users (platform-admin) bypass IP/geo restrictions for emergency access
  • Returns 403 ACCESS_DENIED with \{ reason: "ip_not_allowed" | "geo_blocked" \} in the error envelope

Configuration: managed via Registry API (platform settings), cached in KV with 5-minute TTL.


Status: Detailed design — ready for implementation Implementation target: services/auth/ + services/iam/ + packages/ui-auth/

Client-side auth packages:

  • @neutrino-io/ui-auth — auth UI components, providers, guards, Better Auth/Clerk/Directus integration
  • @neutrino-io/sdk/hooks/auth — auth hooks for use within feature packages (useSettingsAuth, useHasPermission, useOrganizationRoles)

useHasPermission / useHasAnyPermission security note: Both hooks return false (fail-safe) when SettingsAuthContext is not yet loaded or the permission system is not configured. This prevents silent access grants during context initialisation or misconfiguration. Consumers do not need to change their call signature — both hooks retain the same (permission: string) => boolean return type.

Settings feature permission requirement: All settings routes (/settings/*) require settings:read. This is enforced via permissions: ["settings:read"] on every route in createSettingsRoutes() and on the settingsFeatureDefinition. Write operations within settings pages (org name, profile update) are gated at component level with settings:write. Both permissions are included in the default nno_roles seeding for all org types.

Related: System Architecture §14.G · NNO Registry · Shell Feature Config

On this page

OverviewIAM Service (Global)Architecture: Two-Service Auth ModelAuth Service (Per-Platform Template)1. Architecture: One Auth Worker Per Platform [Phase 1]2. Concept Mapping: Better Auth ↔ NNO [Phase 1]Organisation = Tenant3. Role Hierarchy [Phase 1]3.1 Platform-Level Roles3.2 Tenant-Level Roles3.3 Role Precedence4. Feature Permission Layer [Phase 1]4.1 NNO Schema Extensions4.2 Default Role Permission Sets4.3 Permission Resolution Algorithm5. Session Payload [Phase 1]5.1 Raw Better Auth Session Response5.2 NNO-Extended Session (from /api/nno/session)5.3 Shell Context Population6. Tenant Switching [Phase 1]7. API Key Authentication [Phase 1]8. Two-Factor Authentication [Phase 2]9. Auth Worker Configuration [Phase 1]Wrangler.toml (NNO-generated per platform — post-substitution example)Secrets (set via Wrangler, per environment)10. Cross-Origin Cookie Configuration [Phase 1]11. D1 Migration Strategy [Phase 1]12. Auth Endpoints Summary [Phase 1]13. Service-to-Service Authentication [Phase 1]13.1 Token Format and Secret Naming Convention13.2 Gateway Authentication Paths13.3 Upstream Service Validation13.4 Transport Layer13.5 Error Handling and 401/403 Recovery13.6 Phase 1 vs. Phase 2 [Phase 2]14. Platform Lifecycle & Auth Implications [Phase 2]15. Credential Security [Phase 2]15.1 Brute-Force Protection & Account Lockout15.2 Password Policy15.3 Email Verification16. Session Security [Phase 2]16.1 Session Revocation16.2 Concurrent Session Limits16.3 Device & Session Visibility17. SSO & Identity Federation [Phase 2]17.1 OAuth Social Providers17.2 Enterprise SSO (SAML/OIDC) [Phase 3]18. Security Hardening [Phase 2]18.1 2FA Enforcement Policies18.2 Production Rate Limiting18.3 Gateway Header Signing18.4 Service-to-Service JWT [Phase 2] (extends §13.6)18.5 Bootstrap Secret Fix [Phase 1 — Bug Fix]19. Operational Security [Phase 3]19.1 Auth Event Webhooks19.2 Audit Log Export19.3 IP Allowlisting & Geo-Blocking