Authentication Deep Dive
This document covers the complete authentication system in ThreatWeaver, based on the actual implementation in backend/src/middleware/auth.ts, backend/src/routes/auth.routes.ts, and backend/src/routes/sso.routes.ts.
JWT Token Structureβ
ThreatWeaver uses HS256-signed JWTs. The token payload interface is defined in backend/src/middleware/auth.ts:
export interface AuthTokenPayload {
userId: string // UUID of the authenticated user
role: string // e.g., 'admin', 'analyst', 'viewer', 'appsec_admin'
tenantId?: string // Tenant UUID (multi-tenant deployments)
tenantSlug?: string // Human-readable tenant identifier
tenantPlan?: string // 'starter' | 'pro' | 'enterprise'
licenseJti?: string // License JWT ID for entitlement validation
iat?: number // Issued at (auto-set by jsonwebtoken)
exp?: number // Expiration (auto-set by jsonwebtoken)
}
The token is minimized -- it contains only what is needed for authorization decisions. User details like email and twoFactorEnabled are hydrated from the database on each request (with a 30-second cache).
Security Hardeningβ
- Algorithm pinned to HS256: The
JWT_ALGORITHMconstant is set to'HS256'and passed to bothjwt.sign()andjwt.verify()withalgorithms: [JWT_ALGORITHM]. This prevents algorithm confusion attacks where an attacker could switch tononeorRS256with a crafted key. - Separate secrets: Access tokens use
JWT_SECRETand refresh tokens useJWT_REFRESH_SECRET. - Minimum secret length: The environment schema (
backend/src/config/env.ts) enforcesJWT_SECRETto be at least 32 characters.
Token Lifecycleβ
Login (POST /api/auth/login)
|
v
+---------------------------+
| Generate access token | JWT_EXPIRES_IN (default: 15m)
| Generate refresh token | JWT_REFRESH_EXPIRES_IN (default: 7d)
+---------------------------+
|
| Set as HttpOnly cookies: 'token' + 'refreshToken'
v
Browser stores cookies automatically
|
| Every API request sends cookies
v
authenticate() middleware verifies access token
|
| Token expired?
v
POST /api/auth/refresh
| Verify refresh token (separate secret)
| Issue new access token
| (refresh token NOT rotated by default)
v
New access token set as cookie
|
| Refresh token expired (after 7 days)?
v
User must log in again
Token Generation Functionsβ
From backend/src/middleware/auth.ts:
// Access token -- short-lived
export function generateToken(
payload: Omit<AuthTokenPayload, 'iat' | 'exp'>,
expiresIn?: string | number
): string {
return jwt.sign(payload, env.jwt.secret, {
expiresIn: expiresIn || env.jwt.expiresIn, // default '15m'
algorithm: JWT_ALGORITHM, // 'HS256'
} as jwt.SignOptions)
}
// Refresh token -- long-lived
export function generateRefreshToken(
payload: Omit<AuthTokenPayload, 'iat' | 'exp'>
): string {
return jwt.sign(payload, env.jwt.refreshSecret, {
expiresIn: env.jwt.refreshExpiresIn, // default '7d'
algorithm: JWT_ALGORITHM,
} as jwt.SignOptions)
}
Middleware Flowβ
The authenticate middleware in backend/src/middleware/auth.ts is the core gate for every protected route. Here is the exact flow:
Step-by-Step Breakdownβ
-
Extract token: First from
req.cookies.token(browser clients), then fromAuthorization: Bearer <token>header (API clients / Postman). -
Verify signature: Uses
jwt.verify()pinned to HS256. Rejects expired tokens with standard JWT expiry checks. -
User-level revocation: An in-memory
Set<string>of revoked user IDs. When a user is deactivated,revokeUserSessions(userId)adds them instantly -- no cache delay. -
MFA gate: Tokens with
role: 'partial_auth'are issued after password verification but before MFA completion. These tokens cannot access protected routes. -
Cross-tenant validation: In SaaS mode, the
tenantIdin the JWT must match thetenantIdresolved from the request's tenant context. Prevents token replay across tenants. -
Token revocation: Two-layer check. First, an in-memory
Map<string, number>(_revokedCache, bounded at 5,000 entries with TTL-based cleanup every 60s). Then, a database lookup against theTokenBlacklisttable. This ensures logout takes effect even if the in-memory cache was missed. -
User hydration: Reads current
email,role,twoFactorEnabled, andstatusfrom the database. Uses aMapcache with 30-second TTL to avoid a DB hit on every request. The DB role overrides the JWT role -- so role demotions take effect within 30 seconds. -
Account status check: Rejects users with status
inactive,suspended,deactivated, orpending_invite. -
Attach to request: Sets
req.userwith the fullAuthenticatedUserobject for downstream route handlers.
Permission Systemβ
After authenticate(), routes can further restrict access with requirePermission():
router.get('/severity-debug',
requirePermission(PERMISSIONS.MANAGE_SETTINGS),
async (req, res) => { ... }
)
Permissions are defined in backend/src/entities/permissions.ts. The PERMISSIONS object maps permission names to their string keys. Roles are stored in the roles table with a permissions JSONB column that lists which permissions each role has.
Role Hierarchyβ
ThreatWeaver supports both legacy roles and RBAC v2 module-specific roles:
| Category | Roles |
|---|---|
| Legacy | admin, analyst, viewer, manager, security_analyst, scanner_admin, compliance_officer |
| Global | global_admin, global_analyst, global_reader, global_scanner |
| Exposure Management | exposure_admin, exposure_analyst, exposure_reader, exposure_scanner |
| AppSec | appsec_admin, appsec_analyst, appsec_reader, appsec_scanner |
| Cloud Security | cloud_admin, cloud_analyst, cloud_reader |
| Identity Security | identity_admin, identity_analyst, identity_reader |
Multi-Tenant Authenticationβ
In multi-tenant (SaaS) deployments, the JWT carries tenantId, tenantSlug, and tenantPlan. The middleware chain in backend/src/index.ts is:
authenticate β resolveTenant β setTenantSchema β enforceTenancy
- authenticate: Decodes JWT, extracts
tenantId - resolveTenant: Looks up tenant metadata (schema name, plan limits) from TLM
- setTenantSchema: Creates a request-scoped
QueryRunnerwithSET search_path TO "tenant_<slug>", public - enforceTenancy: Validates that the resolved schema is allowed by the license
The TenantContext attached to each request:
export interface TenantContext {
tenantId: string
tenantSlug: string
tenantPlan: string // 'starter' | 'pro' | 'enterprise'
schemaName: string // e.g., 'tenant_blucypher'
licenseJti: string
planLimits: PlanLimits
status: string
allowedModules?: string[]
maxUsers?: number
}
SSO / SAML Flowβ
ThreatWeaver supports Microsoft Entra ID (formerly Azure AD) SSO via OAuth2/OIDC. Routes are defined in backend/src/routes/sso.routes.ts.
SSO Endpointsβ
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/sso/status | Public | Returns { enabled, loginButtonVisible, enforcedDomains } |
GET | /api/sso/authorize | Public | Initiates OAuth2 flow, redirects to Microsoft |
GET | /api/sso/callback | Public | Handles OAuth2 callback, creates session, redirects to frontend |
GET | /api/sso/config | Admin | Returns current SSO config (secrets redacted) |
PUT | /api/sso/config | Admin | Saves SSO configuration |
POST | /api/sso/test | Admin | Tests Microsoft connection |
SSO Flow Diagramβ
The User entity includes SSO-specific fields:
@Column({ type: 'varchar', length: 50, nullable: true })
ssoProvider!: string | null // 'microsoft-entra-id' | null
@Column({ type: 'varchar', length: 255, nullable: true })
ssoSubjectId!: string | null // Azure AD Object ID (oid claim)
@Column({ type: 'varchar', length: 50, default: 'credentials' })
loginMethod!: string // 'credentials' | 'sso'
Session Managementβ
HttpOnly Cookiesβ
Tokens are stored as HttpOnly cookies -- not accessible to JavaScript. This prevents XSS-based token theft:
token: Access token (15-minute TTL)refreshToken: Refresh token (7-day TTL)
Cookie attributes: HttpOnly, Secure (in production), SameSite=Strict.
Token Revocationβ
ThreatWeaver implements multi-layer revocation:
Layer 1 -- User-level revocation (_revokedUserIds: Set<string>):
When a user is deactivated, revokeUserSessions(userId) adds their ID to an in-memory Set. All tokens for that user are instantly invalid. This is the fastest path -- checked before any DB lookup.
Layer 2 -- Token-level in-memory cache (_revokedCache: Map<string, number>):
Individual tokens are cached with their expiry time. Bounded at 5,000 entries. Self-cleans every 60 seconds. The .unref() call on the cleanup interval ensures it does not prevent process exit.
Layer 3 -- Database blacklist (TokenBlacklist entity):
Permanent record of revoked tokens. Checked when the in-memory cache misses. On a DB hit, the token is also added to the in-memory cache for faster future lookups.
export function revokeTokenInMemory(token: string, ttlMs: number): void {
if (_revokedCache.size >= MAX_REVOKED_CACHE) {
// Evict the oldest entry (Map preserves insertion order)
const firstKey = _revokedCache.keys().next().value
if (firstKey !== undefined) _revokedCache.delete(firstKey)
}
_revokedCache.set(token, Date.now() + ttlMs)
}
Account Lockoutβ
From User.entity.ts, accounts are locked after 5 failed login attempts for 15 minutes:
incrementFailedAttempts(): void {
this.failedLoginAttempts++
if (this.failedLoginAttempts >= 5) {
this.lockedUntil = new Date(Date.now() + 15 * 60 * 1000)
this.status = 'locked'
}
}
Rate Limiting on Auth Endpointsβ
From backend/src/index.ts, auth endpoints have dedicated rate limiters:
| Endpoint | Limit | Window |
|---|---|---|
/api/auth/login | 10 requests | 15 minutes |
/api/auth/change-password | 10 requests | 15 minutes |
/api/auth/mfa/validate | 10 requests | 15 minutes |
/api/auth/invite | 5 requests | 1 minute |
/api/auth/forgot-password | 10 requests | 1 minute |
/api/auth/reset-password | 5 requests | 1 minute |
General /api/* | 100 requests | 1 minute |
Two-Factor Authentication (MFA)β
The User entity has MFA fields:
@Column({ type: 'boolean', default: false })
twoFactorEnabled!: boolean
@Column({ type: 'varchar', nullable: true, select: false })
twoFactorSecret!: string | null
The MFA flow:
- User logs in with email/password
- If
twoFactorEnabled, a token withrole: 'partial_auth'is issued - User submits TOTP code to
POST /api/auth/mfa/validate - Backend verifies TOTP using
otplib - If valid, a full access token is issued with the user's real role
- The
partial_authtoken is invalidated
The authenticate middleware explicitly blocks partial_auth tokens from accessing any protected route:
if (decoded.role === 'partial_auth') {
return res.status(401).json({
error: 'Unauthorized',
message: 'MFA verification required to access this resource',
code: 'MFA_REQUIRED',
})
}
Security Headersβ
The backend applies comprehensive security headers via Helmet (backend/src/index.ts):
- Content-Security-Policy: Restricts resource loading sources
- HSTS: 1-year max-age with includeSubDomains
- Referrer-Policy:
strict-origin-when-cross-origin - Permissions-Policy: Disables camera, microphone, geolocation, payment, USB, and sensor APIs
- Cache-Control:
no-store, no-cache, must-revalidate, privateon all responses - ETag: Disabled to prevent cache fingerprinting
CSRF protection is implemented via Origin header validation on mutating requests (POST, PUT, PATCH, DELETE).