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:
| Service | Package | Role |
|---|---|---|
services/iam | @neutrino-io/service-iam | Global NNO IAM Worker. Hosts auth-core AND NNO-specific routes (/api/nno/session). One instance for the entire platform. |
services/auth | @neutrino-io/service-auth | Per-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 viacrypto.subtle. Used by the gateway'sServiceTokenCacheto 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. Thewrangler.tomlcontainsREPLACE_WITH_*placeholders. During platform onboarding, NNO CLI Service:
- Creates a new GitHub repo (
nno-platform-\{platform-id\}) from the console template- Copies
services/auth/source into that repo- Substitutes all
REPLACE_WITH_*placeholders (platform ID, tenant ID, D1 IDs, etc.)- Deploys via
wrangler deployfrom the platform repoRunning
wrangler deploydirectly fromservices/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.appEach platform's auth service is completely independent:
- No shared user tables between platforms
- No cross-platform session tokens
- Platform-specific
AUTH_SECRETper 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 Concept | NNO Concept | Notes |
|---|---|---|
user | Platform user | Scoped to one platform |
organization | Tenant or Sub-tenant | Better Auth org = NNO entity |
member | Entity membership | User belongs to one or more tenants |
member.role | Tenant role | owner / admin / member |
user.role | Platform role | platform-admin / user |
session.activeOrganizationId | Active tenant ID | Drives shell context |
apiKey | Programmatic access token | Service-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.
orgTypefield: ThePOST /api/nno/organizationsendpoint accepts anorgTypeparameter ("nno-operator"|"tenant", defaults to"tenant"). After inserting the organization and owner member rows, the handler immediately seeds threenno_rolesrows (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:
| Role | Description | Typical Holder |
|---|---|---|
platform-admin | Full access to all tenants, platform settings, billing | Platform owner |
user | Standard user — access governed by tenant roles | Regular 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:
| Role | Description |
|---|---|
owner | Full control of the tenant. Can invite, remove members, change roles |
admin | Can manage members. Cannot delete the tenant or change billing |
member | Standard 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 permissions4. 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:
| Permission | Gates | Granted To |
|---|---|---|
zero:access | Entire Zero backoffice feature | NNO operator org: all roles |
zero:platform-manage | Platform CRUD mutations | NNO operator org: owner, admin |
zero:tenant-manage | Tenant lifecycle mutations | NNO operator org: owner, admin |
zero:stack-manage | Stack activation/deactivation on platforms | NNO operator org: owner, admin |
billing:read | View billing dashboard + invoices | All org types: all roles |
billing:manage | Billing mutations (plan change, payment) | All org types: owner, admin |
settings:read | View all settings pages | All org types: all roles |
settings:write | Mutate 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.tomlandservices/iamuseNNO_PLATFORM_ID(with prefix) because those services embed the platform ID directly into session payloads and need a clearly scoped name.services/billing/wrangler.tomlintentionally usesPLATFORM_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:
| Method | Configuration |
|---|---|
| TOTP | 30-second window, 6 digits, issuer = platform name |
| OTP via email | 5-minute validity, 6 digits, sent via email Worker |
| Backup codes | 10 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.tomllooks like after NNO CLI Service has replaced allREPLACE_WITH_*placeholders. The actual template with placeholders lives inservices/auth/wrangler.tomlin 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)
| Secret | Description |
|---|---|
AUTH_SECRET | 32+ byte random string for session signing |
CORS_ORIGINS | Comma-separated allowed origins (shell URL) |
10. Cross-Origin Cookie Configuration [Phase 1]
[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.
| Environment | Shell Origin | Auth Origin | Cookie Domain | SameSite | Secure |
|---|---|---|---|---|---|
| Development | localhost:5174 | localhost:8787 | localhost | Lax | false |
| Staging | <name>.app.stg.<stackId>.<pid>.nno.app | auth.svc.stg.default.<pid>.nno.app | .<pid>.nno.app | None | true |
| Production | <name>.app.<stackId>.<pid>.nno.app | auth.svc.default.<pid>.nno.app | .<pid>.nno.app | None | true |
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:
- Better Auth core tables (
user,session,account,verification) - Organization plugin tables (
organization,member,invitation) - Admin, twoFactor, apiKey plugin tables
- NNO extension tables (
nno_roles,nno_permission_grants) - 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]
| Endpoint | Method | Description |
|---|---|---|
/api/auth/sign-in/email | POST | Email + password login |
/api/auth/sign-up/email | POST | Register new user |
/api/auth/session | GET | Get current session |
/api/auth/sign-out | POST | Invalidate session |
/api/auth/two-factor/* | POST | 2FA enrollment and verification |
/api/auth/organization/* | GET/POST | Tenant CRUD, membership, invitations |
/api/auth/admin/* | GET/POST | User management (platform-admin only) |
/api/auth/api-key/* | GET/POST/DELETE | API key management |
/api/nno/session | GET | NNO-enriched session with permissions |
/api/nno/organizations | POST | Create tenant from Registry (internal) |
/api/nno/roles?orgId=\{id\} | GET | List role–permission mappings for an org (IAM only). orgId query param required. |
/api/nno/roles | POST | Upsert a role–permission mapping (IAM only) |
/api/nno/grants | GET/POST/DELETE | Per-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
SECRETSblock refers toAUTH_API_KEYand/orNNO_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 name | Direction | Set on | Purpose |
|---|---|---|---|
AUTH_API_KEY | Inbound | Every upstream service | The value each service checks on incoming requests. Callers must send this as Authorization: Bearer \{AUTH_API_KEY\}. |
NNO_INTERNAL_API_KEY | Outbound | Gateway + any service that calls another service | The 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):
| Service | AUTH_API_KEY | NNO_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:
| Path | Mechanism | Header | Use Case |
|---|---|---|---|
| Path 1 | Static API key | Authorization: Bearer \{AUTH_API_KEY\} | Service-to-service calls, CLI |
| Path 2 | IAM session validation | Authorization: Bearer \{session_token\} (validated via IAM) | Browser-based console users |
| Path 3 | NNO API key | x-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)
- Validates the incoming
Authorization: Bearertoken againstAUTH_API_KEY(constant-time). - On match, forwards immediately with the token swapped to
NNO_INTERNAL_API_KEY.
Path 2 — IAM session token (user browser sessions)
-
If Path 1 fails and IAM is reachable, validates the token via
GET /api/nno/sessionon the IAM service. -
On success, injects user context headers before forwarding:
x-nno-user-id,x-nno-role,x-nno-platform-id
-
Downstream services use these headers for role-based access control.
-
The
Authorizationheader is still swapped toNNO_INTERNAL_API_KEYbefore forwarding. -
Request ID propagation —
X-Request-Idis injected on every upstream call for distributed tracing (seeservices/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) oftokenagainstAUTH_API_KEYenvironment secret. - If the key does not match — return 403 (header was well-formed, but token is wrong).
- If
AUTH_API_KEYis 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
*_URLsecrets (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
| Scenario | HTTP response | Recovery |
|---|---|---|
Missing Authorization header on inbound gateway request | 401 Unauthorized | Caller must include Authorization: Bearer <key> |
| Wrong token on inbound gateway request | 401 Unauthorized | Caller must use the correct AUTH_API_KEY |
Gateway injects wrong NNO_INTERNAL_API_KEY to upstream | 403 Forbidden from upstream | Gateway 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 fetch | Ensure 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]
| Aspect | Phase 1 (current) | Phase 2 (planned) |
|---|---|---|
| Token format | Opaque shared secret (random string) | IAM-issued JWT with service identity and expiry claims |
| Key scope | Single shared key across all services | Per-service-pair scoped tokens |
| Token rotation | Manual (wrangler secret put across all services in sync) | Automatic rotation via IAM with short-lived JWTs |
| Validation | Local constant-time string compare | Gateway calls NNO_IAM_URL to validate and decode JWT |
| Revocation | Requires rotating the secret in all services simultaneously | IAM 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:
| Status | Auth Behavior |
|---|---|
active | Normal auth flow, all paths available |
suspended | Gateway returns 403 PLATFORM_SUSPENDED via KV lookup (NNO_PLATFORM_STATUS_KV). Operator-role users bypass suspension for admin access |
pending_cancellation | Auth continues until cancellation period ends |
cancelled | Same as suspended — 403 PLATFORM_SUSPENDED for all non-operator requests |
deleted | Resources 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 Attempts | Response |
|---|---|
| 1–4 | Normal 401 response |
| 5–9 | 401 + 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
beforehook on/api/auth/sign-in/emailchecks lockout state before credential validation - Successful login resets the counter
locked_untilis 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:
| Rule | Requirement |
|---|---|
| Minimum length | 10 characters |
| Character classes | At least 2 of: uppercase, lowercase, digit, special |
| Common password check | Reject top-10k common passwords (static list, bundled at build time) |
| Reuse prevention | Not 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:
| Action | Scope | Trigger |
|---|---|---|
| Sign out | Single session | User clicks sign out |
| Revoke all sessions | All sessions for a user | User action, password change, account compromise |
| Revoke by device | Single session by ID | User removes a device from session list |
| Admin force-revoke | All sessions for a user | Platform-admin action via Admin plugin |
Implementation:
DELETE /api/auth/sessions— revoke all sessions for the authenticated user (deletes all rows fromsessionwhereuserIdmatches)DELETE /api/auth/sessions/:sessionId— revoke a specific session- Password change (
/api/auth/change-password) automatically revokes all other sessions - Admin plugin's
banUseralready revokes sessions — extend with explicitrevokeUserSessionsadmin 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:
| Role | Default Limit |
|---|---|
platform-admin | 5 concurrent sessions |
user | 10 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 fromupdateAge),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:
| Provider | Config Source | Notes |
|---|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET env vars | Workspace accounts for enterprise | |
| GitHub | GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET env vars | Developer-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
socialProvidersconfig tocreateCloudflareAuth(), 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: truemerges the accounts automatically - Console UI: social login buttons shown conditionally based on
auth.config.tsfeature flags. Note:auth.config.tshas twosocialAuthsettings —capabilities.socialAuthcontrols provider registration,features.socialAuthcontrols UI visibility. Both must be set totrue, and individual provider booleans (github,google) flipped totrue
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
memberrole in the default tenant - Domain-verified enforcement: if a platform configures SSO for
@acme.com, email+password login is disabled for@acme.comaddresses (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:
| Policy | Scope | Behavior |
|---|---|---|
| Optional | Default for all users | 2FA available but not required |
| Required for admins | platform-admin and org owner roles | Redirect to 2FA setup on login if not enrolled |
| Required for all | Platform-wide setting | All 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
afterhook on sign-in checks policy against user role andtwoFactorEnabledflag - 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: trueinauth.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:
| Layer | Mechanism | Scope |
|---|---|---|
| Gateway (global) | Cloudflare Rate Limiting API (RATE_LIMITER binding) | Per-IP, per-route |
| Auth endpoints | Stricter limits on sensitive paths | Per-email for login, per-IP for signup |
Rate limit tiers:
| Route Pattern | Limit | Window |
|---|---|---|
POST /api/auth/sign-in/* | 10 requests | 5 minutes per email |
POST /api/auth/sign-up/* | 5 requests | 15 minutes per IP |
POST /api/auth/forgot-password | 3 requests | 15 minutes per email |
POST /api/auth/two-factor/* | 5 requests | 5 minutes per session |
All other /api/* | 120 requests | 1 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-Timestampheader 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 viaAUTH_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_SECRETusing HMAC-SHA256 (no asymmetric keys needed — all services are within the same trust boundary) - Gateway caches JWTs for
TTL - 30sto avoid per-request IAM calls - Upstream services validate JWT signature + expiry +
audclaim 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):
| Event | Payload |
|---|---|
user.login | userId, email, ipAddress, method (password/sso/api-key), timestamp |
user.login.failed | email, ipAddress, failureReason, attemptCount, timestamp |
user.signup | userId, email, method, timestamp |
user.locked | userId, email, reason, lockedUntil, timestamp |
user.password.changed | userId, email, timestamp |
user.2fa.enrolled / user.2fa.removed | userId, email, method (totp/otp), timestamp |
session.revoked | userId, sessionId, revokedBy (self/admin/system), timestamp |
apikey.created / apikey.revoked | userId, keyId, keyPrefix, timestamp |
Implementation:
- Webhook URLs registered per-platform via Registry metadata (stored in
platformstable, not auth D1) - Delivery: fire-and-forget
fetch()from the auth Worker after the response is sent (usingctx.waitUntil()) - Payload signed with HMAC-SHA256 using a platform-specific webhook secret, delivered in
X-NNO-Webhook-Signatureheader (distinct fromX-NNO-Signatureused 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:
| Feature | Description |
|---|---|
| On-demand export | `GET /api/audit/export?from=&to=&format=csv |
| Scheduled export | Cron-triggered daily export to R2 bucket (nno-audit-\{platformId\}) |
| Extended retention | R2 exports retained for 1 year (vs 90 days in D1) |
| Filtering | By 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:
| Feature | Scope | Storage |
|---|---|---|
| IP allowlist | Per-platform | KV: platform:\{platformId\}:ip-allowlist (JSON array of CIDRs) |
| Geo-blocking | Per-platform | KV: 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-IPheader; country fromcf.countryproperty 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:
allowmode permits only listed countries;denymode blocks listed countries - Operator-role users (
platform-admin) bypass IP/geo restrictions for emergency access - Returns
403 ACCESS_DENIEDwith\{ 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