Multi-Tenant Authentication & Session Management
Overview
Axiom Genesis implements a subdomain-based multi-tenant authentication system where:
- Subdomains map to tenant identifiers (e.g.,
tenant-a.axiom-genesis.com) - Nginx reverse proxy injects encrypted tenant ID via HTTP header
- Unauthenticated users can load tenant-specific login UI without authentication
- Post-login, tenant ID, user ID, and role are persisted in session and JWT token
- All subsequent requests automatically include the tenant context for isolation
This ensures secure tenant isolation while maintaining a seamless single sign-on experience across subdomains.
Architecture
Multi-Tenant Request Flow
┌─────────────────────────────── ──────────────────────────────────┐
│ Subdomain Request │
│ User visits: https://tenant-code.axiom-genesis.com/login │
└────────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. NGINX Reverse Proxy │
│ ├─ Extract subdomain: "tenant-code" │
│ ├─ Get tenant encryption key from environment │
│ ├─ Encrypt tenant ID: AES-256-GCM({type: "tenant", id: "1"}) │
│ └─ Inject header: x-tenant-id: {ciphertext, nonce, salt} │
└────────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Frontend (Next.js) - Login Page Load │
│ ├─ Request includes x-tenant-id header from nginx │
│ ├─ Validate header is present │
│ ├─ Extract/store encrypted tenant for subsequent requests │
│ └─ Fetch tenant config (logo, colors, settings) WITHOUT auth │
└────────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Backend - Tenant Data Fetch (Unauthenticated) │
│ ├─ Endpoint: GET /api/v1/auth/tenant-config │
│ ├─ Header: x-tenant-id (encrypted) │
│ ├─ NO JWT required │
│ ├─ Decrypt tenant ID in middleware │
│ ├─ Validate tenant exists in app_tenants table │
│ ├─ Return: logos, colors, app_settings, UI configuration │
│ └─ Store response in Redux for rendering │
└────────────────┬────────────────────────────────────────────────┘
│
(User enters credentials and submits login form)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Login Submission (signin Endpoint) │
│ ├─ Endpoint: POST /api/v1/auth/signin │
│ ├─ Body: {email_id, password} │
│ ├─ Header: x-tenant-id (encrypted) │
│ ├─ Middleware: validateTenant (decrypt x-tenant-id) │
│ ├─ Controller: authController.signin() │
│ │ ├─ Call: AuthService.validateCredentials() │
│ │ │ └─ Check password against user.password_hash │
│ │ ├─ Call: AuthService.generateToken() │
│ │ │ └─ Create JWT with {user_id, tenant_id, role} │
│ │ └─ Return token, user_id, tenant_id, expires_in │
│ └─ Response: {token, user_id, tenant_id, expires_in} │
└────────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Frontend - Session Persistence │
│ ├─ Dispatch Redux: setAuth({ token, user_id, expires_in }) │
│ ├ ─ Dispatch Redux: setTenant({ tenant_id }) │
│ ├─ Store encrypted tenant in sessionStorage │
│ ├─ Redirect to dashboard │
│ └─ apiClient interceptor now includes: │
│ ├─ Authorization: Bearer <JWT_TOKEN> │
│ └─ x-tenant-id: <ENCRYPTED_TENANT_ID> │
└────────────────┬────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 6. Authenticated Requests - Automatic Context │
│ ├─ All subsequent API calls include: │
│ │ ├─ JWT token in Authorization header │
│ │ ├─ Encrypted tenant ID in x-tenant-id header │
│ ├─ Backend middleware verifies both headers │
│ ├─ Database queries scoped to tenant │
│ └─ Responses include only tenant-specific data │
└─────────────────────────────────────────────────────────────────┘
Component Responsibilities
1. NGINX Reverse Proxy Configuration
Purpose: Extract subdomain and inject encrypted tenant ID header
Configuration Pattern:
# Map subdomain to tenant ID
map $host $tenant_code {
~^(?<subdomain>.+)\.axiom-genesis\.com$ $subdomain;
~^axiom-genesis\.com$ default;
default '';
}
# Tenant ID mapping (subdomain → numeric ID)
map $tenant_code $tenant_id {
'tenant-a' '1';
'tenant-b' '2';
'default' '1';
default '1';
}
server {
listen 443 ssl http2;
server_name ~^(.+)?\.axiom-genesis\.com$;
# Encrypt tenant ID and inject header
location / {
# Create JSON structure with tenant info
set $tenant_json '{"type":"tenant","id":"$tenant_id"}';
# Encrypt using environment variable (TENANT_ENCRYPTION_KEY)
# Note: In production, use a Lua module or external service for encryption
# This is a simplified example - actual encryption must happen server-side
# Inject encrypted tenant ID as header
proxy_set_header x-tenant-id $encrypted_tenant_id;
# Forward other essential headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Proxy to frontend
proxy_pass http://frontend:3000;
}
}
Key Points:
- Extracts subdomain from
Hostheader - Maps subdomain to tenant ID from configuration
- Encrypts tenant ID using AES-256-GCM before injection (uses
TENANT_ENCRYPTION_KEY) - Passes encrypted value in
x-tenant-idheader - All requests from that subdomain automatically include this header
2. Frontend (Next.js) - Login Page
Purpose: Load tenant-specific UI without authentication
Header Retrieval & Storage
File: src/lib/encrypted-tenant-utils.ts
/**
* Retrieve encrypted tenant from:
* 1. Session storage (cached from previous header)
* 2. HTTP header (from nginx on initial page load - browser reads via document metadata)
* 3. Fallback to default tenant_id=1
*/
export async function getEncryptedTenantId(): Promise<string | null> {
// Check session storage first (cached from login)
let encrypted = getStoredEncryptedTenant();
if (encrypted) return encrypted;
// Check for nginx header (set as data attribute on document)
const headerValue = (document as any).__NGINX_X_TENANT_ID;
if (headerValue) {
storeEncryptedTenant(headerValue);
return headerValue;
}
// Fallback to default context
return null;
}
/**
* Store encrypted tenant in sessionStorage
* This persists across page reloads in same browser session
*/
export function storeEncryptedTenant(encryptedTenant: string): void {
if (typeof window !== "undefined") {
sessionStorage.setItem("encrypted-tenant-id", encryptedTenant);
}
}
API Client Request Interceptor
File: src/lib/apiClient.ts
import {
getStoredEncryptedTenant,
getEncryptedTenantId,
} from "./encrypted-tenant-utils";
/**
* Request Interceptor: Adds Authentication Token & Tenant Header
* Automatically includes x-tenant-id for all outgoing requests
*/
apiClient.interceptors.request.use(
async (config) => {
// 1. Add JWT token from Redux store (if authenticated)
let token: string | null = null;
if (isStoreInitialized()) {
const state = getStoreState();
token = state.auth.token;
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 2. Add encrypted tenant ID header
// Priority:
// a) Stored encrypted tenant (from session storage)
// b) Fetch with smart fallback (header → default=1)
try {
let encryptedTenant = getStoredEncryptedTenant();
if (!encryptedTenant) {
encryptedTenant = await getEncryptedTenantId();
}
if (encryptedTenant) {
config.headers["x-tenant-id"] = encryptedTenant;
}
} catch (error) {
// Continue without tenant header - let server handle
console.warn("Failed to set x-tenant-id header:", error);
}
return config;
},
(error) => Promise.reject(error),
);
Login Page Flow
File: src/app/login/page.tsx (example)
import { useEffect, useState } from 'react';
import { useAppDispatch } from '@/hooks/useRedux';
import { setTenant } from '@/store/slices/tenantSlice';
import api from '@/services/api';
export default function LoginPage() {
const dispatch = useAppDispatch();
const [tenantConfig, setTenantConfig] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Step 1: Fetch tenant configuration (no auth required)
// The x-tenant-id header is automatically added by apiClient interceptor
const fetchTenantConfig = async () => {
try {
// GET /api/v1/auth/tenant-config
// This endpoint DOES NOT require JWT authentication
// It validates tenant ID from x-tenant-id header
const response = await api.auth.getTenantConfig();
setTenantConfig(response.data);
// Store tenant ID in Redux for other components
if (response.data.tenant_id) {
dispatch(setTenant({ tenant_id: response.data.tenant_id }));
}
} catch (error) {
console.error('Failed to load tenant configuration:', error);
} finally {
setLoading(false);
}
};
fetchTenantConfig();
}, [dispatch]);
const handleLogin = async (email: string, password: string) => {
try {
// POST /api/v1/auth/signin
// x-tenant-id header is automatically added by apiClient
const response = await api.auth.signin({
email_id: email,
password
});
// Step 2: Persist authentication in Redux
dispatch(setAuth({
token: response.data.token,
user_id: response.data.user_id,
expires_in: response.data.expires_in
}));
// Step 3: Persist tenant ID
dispatch(setTenant({
tenant_id: response.data.tenant_id
}));
// Step 4: Encrypt and store tenant for future requests
const encrypted = await encryptAndStoreTenant(response.data.tenant_id);
// Redirect to dashboard
router.push('/dashboard');
} catch (error) {
console.error('Login failed:', error);
}
};
return (
<div>
{/* Render tenant-specific UI from tenantConfig */}
<img src={tenantConfig?.logo_url} alt="Tenant Logo" />
<LoginForm onSubmit={handleLogin} />
</div>
);
}
3. Backend Middleware - Request Validation
Purpose: Decrypt tenant ID and validate tenant existence
File: middleware/validate-tenant.middleware.js
/**
* Tenant Header Validation Middleware
* - Validates x-tenant-id header format
* - Decrypts encrypted tenant ID
* - Attaches req.tenantId for downstream handlers
*/
const { ValidationError } = require("../utils/errors/AppError");
const { decryptTenantId } = require("../helpers/tenant-encryption.helper");
const validateTenant = (req, res, next) => {
try {
const encryptedTenantId = req.headers["x-tenant-id"];
// 1. Validate header exists
if (!encryptedTenantId) {
throw new ValidationError("Tenant ID is required in headers", {
"x-tenant-id": "required",
});
}
// 2. Validate header format (must be encryption JSON)
let isValidEncrypted = false;
try {
const parsed = JSON.parse(encryptedTenantId);
isValidEncrypted = parsed.ciphertext && parsed.nonce && parsed.salt;
} catch (e) {
// Not valid JSON with encryption fields
}
if (!isValidEncrypted) {
throw new ValidationError(
"Invalid tenant ID format - must be encrypted",
{
"x-tenant-id": "invalid_format_not_encrypted",
},
);
}
// 3. Decrypt tenant ID
try {
const masterKey = process.env.TENANT_ENCRYPTION_KEY;
if (!masterKey) {
throw new Error("Encryption key not configured on server");
}
const decryptedTenantId = decryptTenantId(encryptedTenantId, masterKey);
// Attach to request for use in controllers
req.tenantId = decryptedTenantId.trim();
next();
} catch (decryptError) {
throw new ValidationError("Failed to decrypt tenant ID", {
"x-tenant-id": "decryption_failed",
});
}
} catch (error) {
next(error);
}
};
module.exports = { validateTenant };
Key Points:
- Validates encrypted format (must be JSON with
ciphertext,nonce,salt) - Decrypts using
TENANT_ENCRYPTION_KEYenvironment variable - Attaches
req.tenantIdfor downstream controllers - Throws validation error if format is invalid or decryption fails
4. Backend Controller - Authentication
Purpose: Validate credentials and generate JWT token with tenant context
File: controllers/auth.controller.js
Signin Endpoint
/**
* POST /auth/signin
* @body {string} email_id - User email
* @body {string} password - User password
* @header {string} x-tenant-id - Encrypted tenant ID (required)
* @returns {object} {token, user_id, tenant_id, expires_in, expires_at}
*/
AuthController.signin = asyncHandler(async (req, res) => {
const { email_id, password, device_token } = req.body;
const tenantId = req.tenantId; // From validateTenant middleware
// 1. Validate input
if (!email_id || !password) {
throw new ValidationError("Email and password are required", {
email_id: email_id ? null : "required",
password: password ? null : "required",
});
}
// 2. Validate credentials against tenant-specific user record
// This ensures cross-tenant isolation
const user = await AuthService.validateCredentials(
email_id,
password,
tenantId,
);
// 3. Generate JWT token with tenant context
const tokenResult = await AuthService.generateToken(user);
// 4. Load tenant settings for client-side configuration
let tenantSettings = null;
try {
tenantSettings = await AuthService.getTenantSettings(tenantId);
} catch (settingsError) {
Logger.warn("signin_tenant_settings_failed", {
tenant_id: tenantId,
error: settingsError.message,
});
}
// 5. Log successful authentication
Logger.info("user_signin_successful", {
email_id,
tenant_id: tenantId,
user_id: user.id,
});
// 6. Return authentication response
return res.success(
{
message: "Signin successful",
data: {
token: tokenResult.token, // JWT with tenant_id embedded
user_id: user.id,
tenant_id: tenantId, // Also returned explicitly
expires_in: tokenResult.expiresIn, // Token TTL in seconds
expires_at: tokenResult.expiresAt, // Absolute expiration timestamp
type: tokenResult.type, // "Bearer"
tenantSettings: tenantSettings, // For client-side config
},
},
200,
);
});
Token Generation Service
File: services/auth.service.js
/**
* Generate JWT token for authenticated user
* Includes tenant_id in token payload for verification
*/
static async generateToken(user) {
const payload = {
user_id: user.id,
email: user.email_id,
tenant_id: user.tenant_id, // CRITICAL: Embed tenant ID in JWT
role_id: user.role_id,
iat: Math.floor(Date.now() / 1000),
};
// Token expires in 24 hours (or configured value)
const expiresIn = process.env.JWT_EXPIRES_IN || '24h';
const token = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: expiresIn,
algorithm: 'HS256'
});
return {
token: token,
expiresIn: ms(expiresIn) / 1000, // Convert to seconds
expiresAt: new Date(Date.now() + ms(expiresIn)).toISOString(),
type: 'Bearer'
};
}
5. Backend Route Configuration
Purpose: Apply middleware to routes for tenant-scoped authentication
File: routes/auth.routes.js
const { Router } = require("express");
const { validateTenant } = require("../middleware/validate-tenant.middleware");
const authController = require("../controllers/auth.controller");
const router = Router();
/**
* PUBLIC ROUTES (Tenant-ID required, but NO JWT authentication)
* These endpoints use validateTenant to decrypt x-tenant-id
* but do NOT require JWT token
*/
// Get tenant configuration (for login page rendering)
router.get(
"/tenant-config",
validateTenant, // ← Validates & decrypts x-tenant-id
authController.getTenantConfig, // ← No auth required
);
// Signin endpoint
router.post(
"/signin",
validateTenant, // ← Validates & decrypts x-tenant-id
authValidator.signin, // ← Validates email/password format
authController.signin, // ← Generates JWT token
);
/**
* PROTECTED ROUTES (Both x-tenant-id AND JWT required)
* These use both validateTenant and JWT middleware
*/
router.post(
"/logout",
validateTenant, // ← Validates x-tenant-id
authenticate, // ← Validates JWT token
authController.logout,
);
router.get(
"/profile",
validateTenant, // ← Validates x-tenant-id
authenticate, // ← Validates JWT token
authController.getProfile,
);
module.exports = router;
Encryption & Security
Tenant ID Encryption Strategy
Algorithm: AES-256-GCM (Authenticated Encryption)
Encryption Process (Nginx → Frontend):
// JSON structure to encrypt
const tenantData = {
type: "tenant",
id: "1", // Numeric tenant ID
timestamp: 1710235200 // Optional: prevent replay attacks
};
// Encryption parameters
const algorithm = 'aes-256-gcm';
const key = process.env.TENANT_ENCRYPTION_KEY; // 32 bytes (256 bits)
const iv = crypto.randomBytes(16); // 16 bytes
const salt = crypto.randomBytes(16); // 16 bytes (for key derivation)
// Encrypt
const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.write(JSON.stringify(tenantData));
const ciphertext = cipher.update(..., 'utf8', 'hex');
const authTag = cipher.getAuthTag();
// Format for transmission
const encrypted = JSON.stringify({
ciphertext: ciphertext,
nonce: iv.toString('hex'),
salt: salt.toString('hex'),
authTag: authTag.toString('hex'),
algorithm: 'aes-256-gcm'
});
// Send as x-tenant-id header
// x-tenant-id: {ciphertext, nonce, salt, authTag, algorithm}
Decryption Process (Backend):
// Receive encrypted header
const encryptedHeader = req.headers["x-tenant-id"];
const encrypted = JSON.parse(encryptedHeader);
// Decryption parameters
const key = process.env.TENANT_ENCRYPTION_KEY;
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
key,
Buffer.from(encrypted.nonce, "hex"),
);
decipher.setAuthTag(Buffer.from(encrypted.authTag, "hex"));
// Decrypt
const decrypted = decipher.update(encrypted.ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
// Parse & use
const tenantData = JSON.parse(decrypted);
req.tenantId = tenantData.id;
Benefits:
- Confidentiality: AES-256 provides military-grade encryption
- Integrity: GCM mode prevents tampering/modification
- Authentication: Auth tag proves data wasn't altered
- Freshness: Timestamp in payload can prevent replay attacks
- Transparency: Header is base64-encoded JSON, not opaque binary
Redux State Management
Authentication State
File: src/store/slices/authSlice.ts
interface AuthState {
token: string | null; // JWT token from signin
user_id: string | null; // User ID
role_id: string | null; // User role
is_authenticated: boolean; // Boolean flag
expires_at: string | null; // Token expiration timestamp
loading: boolean; // During signin/logout
}
const authSlice = createSlice({
name: "auth",
initialState: {
token: null,
user_id: null,
role_id: null,
is_authenticated: false,
expires_at: null,
loading: false,
},
reducers: {
setAuth: (state, action) => {
state.token = action.payload.token;
state.user_id = action.payload.user_id;
state.role_id = action.payload.role_id;
state.is_authenticated = true;
state.expires_at = action.payload.expires_at;
// Auto-save to localStorage for persistence
localStorage.setItem("auth", JSON.stringify(state));
},
clearAuth: (state) => {
state.token = null;
state.user_id = null;
state.role_id = null;
state.is_authenticated = false;
state.expires_at = null;
localStorage.removeItem("auth");
},
},
});
Tenant State
File: src/store/slices/tenantSlice.ts
interface TenantState {
tenant_id: string | null; // Current tenant ID (from signin)
tenant_name: string | null; // Display name
logo_url: string | null; // Tenant-specific logo
colors: Record<string, string>; // Theming
settings: any; // Custom settings
}
const tenantSlice = createSlice({
name: "tenant",
initialState: {
tenant_id: null,
tenant_name: null,
logo_url: null,
colors: {},
settings: null,
},
reducers: {
setTenant: (state, action) => {
state.tenant_id = action.payload.tenant_id;
state.tenant_name = action.payload.tenant_name;
state.logo_url = action.payload.logo_url;
state.colors = action.payload.colors || {};
state.settings = action.payload.settings;
// Persist to sessionStorage
sessionStorage.setItem("tenant", JSON.stringify(state));
},
clearTenant: (state) => {
state.tenant_id = null;
state.tenant_name = null;
state.logo_url = null;
state.colors = {};
state.settings = null;
sessionStorage.removeItem("tenant");
},
},
});
Error Handling & Edge Cases
Missing x-tenant-id Header
Scenario: User bypasses nginx (e.g., direct API call without subdomain)
Frontend Response (apiClient):
// Header not available
// Fall back to getEncryptedTenantId() which returns null
// Request made WITHOUT x-tenant-id header
Backend Response (validateTenant middleware):
// Error: "Tenant ID is required in headers"
// Status: 400 Bad Request
// User redirected to login page with error message
Invalid/Corrupted Encryption
Scenario: Header modified in transit or encrypted with wrong key
Frontend: Handled gracefully, continues without header
Backend Response:
// Error: "Failed to decrypt tenant ID"
// Status: 400 Bad Request
// Log warning with full error details for debugging
// Prevent database queries until resolved
Token Expiration During Session
Scenario: User's JWT token expires after 24 hours
Frontend Response (apiClient interceptor):
// Detect 401 Unauthorized response
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear auth state
dispatch(clearAuth());
// Clear user session
clearEncryptedTenant();
// Redirect to login
router.push("/login");
}
return Promise.reject(error);
},
);
Cross-Tenant Security
Scenario: User from Tenant A tries to access Tenant B's resources
Protection Layers:
- Header Validation:
validateTenantmiddleware verifies encryption - JWT Validation: Token includes
tenant_idwhich is verified - Query Scoping: All database queries include
WHERE tenant_id = req.tenantId - Authorization: Role-based access control applies within tenant scope
Configuration
Environment Variables
Frontend (.env.local or .env.development):
# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_API_TIMEOUT=30000
# Tenant Encryption
NEXT_PUBLIC_TENANT_ENCRYPTION_KEY=your-32-byte-base64-encoded-key
Backend (.env):
# Server Configuration
NODE_ENV=development
PORT=3001
LOG_LEVEL=info
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-signing-key
JWT_EXPIRES_IN=24h
# Tenant Encryption (MUST match frontend key)
TENANT_ENCRYPTION_KEY=your-32-byte-base64-encoded-key
# Database (Configuration DB - stores app_tenants, users, etc.)
DB_HOST=localhost
DB_PORT=1433
DB_USER=sa
DB_PASSWORD=MyPassword123!
DB_DATABASE=RADICS_CFG
# Redis (for session storage & caching)
REDIS_URL=redis://localhost:6379
API Endpoints
Public Endpoints (Tenant ID required, no JWT)
| Endpoint | Method | Header | Body | Returns |
|---|---|---|---|---|
/auth/tenant-config | GET | x-tenant-id | - | {logo_url, colors, settings, tenant_name} |
/auth/signin | POST | x-tenant-id | {email_id, password} | {token, user_id, tenant_id, expires_in} |
/auth/signup-otp | POST | x-tenant-id | {email_id} | {message, email_id} |
/auth/create-account | POST | x-tenant-id | {password, session} | {token, user_id, tenant_id} |
Protected Endpoints (Both headers required)
| Endpoint | Method | Headers | Body | Returns |
|---|---|---|---|---|
/auth/logout | POST | x-tenant-id, Authorization | - | {message} |
/auth/profile | GET | x-tenant-id, Authorization | - | {user_id, email_id, role_id, ...} |
/workflows | GET | x-tenant-id, Authorization | - | [{id, name, status, ...}] |
/records | GET | x-tenant-id, Authorization | - | [{record data}] |
Testing Multi-Tenant Auth
Prerequisites
- Both frontend and backend running
- MSSQL database with
app_tenantsand users seeded - Encryption keys configured in environment
Test Flow
# 1. Start with subdomain (nginx will inject x-tenant-id)
# Browser: https://tenant-a.axiom-genesis.local/login
# 2. Inspect network request to /api/v1/auth/tenant-config
# Should see:
# Request Header: x-tenant-id: {encrypted json}
# Response: {logo_url, colors, tenant_name}
# 3. Submit login form
# Should see:
# Request: POST /api/v1/auth/signin
# Body: {email_id: "user@tenant-a.com", password: "..."}
# Header: x-tenant-id: {encrypted}
# Response: {token, user_id, tenant_id}
# 4. Check Redux state (DevTools)
# auth.token = "eyJhbGc..."
# auth.user_id = "uuid"
# tenant.tenant_id = "1"
# 5. Verify subsequent requests include auth
# GET /api/v1/workflows
# Headers:
# x-tenant-id: {encrypted}
# Authorization: Bearer eyJhbGc...
Curl Testing
# Get tenant config (no auth needed)
curl -X GET http://localhost:3001/api/v1/auth/tenant-config \
-H "x-tenant-id: {encrypted-tenant-id}"
# Signin
curl -X POST http://localhost:3001/api/v1/auth/signin \
-H "x-tenant-id: {encrypted-tenant-id}" \
-H "Content-Type: application/json" \
-d '{"email_id":"user@example.com","password":"password123"}'
# Logout (requires JWT)
curl -X POST http://localhost:3001/api/v1/auth/logout \
-H "x-tenant-id: {encrypted-tenant-id}" \
-H "Authorization: Bearer {jwt-token}"
Common Issues & Debugging
Issue: "Tenant ID is required in headers"
Causes:
- Nginx not injecting header
- Frontend not sending header on requests
- Header lost during redirect
Debug Steps:
# 1. Check browser network tab
# - Request to /api/v1/auth/signin should show x-tenant-id header
# 2. Verify nginx config is active
# nginx -t && nginx -s reload
# 3. Check browser console
# console.log(getStoredEncryptedTenant()) # Should return encrypted value
# 4. Check apiClient interceptor
# - Add console.log in request interceptor to see what headers are being sent
Issue: "Failed to decrypt tenant ID"
Causes:
TENANT_ENCRYPTION_KEYmismatch between frontend and backend- Header corrupted during transmission
- Wrong encryption format
Debug Steps:
# 1. Verify encryption keys match
# Frontend: NEXT_PUBLIC_TENANT_ENCRYPTION_KEY
# Backend: TENANT_ENCRYPTION_KEY
# echo $TENANT_ENCRYPTION_KEY # Should be same 32-byte base64 string
# 2. Check header format in network tab
# x-tenant-id should be valid JSON: {"ciphertext": "...", "nonce": "...", "salt": "...", "authTag": "..."}
# 3. Enable debug logging in middleware
# Add: console.log('Encrypted header:', encryptedTenantId);
Issue: User logged in to Tenant A, seen data from Tenant B
Causes:
- Missing
WHERE tenant_id = ?in database query - JWT token not validated
- x-tenant-id header ignored
Debug Steps:
# 1. Check database query
# SELECT * FROM workflows WHERE tenant_id = @tenant_id
# 2. Print req.tenantId in controller
# console.log('Tenant ID from header:', req.tenantId);
# 3. Verify JWT payload includes tenant_id
# jwt.decode(token) # Should show {"tenant_id": "1", ...}
# 4. Check database connection routing
# Ensure queries use correct tenant database
Related Documentation
- Login Flow & Role Management Guide — End-user login, role parsing, feature access control (NEW)
- DATABASE.md - Tenant isolation at database layer
- MIDDLEWARE.md - API routing and authentication middleware
- FRONTEND.md - Redux state management and API client setup
- CONFIGURATION/TENANT_USERS_ROLES.md - Multi-tenancy, RBAC, session persistence
Summary
Axiom Genesis Multi-Tenant Authentication:
- Subdomain Routing: Nginx extracts tenant code from subdomain (e.g.,
tenant-a.domain.com) - Header Injection: NGINX encrypts tenant ID and injects as
x-tenant-idheader - Unauthenticated Tenant Load: Frontend fetches tenant config without JWT
- User Signin: Validates credentials, generates JWT with
tenant_idembedded - Session Persistence: Redux stores token, sessions store encrypted tenant ID
- Automatic Context: All subsequent requests include both headers for multi-tenant isolation
- Security: Encryption at REST, HTTPS in transit, JWT validation, query scoping
This design ensures secure, scalable multi-tenancy while maintaining a seamless user experience across subdomains.