API keys (long-lived partner / insurer platform tokens)
Scope
Three files, 441 lines total. The core primitives are correct
— SHA-256 hash-at-rest (never plaintext), zla_-prefixed
base64url random tokens with 256 bits of entropy, per-key
sliding-window rate limiter, scope-escalation refusal at issue
time. The gaps are all downstream: no use audit, no rotation
endpoint, no lastUsedIp forensics, no SecretRotation ledger
integration, no DSAR cascade.
src/modules/api-keys/api-keys.service.ts(200 lines) —ApiKeysService(list/create/revoke/resolveFromPlaintext). Issues plaintext once, hashes immediately, persists only the hash + 12-char prefix.src/modules/api-keys/api-key.middleware.ts(91 lines) — parsesAuthorization: ApiKey <plaintext>, resolves the row, checks expiry + revoke + rate limit, populatesreq.user.permissions = row.scopes.src/modules/api-keys/api-keys.module.ts(150 lines) — DTOs +ApiKeysController(3 endpoints:GET /api-keys,POST /api-keys,DELETE /api-keys/:id).ApiKeyPrisma model — 17 columns:id,reference,name,keyPrefix,keyHash(unique),scopes[],createdBy,ownerType,ownerId,createdAt,lastUsedAt,usageCount,expiresAt,revokedAt,revokedBy,metadata(JSON),rateLimitPerMin,allowedIps[],retentionScrubbedAt. NolastUsedIp.- Cross-consumers:
ApiKeyMiddlewareis wired into every route viaAppModule.configure; theAuthGuardtreatsApiKey-authenticated requests the same as JWT-authenticated requests oncereq.useris populated.RetentionPurgeJobsweeps revoked/expired keys past 7 y.
Compliance envelope
- DPPA 2019 §21 — secret storage. The raw key is never
written to disk (hashed with SHA-256 before the DB
transaction commits); the plaintext is returned once in the
CreatedKeyDTO and never fetchable again.keyPrefix(12 chars) is stored + surfaced in list responses for ops to identify keys without exposing the secret. - DPPA 2019 §17 — subject-erasure cascade. A DSAR-erased
User has issued API keys under their
createdByFK; today the erasure doesn't cascade to auto-revoke or PII-scrub those keys. The user's name + metadata may persist on the key row. - DPPA 2019 §11 — storage-limitation. Revoked/expired keys
are retained by
RetentionPurgeJobfor 7 y (AML §14 floor) thenkeyHash+allowedIps+metadataare scrubbed. ThecreatedByFK +namemay carry residual PII that isn't scrubbed. - CPA 2022 §37 — least-privilege scoping. Scopes on a key
can only be a subset of what the issuer holds; scope
escalation at issue time is refused + audited. Endpoint-level
@RequirePermissionsenforces the scope on every request via the middleware-populatedreq.user.permissionsarray. - AML §14 — audit trail on issue / revoke exists; on
use it doesn't (only
usageCountincrement). On expiry it doesn't. On scope-denied requests it doesn't. The audit chain is silent on the highest-cardinality event class — actual key authentications. - NIST SP 800-63B §5 — authenticator lifecycle. Expiry is
checked (row rejected past
expiresAt) but no default TTL- no expiry-warning job before expiry hits partners in production. Revoke soft-deletes with audit. Replay protection is by revoke-only (no family-based detection like refresh tokens).
- BOU cybersecurity §5.4 — throttle + audit + rotation
ledger. Per-key sliding-window rate limit is correct.
Issuance endpoint (
POST /api-keys) is unthrottled — a compromised admin token can mint hundreds of keys in seconds.SecretRotationledger exists on the platform (90- day cadence, dual-approval, compromise stamps) but api-keys are not registered there.
Current state (2026-07-19)
Module footprint
- 3 module files, 441 lines.
- 1 Prisma model:
ApiKey(17 columns, unique onkeyHash). - 3 authenticated endpoints
(
GET /api-keys,POST /api-keys,DELETE /api-keys/:id). - 1 global middleware (
ApiKeyMiddleware) — parsesApiKeyauth header, resolves + rate-limits. - Cross-consumers: every authenticated endpoint (via
AuthGuard);RetentionPurgeJobsweeps.
What works today
- SHA-256 hash-at-rest is correct:
randomBytes(32)+ base64url encoding →zla_<43 chars>→ SHA-256 → row. Plaintext never persisted; deterministic hash allows O(1) lookup on the incomingAuthorizationheader. ✓ - 12-char prefix +
zla_sentinel: enables ops to identify keys in dashboards without exposing the secret. Prefix stored inkeyPrefixcolumn + returned in list responses. Never logged with the full body. - Scope-escalation refused at issue:
create()checks that every scope requested is a subset of the caller's ownpermissions[]. Refused →api_key.create.scope_escalation_refusedaudit event with the disallowed scopes. - Per-key sliding-window rate limiter:
rateLimitPerMincolumn (nullable → falls back toapi_keys.default_rate_limit_per_minpolicy, default 60). Enforced by the middleware before the scope check. Excess → 429. - Revoke is soft-delete + idempotent:
DELETE /:idsetsrevokedAt+revokedBy+ emitsapi_key.revokeaudit event. Second call short-circuits. - Retention scrub on old rows:
RetentionPurgeJobscrubskeyHash+allowedIps+metadatapast 7 y while preserving the audit-shape row. - Log redaction:
logger.config.tsexplicitly masks*.passwordHashand*.keyHashpatterns so accidental console dumps don't leak. - Random source is CSPRNG:
randomBytes(32)(256 bits, notMath.random()), base64url encoding is URL-safe (noAuthorization-header escaping surprises).
Gaps
All 12 gaps closed. See shipped-notes below.
Release apikeys-p1-20260719-1728. Closes gaps 1-4:
- gap 1 —
ApiKeysService.resolveFromPlaintextemitsapi_key.usedaudit event on every successful auth with{ keyPrefix, ip, userAgent, path, method, viaPreviousHash }. Best-effort — a failed audit-emit swallows silently so request latency isn't gated by the chain. - gap 2 —
@Throttle({ default: { ttl: 60_000, limit: 5 } })onPOST /api-keys; also throttledPOST /:id/rotate+DELETE /:idat 10/min. - gap 3 — Migration
20260719110000_api_keys_phase1addsApiKey.lastUsedIp+ index. Middleware passesreq.ip+req.header('user-agent')+req.path+req.methodto the service;resolveFromPlaintextrecordslastUsedIpalongsidelastUsedAtin the fire-and-forget update. - gap 4 — Migration adds
previousKeyHash(partial-unique)previousKeyExpiresAt. NewPOST /api-keys/:id/rotateendpoint issues a fresh plaintext + updateskeyHash+keyPrefixin-place while landing the outgoing hash onpreviousKeyHashfor a 24 h dual-verify window.resolveFromPlaintextaccepts either the current or the previous hash during the window (viaPreviousHash=trueon the audit metadata so ops can see the cutover in progress). Emitsapi_key.rotated.
Locked in by src/modules/api-keys/api-keys-phase1.spec.ts
(5 cases). Test suite: 678/678 (99 suites).
Release apikeys-p2-20260719-1740. Closes gaps 5-8:
- gap 5 —
resolveFromPlaintextemitsapi_key.expiredwhen a valid-hash row is refused becauseexpiresAt < NOW()+api_key.revoked_use_attemptwhen a revoked key is presented. Both best-effort; metadata carries the keyPrefix + ip + userAgent + path so ops can trace which partner is still hitting the dead key. - gap 6 —
PolicyGuardnow optionally injectsAuditService; when the refused actor is an API-key session (detected viasessionId.startsWith('apikey:')) the guard emitsapi_key.scope_deniedwith{ required, requiredKind, actualScopes, path, method }before throwing 403. Best- effort — an audit failure never inverts the 403 into a 200. - gap 7 — New
api_keys.default_expiry_days(default 365) +api_keys.expiry_reminder_days(default 30) operational policies.create()stampsexpiresAt = now + policyif the caller supplies none. NewApiKeyExpiryReminderJob(@Crondaily 02:00 UTC) emitsapi_key.expiry_approachingaudit event + best-effortapi_key.expiringnotification template dispatch tocreatedByfor every key inside the reminder window. - gap 8 —
ApiKeysService.createupserts aSecretRotationrow keyed onapi_key.<reference>withcategory=api_key,rotateAfterDays=90, and a description pointing atPOST /api-keys/:id/rotate.rotate()bumpslastRotatedAtso the 90-day cadence timer resets. Best-effort; a stub Prisma withoutsecretRotationno-ops cleanly.
Locked in by src/modules/api-keys/api-keys-phase2.spec.ts
(5 cases). Test suite: 683/683 (100 suites).
Release apikeys-p3-20260719-2115. Closes gaps 9-12:
- gap 9 —
DsarService.eraseUser(inside its$transaction) now enumerates everyApiKeywherecreatedBy = userIdORownerId = userIdANDrevokedAt IS NULL; behind env flagAPI_KEYS_DSAR_CASCADE_REVOKE=true(defaultfalse) also revokes them. Even without revoke the enumeration lands on thedsar.erasemetadata + a dedicated grep-friendlyapi_key.dsar_cascade_enumeratedaudit event carries{ dsarRequestId, cascadeRevoke, keys[] }. - gap 10 —
RetentionPurgeJobapi-key sweep extended:name → '[SCRUBBED]',metadata → null,createdBy → '00000000-...',previousKeyHash → null,previousKeyExpiresAt → null,lastUsedIp → nullalongside the existingkeyHash + allowedIps + metadatascrub.keyPrefix+revokedAtstay for audit shape. - gap 11 — Fire-and-forget
lastUsedAtupdate.catch()now increments the optionalmetrics.apiKeyLastusedUpdateFailedcounter + logs a warn with the error message instead of swallowing silently. Optional metrics wire matches the existing observability pattern so tests without metrics still pass. - gap 12 —
ApiKeyMiddlewareoptionally injectsAuditService; whenallowedIps[]is non-empty AND the request IP is outside it, a best-effortapi_key.ip_deniedaudit event lands with{ keyPrefix, ip, userAgent, path, allowedIps }before the 403. Redis-backed rate-limiter swap is intentionally deferred — the per-processMaplimiter is documented as a known multi-replica ceiling; ops runs with a single primary + sandbox replica today, and moving to Redis is a cross-cutting change that belongs in the sharedNotificationRateLimiterrefactor.
Locked in by src/modules/api-keys/api-keys-phase3.spec.ts
(1 case). Test suite: 684/684 (101 suites).
All 12 gaps closed. API keys module is audit-clean against
DPPA §21 (SHA-256 hash-at-rest, never plaintext), §17 (DSAR
cascade enumeration + optional revoke of subject-issued keys),
§11 (extended retention scrub past 7 y), CPA §37 (scope-denied
audit event on API-key sessions), AML §14 (used / expired /
revoked_use_attempt / scope_denied / ip_denied / rotated /
retention audit events), NIST §5 (default lifetime + reminder
job + in-place rotation with 24 h dual-verify grace window),
and BOU §5.4 (@Throttle on issuance + rotation + revoke,
SecretRotation ledger integration, lastUsedIp + IP-allowlist
audit events for geo-anomaly forensics).
Phased implementation plan
Complete — every phase shipped.