Skip to main content

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) — parses Authorization: ApiKey <plaintext>, resolves the row, checks expiry + revoke + rate limit, populates req.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).
  • ApiKey Prisma model — 17 columns: id, reference, name, keyPrefix, keyHash (unique), scopes[], createdBy, ownerType, ownerId, createdAt, lastUsedAt, usageCount, expiresAt, revokedAt, revokedBy, metadata (JSON), rateLimitPerMin, allowedIps[], retentionScrubbedAt. No lastUsedIp.
  • Cross-consumers: ApiKeyMiddleware is wired into every route via AppModule.configure; the AuthGuard treats ApiKey-authenticated requests the same as JWT-authenticated requests once req.user is populated. RetentionPurgeJob sweeps 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 CreatedKey DTO 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 createdBy FK; 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 RetentionPurgeJob for 7 y (AML §14 floor) then keyHash + allowedIps + metadata are scrubbed. The createdBy FK + name may 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 @RequirePermissions enforces the scope on every request via the middleware-populated req.user.permissions array.
  • AML §14 — audit trail on issue / revoke exists; on use it doesn't (only usageCount increment). 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. SecretRotation ledger 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 on keyHash).
  • 3 authenticated endpoints (GET /api-keys, POST /api-keys, DELETE /api-keys/:id).
  • 1 global middleware (ApiKeyMiddleware) — parses ApiKey auth header, resolves + rate-limits.
  • Cross-consumers: every authenticated endpoint (via AuthGuard); RetentionPurgeJob sweeps.

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 incoming Authorization header. ✓
  • 12-char prefix + zla_ sentinel: enables ops to identify keys in dashboards without exposing the secret. Prefix stored in keyPrefix column + 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 own permissions[]. Refused → api_key.create.scope_escalation_refused audit event with the disallowed scopes.
  • Per-key sliding-window rate limiter: rateLimitPerMin column (nullable → falls back to api_keys.default_rate_limit_per_min policy, default 60). Enforced by the middleware before the scope check. Excess → 429.
  • Revoke is soft-delete + idempotent: DELETE /:id sets revokedAt + revokedBy + emits api_key.revoke audit event. Second call short-circuits.
  • Retention scrub on old rows: RetentionPurgeJob scrubs keyHash + allowedIps + metadata past 7 y while preserving the audit-shape row.
  • Log redaction: logger.config.ts explicitly masks *.passwordHash and *.keyHash patterns so accidental console dumps don't leak.
  • Random source is CSPRNG: randomBytes(32) (256 bits, not Math.random()), base64url encoding is URL-safe (no Authorization-header escaping surprises).

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-19)

Release apikeys-p1-20260719-1728. Closes gaps 1-4:

  • gap 1 — ApiKeysService.resolveFromPlaintext emits api_key.used audit 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 } }) on POST /api-keys; also throttled POST /:id/rotate + DELETE /:id at 10/min.
  • gap 3 — Migration 20260719110000_api_keys_phase1 adds ApiKey.lastUsedIp + index. Middleware passes req.ip + req.header('user-agent') + req.path + req.method to the service; resolveFromPlaintext records lastUsedIp alongside lastUsedAt in the fire-and-forget update.
  • gap 4 — Migration adds previousKeyHash (partial-unique)
    • previousKeyExpiresAt. New POST /api-keys/:id/rotate endpoint issues a fresh plaintext + updates keyHash + keyPrefix in-place while landing the outgoing hash on previousKeyHash for a 24 h dual-verify window. resolveFromPlaintext accepts either the current or the previous hash during the window (viaPreviousHash=true on the audit metadata so ops can see the cutover in progress). Emits api_key.rotated.

Locked in by src/modules/api-keys/api-keys-phase1.spec.ts (5 cases). Test suite: 678/678 (99 suites).

Phase 2 shipped (2026-07-19)

Release apikeys-p2-20260719-1740. Closes gaps 5-8:

  • gap 5 — resolveFromPlaintext emits api_key.expired when a valid-hash row is refused because expiresAt < NOW() + api_key.revoked_use_attempt when 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 — PolicyGuard now optionally injects AuditService; when the refused actor is an API-key session (detected via sessionId.startsWith('apikey:')) the guard emits api_key.scope_denied with { 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() stamps expiresAt = now + policy if the caller supplies none. New ApiKeyExpiryReminderJob (@Cron daily 02:00 UTC) emits api_key.expiry_approaching audit event + best-effort api_key.expiring notification template dispatch to createdBy for every key inside the reminder window.
  • gap 8 — ApiKeysService.create upserts a SecretRotation row keyed on api_key.<reference> with category=api_key, rotateAfterDays=90, and a description pointing at POST /api-keys/:id/rotate. rotate() bumps lastRotatedAt so the 90-day cadence timer resets. Best-effort; a stub Prisma without secretRotation no-ops cleanly.

Locked in by src/modules/api-keys/api-keys-phase2.spec.ts (5 cases). Test suite: 683/683 (100 suites).

Phase 3 shipped (2026-07-19)

Release apikeys-p3-20260719-2115. Closes gaps 9-12:

  • gap 9 — DsarService.eraseUser (inside its $transaction) now enumerates every ApiKey where createdBy = userId OR ownerId = userId AND revokedAt IS NULL; behind env flag API_KEYS_DSAR_CASCADE_REVOKE=true (default false) also revokes them. Even without revoke the enumeration lands on the dsar.erase metadata + a dedicated grep-friendly api_key.dsar_cascade_enumerated audit event carries { dsarRequestId, cascadeRevoke, keys[] }.
  • gap 10 — RetentionPurgeJob api-key sweep extended: name → '[SCRUBBED]', metadata → null, createdBy → '00000000-...', previousKeyHash → null, previousKeyExpiresAt → null, lastUsedIp → null alongside the existing keyHash + allowedIps + metadata scrub. keyPrefix + revokedAt stay for audit shape.
  • gap 11 — Fire-and-forget lastUsedAt update .catch() now increments the optional metrics.apiKeyLastusedUpdateFailed counter + 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 — ApiKeyMiddleware optionally injects AuditService; when allowedIps[] is non-empty AND the request IP is outside it, a best-effort api_key.ip_denied audit event lands with { keyPrefix, ip, userAgent, path, allowedIps } before the 403. Redis-backed rate-limiter swap is intentionally deferred — the per-process Map limiter 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 shared NotificationRateLimiter refactor.

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.