03-security-and-tenantswave: W1filled8 citations

API Token Authentication

Audiences: developer, internal

API Token Authentication

Dudoxx HMS supports two distinct kinds of API tokens that serve different purposes: infrastructure-level gateway keys (service-to-service trust) and user-facing LLM API keys (developer/integration quota management). This page covers both and the guard/strategy wiring that enforces them.

Business Purpose

Two scenarios require token-based (non-JWT) authentication in Dudoxx HMS:

  1. Service-to-service (infrastructure): Seeders, cron jobs, FHIR subscription callbacks, and LiveKit agents need to call NestJS without a human JWT. They authenticate using pre-registered GATEWAY_API_KEY_* env vars validated by GatewayAuthGuard.

  2. Developer/integration LLM API keys: Partners and clinic admins can generate personal API keys (format: ddx_<base64url-32bytes>) that grant budget-limited access to the Dudoxx LLM router (LiteLLM, port 4000). These are stored in ddx_api_ai (Prisma AI schema), SHA-256 hashed, and optionally mirrored into LiteLLM for rate-limit management.

Business outcome: A partner developer generates an API key via POST /ai/api-keys with a 90-day budget cap; their integration calls the TUCAN endpoints using Authorization: Bearer ddx_<key>; the ApiKeysService.validateApiKey() checks the SHA-256 hash against the AI database and updates lastUsedAt. Budget enforcement is delegated to LiteLLM so NestJS is not the billing bottleneck.

Audiences

  • Investor: The dual-key architecture separates infrastructure trust (env-var gateway keys, never exposed to users) from user-facing API keys (scoped, budgeted, revokable). This is a standard enterprise API management pattern that enables a future API-as-a-product offering.
  • Clinical buyer: Clinic admins can issue API keys to their internal tools (e.g. a custom EMR integration) and revoke them at any time without affecting other users. Budget limits prevent runaway LLM spend.
  • Developer/partner: Generate keys at POST /ai/api-keys. Keys are shown once (full value) then only the prefix is stored. Keys support scopes (chat, embeddings, search), models, budget, rpmLimit, tpmLimit.
  • Internal (ops/support): Gateway API keys are managed exclusively via env vars — they never appear in the database. LLM API keys are in ddx_api_ai.APIKey. validateApiKey() logs warnings on invalid/expired/inactive key attempts.

Architecture

There are two separate guard stacks for the two key types:

Stack A — Gateway API keys (infrastructure, env-var managed)

GatewayAuthGuard (APP_GUARD, global):

#ResponsibilityDetail
1this.apiKeys Map<string, ApiKeyEntry>loaded from GATEWAY_API_KEY_NEXTJS, _SEEDER, _FHIR, _CRON, _VOICE, _VIVOXX, etc.
2validates X-API-Key header
3builds ServiceAccountisServiceAccount: true, role: SUPER_ADMIN

These keys authenticate the calling service, not a user. The source field (e.g. seeder, ddx-voice-agent) distinguishes callers. Source-spoofing is blocked since the 2026-04-18 security review (see auth-jwt.md).

Stack B — LLM API keys (user-facing, database managed)

ApiKeyGuard extends AuthGuard('api-key') (per-route, not global) → ApiKeyStrategy (passport-headerapikey, 'api-key'):

#Step
1reads X-API-Key header
2checks against this.validApiKeys[] (from env: API_KEYS csv)
3returns ApiKeyUser { isServiceAccount: true, roles: [SERVICE_ACCOUNT, SUPER_ADMIN] }

ApiKeysService.validateApiKey(fullKey) (programmatic validation):

#Step
1SHA-256 hash fullKeykeyHash
2PrismaAiService.aPIKey.findUnique({ where: { keyHash } })
3checks isActive, expiresAt
4updates lastUsedAt
5returns { userId, organizationId }

Note: The Passport-based ApiKeyGuard/ApiKeyStrategy validates against the API_KEYS CSV env var — this is the legacy path. The ApiKeysService.validateApiKey() validates against the AI database — this is the current path for user-generated ddx_* keys. Both coexist during migration.

Tech Stack & Choices

TechnologyRole
passport-headerapikeyPassport strategy for X-API-Key header extraction
@nestjs/passport AuthGuard('api-key')ApiKeyGuard wraps the strategy
Node.js cryptocrypto.randomBytes(32) for key generation; SHA-256 for hashing
AES-256-CBCEncrypts the mirrored LiteLLM key stored in the AI database
PrismaAiService (ddx_api_ai)Stores APIKey records: keyHash, keyPrefix, scopes, budget, spent, litellmKey (encrypted)
DDXLLMService (LiteLLM router)Best-effort: creates mirrored key in LiteLLM for budget/rate tracking
PrismaService (ddx_api_main)Used by getEffectiveKey() to read org-level LiteLLM config

Key format: ddx_<base64url-32-bytes> — 44 characters after the prefix. First 12 characters (e.g. ddx_abc123ef) form the keyPrefix stored in plaintext for display. The full key is SHA-256 hashed for database storage — it is never stored plaintext after creation.

AES-256-CBC for the LiteLLM mirror key: the LiteLLM-issued key is encrypted with LLM_ENCRYPTION_KEY (padded/truncated to 32 bytes) before storage in ddx_api_ai.APIKey.litellmKey. IV is prepended in the <ivHex>:<ciphertext> format at api-keys.service.ts:61.

Why SHA-256 hashing for lookup: allows O(1) database lookup without storing the full key. Collision risk for 32-byte random keys is negligible.

Why LiteLLM integration is best-effort: if LiteLLM is unavailable (e.g. during dev, or a transient outage), key creation still succeeds and the key works for local operations. The generateApiKey() catch block at api-keys.service.ts:155 logs a warning and continues.

Data Flow

Key generation

Entry: POST /ai/api-keys { name, scopes, models, budget, rpmLimit, ... }ApiKeysControllerApiKeysService.generateApiKey(userId, orgId, dto).

#StepDetail
1crypto.randomBytes(32) → base64urlfullKey = "ddx_<base64>"
2keyPrefix = fullKey[0..11]displayed prefix
3keyHash = sha256(fullKey)stored for lookup
4[best-effort] DDXLLMService.generateKey({ budget, models, ... })litellmTokenId, litellmKey (encrypted with AES-256-CBC before store)
5PrismaAiService.aPIKey.create({ keyHash, keyPrefix, litellmKey, ... })
6returns ApiKeyCreatedResponseDto { fullKey, keyPrefix, ... }⚠️ fullKey returned ONCE — not stored. Client must save it.

Key validation (programmatic)

Entry: request header Authorization: Bearer ddx_<key> (or X-API-Key: ddx_<key>) → ApiKeysService.validateApiKey(fullKey).

#Step
1sha256(fullKey)keyHash
2PrismaAiService.aPIKey.findUnique({ where: { keyHash } })
3checks: isActive === true
4checks: expiresAt === null || expiresAt > now
5PrismaAiService.aPIKey.update({ data: { lastUsedAt: now } })
6returns { userId, organizationId }

Effective key resolution (LLM routing)

ApiKeysService.getEffectiveKey(userId, orgId) resolves in strict priority order:

PrioritySourceLookup
1user personal keyPrismaAiServiceisActive, not expired
2org-wide keyPrismaAiServiceuserId === null
3org LiteLLM configPrismaService main — organization.litellmApiKey

Returns EffectiveApiKeyResponseDto { source, hasKey, models, budget, spent, ... }.

Business outcome → Technical mechanism: A clinic admin creates an org-wide API key with budget: 5000. A developer on that clinic's team calls /ai/api-keys/effectivegetEffectiveKey() finds no personal key, falls through to Priority 2, and returns the org key metadata. LiteLLM tracks spending against the litellmTokenId; when spend reaches 5000 the LiteLLM router rejects calls with budget_exceeded.

Implicated Code

  • ddx-api/src/platform/auth/guards/api-key.guard.ts:1ApiKeyGuard extends AuthGuard('api-key') — minimal wrapper, all logic in strategy
  • ddx-api/src/platform/auth/strategies/api-key.strategy.ts:38ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy, 'api-key'); reads API_KEYS CSV from env at line 46; SYSTEM_ADMIN_USER_ID constant at line 10; validate() at line 62 returns ApiKeyUser { isServiceAccount: true, roles: [SERVICE_ACCOUNT, SUPER_ADMIN] }
  • ddx-api/src/ai/ai-core/api-keys.service.ts:22ApiKeysService; generateSecureApiKey() at line 44: crypto.randomBytes(32), base64url, sha256; encryptLiteLLMKey() at line 61: AES-256-CBC with IV
  • ddx-api/src/ai/ai-core/api-keys.service.ts:99generateApiKey(): best-effort LiteLLM mirror, catch block at line 155; prismaAi.aPIKey.create() at line 164
  • ddx-api/src/ai/ai-core/api-keys.service.ts:285getEffectiveKey(): 3-priority inheritance chain — user key → org key → org LiteLLM config from main DB
  • ddx-api/src/ai/ai-core/api-keys.service.ts:422validateApiKey(): SHA-256 lookup, isActive + expiresAt checks, lastUsedAt update
  • ddx-api/src/ai/ai-core/api-keys.controller.ts — REST surface: POST /ai/api-keys, GET /ai/api-keys, GET /ai/api-keys/effective, DELETE /ai/api-keys/:id
  • ddx-api/src/ai/ai-core/dto/api-keys.dto.tsCreateApiKeyDto, ApiKeyCreatedResponseDto (includes fullKey), ApiKeyResponseDto (no fullKey)

Operational Notes

Security rules:

  1. fullKey is returned once in ApiKeyCreatedResponseDto and never stored plaintext. If lost, the key must be revoked and regenerated.
  2. LLM_ENCRYPTION_KEY must be exactly 32 characters in production — the service pads/truncates to 32 bytes (padEnd(32, '0').slice(0, 32)). A key shorter than 32 chars is silently padded with zeros, weakening encryption.
  3. API_KEYS CSV env var (legacy ApiKeyStrategy) and GATEWAY_API_KEY_* env vars (current GatewayAuthGuard) are separate systems. Mixing them up causes 401 Invalid API key errors.
  4. ApiKeyStrategy assigns roles: ['SERVICE_ACCOUNT', 'SUPER_ADMIN'] to all validated API key users — this is intentional for seeder/cron operations but means API-key-authenticated calls bypass @Roles() guards that require non-admin roles.

Env vars:

VarPurpose
API_KEYSCSV of valid keys for legacy ApiKeyStrategy
LLM_ENCRYPTION_KEYAES-256 key for encrypting LiteLLM mirror keys (must be ≥32 chars)
GATEWAY_API_KEY_*Infrastructure gateway keys — validated by GatewayAuthGuard, never exposed to users

Key lifecycle:

bash
# Generate (via API)
curl -X POST http://localhost:6100/api/v1/ai/api-keys \
  -H "X-API-Key: $GATEWAY_API_KEY_NEXTJS" \
  -H "X-Clinic-ID: ddx-demo-clinic" \
  -d '{"name":"my-key","budget":1000}'

# List (prefix only — no fullKey)
curl .../ai/api-keys -H "X-API-Key: ..."

# Revoke
curl -X DELETE .../ai/api-keys/<id> -H "X-API-Key: ..."

Known gap: the ApiKeyStrategy assigns permissions: ['*'] (wildcard) to all service-account callers. DynamicPermissionGuard blocks wildcard on mutations, but any endpoint without DynamicPermissionGuard is effectively open to service accounts. This is tracked but not yet remediated.

  • auth-jwt.md — gateway API keys are managed by GatewayAuthGuard, documented there; JWT is the human-user auth path
  • rbac-roles.mdApiKeyStrategy returns roles: [SUPER_ADMIN]; how RolesGuard handles this is documented in rbac-roles
  • tenant-isolation.md — API-key-authenticated service accounts require X-Clinic-ID header; TenantIsolationInterceptor enforces this
    API Token Authentication — Dudoxx Docs | Dudoxx