Skip to main content

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_ALGORITHM constant is set to 'HS256' and passed to both jwt.sign() and jwt.verify() with algorithms: [JWT_ALGORITHM]. This prevents algorithm confusion attacks where an attacker could switch to none or RS256 with a crafted key.
  • Separate secrets: Access tokens use JWT_SECRET and refresh tokens use JWT_REFRESH_SECRET.
  • Minimum secret length: The environment schema (backend/src/config/env.ts) enforces JWT_SECRET to 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​

  1. Extract token: First from req.cookies.token (browser clients), then from Authorization: Bearer <token> header (API clients / Postman).

  2. Verify signature: Uses jwt.verify() pinned to HS256. Rejects expired tokens with standard JWT expiry checks.

  3. 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.

  4. MFA gate: Tokens with role: 'partial_auth' are issued after password verification but before MFA completion. These tokens cannot access protected routes.

  5. Cross-tenant validation: In SaaS mode, the tenantId in the JWT must match the tenantId resolved from the request's tenant context. Prevents token replay across tenants.

  6. 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 the TokenBlacklist table. This ensures logout takes effect even if the in-memory cache was missed.

  7. User hydration: Reads current email, role, twoFactorEnabled, and status from the database. Uses a Map cache 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.

  8. Account status check: Rejects users with status inactive, suspended, deactivated, or pending_invite.

  9. Attach to request: Sets req.user with the full AuthenticatedUser object 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:

CategoryRoles
Legacyadmin, analyst, viewer, manager, security_analyst, scanner_admin, compliance_officer
Globalglobal_admin, global_analyst, global_reader, global_scanner
Exposure Managementexposure_admin, exposure_analyst, exposure_reader, exposure_scanner
AppSecappsec_admin, appsec_analyst, appsec_reader, appsec_scanner
Cloud Securitycloud_admin, cloud_analyst, cloud_reader
Identity Securityidentity_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
  1. authenticate: Decodes JWT, extracts tenantId
  2. resolveTenant: Looks up tenant metadata (schema name, plan limits) from TLM
  3. setTenantSchema: Creates a request-scoped QueryRunner with SET search_path TO "tenant_<slug>", public
  4. 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​

MethodPathAuthPurpose
GET/api/sso/statusPublicReturns { enabled, loginButtonVisible, enforcedDomains }
GET/api/sso/authorizePublicInitiates OAuth2 flow, redirects to Microsoft
GET/api/sso/callbackPublicHandles OAuth2 callback, creates session, redirects to frontend
GET/api/sso/configAdminReturns current SSO config (secrets redacted)
PUT/api/sso/configAdminSaves SSO configuration
POST/api/sso/testAdminTests 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:

EndpointLimitWindow
/api/auth/login10 requests15 minutes
/api/auth/change-password10 requests15 minutes
/api/auth/mfa/validate10 requests15 minutes
/api/auth/invite5 requests1 minute
/api/auth/forgot-password10 requests1 minute
/api/auth/reset-password5 requests1 minute
General /api/*100 requests1 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:

  1. User logs in with email/password
  2. If twoFactorEnabled, a token with role: 'partial_auth' is issued
  3. User submits TOTP code to POST /api/auth/mfa/validate
  4. Backend verifies TOTP using otplib
  5. If valid, a full access token is issued with the user's real role
  6. The partial_auth token 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, private on all responses
  • ETag: Disabled to prevent cache fingerprinting

CSRF protection is implemented via Origin header validation on mutating requests (POST, PUT, PATCH, DELETE).