API Token Authentication
Audiences: developer, internal
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:
-
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 byGatewayAuthGuard. -
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 inddx_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 supportscopes(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)
└── this.apiKeys Map<string, ApiKeyEntry>
loaded from GATEWAY_API_KEY_NEXTJS, _SEEDER, _FHIR, _CRON, _VOICE, _VIVOXX, etc.
└── validates X-API-Key header
└── builds ServiceAccount (isServiceAccount: 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')
└── reads X-API-Key header
└── checks against this.validApiKeys[] (from env: API_KEYS csv)
└── returns ApiKeyUser { isServiceAccount: true, roles: [SERVICE_ACCOUNT, SUPER_ADMIN] }
ApiKeysService.validateApiKey(fullKey) (programmatic validation)
└── SHA-256 hash fullKey → keyHash
└── PrismaAiService.aPIKey.findUnique({ where: { keyHash } })
└── checks isActive, expiresAt
└── updates lastUsedAt
└── returns { 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
| Technology | Role |
|---|---|
passport-headerapikey | Passport strategy for X-API-Key header extraction |
@nestjs/passport AuthGuard('api-key') | ApiKeyGuard wraps the strategy |
Node.js crypto | crypto.randomBytes(32) for key generation; SHA-256 for hashing |
AES-256-CBC | Encrypts 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
POST /ai/api-keys { name, scopes, models, budget, rpmLimit, ... }
└── ApiKeysController → ApiKeysService.generateApiKey(userId, orgId, dto)
├── crypto.randomBytes(32) → base64url → fullKey = "ddx_<base64>"
├── keyPrefix = fullKey[0..11] (displayed prefix)
├── keyHash = sha256(fullKey) (stored for lookup)
├── [best-effort] DDXLLMService.generateKey({ budget, models, ... })
│ → litellmTokenId, litellmKey (encrypted with AES-256-CBC before store)
└── PrismaAiService.aPIKey.create({ keyHash, keyPrefix, litellmKey, ... })
└── returns ApiKeyCreatedResponseDto { fullKey, keyPrefix, ... }
⚠️ fullKey returned ONCE — not stored. Client must save it.
Key validation (programmatic)
Request header: Authorization: Bearer ddx_<key> (or X-API-Key: ddx_<key>)
└── ApiKeysService.validateApiKey(fullKey)
├── sha256(fullKey) → keyHash
├── PrismaAiService.aPIKey.findUnique({ where: { keyHash } })
├── checks: isActive === true
├── checks: expiresAt === null || expiresAt > now
└── PrismaAiService.aPIKey.update({ data: { lastUsedAt: now } })
└── returns { userId, organizationId }
Effective key resolution (LLM routing)
ApiKeysService.getEffectiveKey(userId, orgId)
Priority 1: user personal key (PrismaAiService — isActive, not expired)
Priority 2: org-wide key (PrismaAiService — userId === null)
Priority 3: org LiteLLM config (PrismaService main — organization.litellmApiKey)
→ 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/effective — getEffectiveKey() 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:1—ApiKeyGuard extends AuthGuard('api-key')— minimal wrapper, all logic in strategyddx-api/src/platform/auth/strategies/api-key.strategy.ts:38—ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy, 'api-key'); readsAPI_KEYSCSV from env at line 46;SYSTEM_ADMIN_USER_IDconstant at line 10; validate() at line 62 returnsApiKeyUser { isServiceAccount: true, roles: [SERVICE_ACCOUNT, SUPER_ADMIN] }ddx-api/src/ai/ai-core/api-keys.service.ts:22—ApiKeysService;generateSecureApiKey()at line 44:crypto.randomBytes(32),base64url,sha256;encryptLiteLLMKey()at line 61: AES-256-CBC with IVddx-api/src/ai/ai-core/api-keys.service.ts:99—generateApiKey(): best-effort LiteLLM mirror, catch block at line 155;prismaAi.aPIKey.create()at line 164ddx-api/src/ai/ai-core/api-keys.service.ts:285—getEffectiveKey(): 3-priority inheritance chain — user key → org key → org LiteLLM config from main DBddx-api/src/ai/ai-core/api-keys.service.ts:422—validateApiKey(): SHA-256 lookup,isActive+expiresAtchecks,lastUsedAtupdateddx-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/:idddx-api/src/ai/ai-core/dto/api-keys.dto.ts—CreateApiKeyDto,ApiKeyCreatedResponseDto(includesfullKey),ApiKeyResponseDto(nofullKey)
Operational Notes
Security rules:
fullKeyis returned once inApiKeyCreatedResponseDtoand never stored plaintext. If lost, the key must be revoked and regenerated.LLM_ENCRYPTION_KEYmust 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.API_KEYSCSV env var (legacyApiKeyStrategy) andGATEWAY_API_KEY_*env vars (currentGatewayAuthGuard) are separate systems. Mixing them up causes401 Invalid API keyerrors.ApiKeyStrategyassignsroles: ['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:
| Var | Purpose |
|---|---|
API_KEYS | CSV of valid keys for legacy ApiKeyStrategy |
LLM_ENCRYPTION_KEY | AES-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:
# 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.
Related Topics
- auth-jwt.md — gateway API keys are managed by
GatewayAuthGuard, documented there; JWT is the human-user auth path - rbac-roles.md —
ApiKeyStrategyreturnsroles: [SUPER_ADMIN]; howRolesGuardhandles this is documented in rbac-roles - tenant-isolation.md — API-key-authenticated service accounts require
X-Clinic-IDheader;TenantIsolationInterceptorenforces this