JWT Authentication — Gateway Guards and Strategies
Audiences: developer, internal
Dudoxx HMS uses a Trusted Gateway pattern: browsers validate against Next.js via JWT, and Next.js forwards requests to NestJS using a validated API key plus gateway headers — NestJS never sees raw JWT tokens from browsers in the standard flow.
Business Purpose
A healthcare platform requires rock-solid authentication: clinicians must be securely identified on every API call without friction, and the system must prevent unauthorized access to patient data. The Trusted Gateway Pattern achieves this by keeping JWT validation in the Next.js layer (where it can also enforce session freshness and cookie management) and presenting NestJS with a pre-validated identity via signed service-to-service headers.
This design means NestJS never exposes a JWT endpoint directly to browsers, reducing the attack surface and eliminating the need to manage CORS on the API server. For mobile apps or external integrations that cannot go through Next.js, a direct JWT strategy is also wired but gated behind the same GatewayAuthGuard.
Business outcome: A doctor logs in; Next.js issues a JWT access token (1-hour TTL) + refresh token (7-day TTL) stored in Session (Prisma) and returns them to the browser. On every subsequent page request the browser sends the JWT to Next.js; Next.js validates it and forwards X-Gateway-User-* headers to NestJS; NestJS trusts these headers because the accompanying X-API-Key matches a registered gateway key.
Audiences
- Investor: The Trusted Gateway pattern is a defence-in-depth architecture: even a compromised frontend cannot forge NestJS-level access without also holding the gateway API key, which never leaves the server network.
- Clinical buyer: Clinicians experience seamless authentication — JWT refresh is transparent, sessions are tracked in the database for audit, and stale sessions are cleaned up automatically.
- Developer/partner: Partners building integrations send
POST /auth/loginto get tokens, then includeAuthorization: Bearer <token>on subsequent calls. TheJwtAuthGuardorGatewayAuthGuardhandles validation depending on the call path. - Internal (ops/support): Session table in
ddx_api_main(prisma-main) holdstoken,refreshToken,status,expiresAt.cleanupExpiredSessions()is available viaAuthService. Failed logins are logged vialogFailedLogin().
Architecture
The authentication surface has two complementary paths:
Path 1 — Trusted Gateway (primary, all ddx-web traffic). Entry: Browser —JWT→
Next.js (ddx-web, port 6200).
| # | Hop | Action |
|---|---|---|
| 1 | Next.js (ddx-web, port 6200) | validates JWT locally (next-auth / ddx-web/src/auth.ts) |
| 2 | Next.js (ddx-web, port 6200) | forwards X-API-Key + X-Clinic-ID + X-Gateway-User-* headers |
| 3 | NestJS (ddx-api, port 6100) | GatewayAuthGuard validates X-API-Key (source lookup) |
| 4 | NestJS (ddx-api, port 6100) | builds request.user from X-Gateway-User-* headers |
Path 2 — Direct JWT (mobile apps, external integrations). Entry: external client
—Authorization: Bearer <token>→ NestJS.
| # | Component |
|---|---|
| 1 | JwtAuthGuard (Passport 'jwt' strategy via jwt.strategy.ts) |
| 2 | JwtOrApiKeyGuard handles the union case |
The GatewayAuthGuard is registered as APP_GUARD in app.module.ts — it runs globally as the first step of the pipeline documented in ddx-api/CLAUDE.md §Request Pipeline. Architecture is anchored in coding_context/ddx-hms-context.md. Cross-cutting tenant isolation depends on this guard building request.user.organizationId — see tenant-isolation.md.
Tech Stack & Choices
| Technology | Role |
|---|---|
@nestjs/jwt + @nestjs/passport | JWT sign/verify + strategy framework |
passport-jwt | Passport strategy for Bearer token extraction |
passport-headerapikey | Header-based API key strategy (used by ApiKeyStrategy) |
@nestjs/config (ConfigService) | Reads JWT_SECRET, JWT_EXPIRES_IN, JWT_REFRESH_SECRET, JWT_REFRESH_EXPIRES_IN |
Prisma Session model (ddx_api_main) | Persists access+refresh tokens, status, expiresAt |
@ddx/prisma-main UserRole enum | Provides the role hierarchy used in RolesGuard |
JWT payload shape (JwtPayload interface, token.service.ts:7):
interface JwtPayload {
sub: string; // user UUID
email: string;
role: string; // primary role: SUPER_ADMIN, DOCTOR, NURSE, etc.
organizationId: string; // clinic slug (or "platform" for SUPER_ADMIN)
dbInstanceId: string; // Odoo-style instance lock
jti: string; // unique token ID prevents duplicate tokens
iat?: number;
exp?: number;
}
Role hierarchy (from ddx-api/CLAUDE.md):
SUPER_ADMIN > ORG_ADMIN > CLINIC_ADMIN > DOCTOR/NURSE > RECEPTIONIST > ACCOUNTANT > STAFF > PATIENT
Why organizationId is a clinic slug, not UUID: the slug is stable across database migrations, URL-friendly for FHIR partition headers, and used as X-Clinic-ID throughout the system. SUPER_ADMIN tokens receive "platform" as their organizationId — this sentinel bypasses TenantIsolationInterceptor's org lookup.
Why dbInstanceId: prevents tokens from being replayed against a different database instance after a tenant migration (Odoo-style approach — database-instance.service.ts generates this on boot).
Data Flow
Login flow
Entry: POST /auth/login (credentials) → AuthController → AuthService.login().
| # | Stage | Step |
|---|---|---|
| 1 | AuthenticationService.validateUser(email, password) | Prisma: find user, bcrypt.compare(password, hash) |
| 2 | TokenService.generateTokens(user) | databaseInstanceService.getInstanceId() → dbInstanceId |
| 3 | TokenService.generateTokens(user) | extracts role from userRoleAssignment (prisma-main) |
| 4 | TokenService.generateTokens(user) | resolves org UUID → slug (prisma: organization.slug) |
| 5 | TokenService.generateTokens(user) | jwtService.signAsync(payload, { secret: JWT_SECRET, expiresIn }) |
| 6 | TokenService.generateTokens(user) | jwtService.signAsync(payload, { secret: JWT_REFRESH_SECRET, expiresIn: 7d }) |
| 7 | TokenService.generateTokens(user) | prisma.session.create({ token, refreshToken, status: ACTIVE }) |
| 8 | return | AuthResponse { accessToken, refreshToken, user: {...} } |
Per-request validation (Trusted Gateway path)
Entry: NestJS receives request from Next.js → GatewayAuthGuard.canActivate().
| # | Check | Outcome |
|---|---|---|
| 1 | reflector: is @Public()? | → skip all checks |
| 2 | validates X-API-Key against this.apiKeys map | loaded from env vars |
| 3 | validates X-Clinic-ID format | isOrganizationIdentifier |
| 4 | resolves clinic existence | OrganizationService.getOrganizationId |
| 5 | checks X-Gateway-Source matches the key's registered source | — |
| 6 | if X-Gateway-User-ID present + source is ddx-* | buildUserContext() → GatewayUser { id, email, role, roles, permissions, organizationId, fhirIds... } |
| 7 | if no user ID | ServiceAccount { role: SUPER_ADMIN, isServiceAccount: true } |
Result: request.user = GatewayUser | ServiceAccount.
Token refresh flow
Entry: POST /auth/refresh { refreshToken } → TokenService.refreshAccessToken(refreshToken).
| # | Step | Detail |
|---|---|---|
| 1 | jwtService.verify(token, JWT_REFRESH_SECRET) | → JwtPayload |
| 2 | prisma.user.findUnique(payload.sub) | must be ACTIVE |
| 3 | prisma.userRoleAssignment.findFirst(userId) | — |
| 4 | prisma.organizationMember.findFirst(userId) | → org.slug |
| 5 | generateTokens(userWithRelations) | → new AuthResponse |
Implicated Code
ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:63—GatewayAuthGuard implements CanActivate;loadApiKeys()at line 82 reads all 8GATEWAY_API_KEY_*env vars; source-spoofing check at line 189 (security review 2026-04-18, Critical #2)ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:244—buildUserContext(): trusted-frontend detection (ddx-*prefix +nextjslegacy alias),GatewayUservsServiceAccountconstructionddx-api/src/platform/auth/guards/gateway-auth.guard.ts:334—validateClinicId():isOrganizationIdentifier()format check +OrganizationService.getOrganizationId()DB existence checkddx-api/src/platform/auth/services/token.service.ts:7—JwtPayloadinterface;generateTokens()at line 72: role extraction fallback chain, slug resolution,jtiunique IDddx-api/src/platform/auth/services/token.service.ts:137—prisma.session.create(): persists both tokens with 30-dayexpiresAtddx-api/src/platform/auth/services/token.service.ts:181—verifyAccessToken()/verifyRefreshToken():JwtService.verify()with separate secretsddx-api/src/platform/auth/auth.service.ts:33—AuthServiceorchestrator delegates to 7 sub-services:AuthenticationService,RegistrationService,PasswordService,EmailVerificationService,TokenService,FhirIntegrationService,SessionManagementServiceddx-api/src/platform/auth/guards/jwt-auth.guard.ts—JwtAuthGuard extends AuthGuard('jwt')— used on direct-JWT endpointsddx-api/src/platform/auth/guards/roles.guard.ts:14—RolesGuard: readsROLES_KEYmetadata from Reflector; service accounts must still satisfy required rolesddx-api/src/platform/auth/strategies/jwt.strategy.ts— Passport JWT strategy; extracts Bearer tokenddx-api/src/platform/auth/decorators/public.decorator.ts—@Public()setsIS_PUBLIC_KEYmetadata; GatewayAuthGuard reads it at line 125
Operational Notes
Env vars (required):
| Var | Purpose |
|---|---|
JWT_SECRET | Access token signing key |
JWT_EXPIRES_IN | Default 1h |
JWT_REFRESH_SECRET | Refresh token signing key (separate from access) |
JWT_REFRESH_EXPIRES_IN | Default 7d |
GATEWAY_API_KEY_NEXTJS | ddx-web server → NestJS trusted gateway key |
GATEWAY_API_KEY_SEEDER | seeder → NestJS |
GATEWAY_API_KEY_VOICE | LiveKit Python agent → NestJS |
GATEWAY_API_KEY_VIVOXX | vivoxx agent → NestJS |
API_KEY | Legacy primary key (kept for backward compat) |
Known security posture:
- Source-spoofing is blocked since the 2026-04-18 security review: if
X-Gateway-Sourceis supplied in a request header, it MUST match the registered source for the API key (gateway-auth.guard.ts:189). A holder ofGATEWAY_API_KEY_TESTcannot spoofddx-vivoxxsource. - Service accounts (
isServiceAccount: true) receiverole: SUPER_ADMINby construction (buildUserContext()line 296). This is intentional for seeder/cron operations but means service accounts bypass@Roles()checks that don't explicitly listSUPER_ADMIN. Downstream:DynamicPermissionGuardblocks wildcard*on mutations. @Public()completely bypassesGatewayAuthGuard. Routes marked@Public()must have an independent auth mechanism (e.g.SseControllervalidatesX-API-Keyinline) or be genuinely public (health, login, email-verify).
RBAC bootstrap: on a fresh deploy the permissions and role_permissions tables must be seeded before any non-SUPER_ADMIN user can authorize. See ddx-api/CLAUDE.md §RBAC Bootstrap on Fresh Deploy.
Session cleanup: AuthService.cleanupExpiredSessions() deletes rows where expiresAt < now. Wire this to a cron job (@nestjs/schedule) for production — default session storage is in ddx_api_main.
Detailed auth flow: ddx-api/docs/GATEWAY_AUTH_ARCHITECTURE.md (full deep-dive); JWT structure: ddx-api/docs/TENANT_CREATION.md.
Related Topics
- auth-api-token.md — the second authentication modality: user-facing LLM API keys (different from gateway keys)
- rbac-roles.md — role enforcement layer that reads
request.userbuilt by this guard - tenant-isolation.md —
TenantIsolationInterceptorreadsrequest.user.organizationIdset here - sse-event-engine.md — SSE endpoint opts out of
GatewayAuthGuardvia@Public()and validates the API key inline