Skip to main content

Production hardening

WIP

Every gate AuthEngine.strict() enforces, plus the threat model and the OWASP Top 10 mapping.

AuthEngine.strict()

strict() is the boot-time validator. Call it after construction; it runs synchronously, no I/O.

auth.strict({ env: 'development' })  // warns
auth.strict({ env: 'production' })   // throws AUTH/MISCONFIGURED

It is a hard error to omit this call in production - there is no way to guess your intent without it, and every production deploy of duck-auth should pin the configuration through this gate.

The gates

In production mode, strict rejects:

Transport gates

  • CookieTransport.secure: false - cookies must be sent only over HTTPS.
  • CookieTransport.sameSite: 'none' without secure: true.
  • JwtTransport with a single key (no rotation overlap possible).
  • JwtTransport HS256 secret shorter than 32 bytes.
  • JwtTransport.ttlMs > 24 hours (refresh-token rotation is the right mechanism for long-lived access).

Storage gates

  • AuthMemoryAdapter (the dev/test in-process adapter).
  • AuthMemoryLimiter (or no limiter at all).
  • A AuthSession.IStore that is not durable across process restarts.
  • An events.IBus that doesn't fan out across replicas, unless you've explicitly opted out of multi-replica.

Observability gates

  • No listener attached to the lockout event. At minimum you should be paging on or auditing every lockout.
  • No AuthWebhookDeliverer configured under SOC2 / HIPAA presets.

Compliance gates

  • HIPAA / FIPS without AuthArgon2idHasher.
  • FIPS with HS256 or RS256 JWTs.
  • HIPAA / FIPS without dataAtRest encryption.
  • HIPAA without an audit-hooked channel.
  • FIPS without mfa.enforced: true.

Provider gates

  • A configured baseUrl that doesn't match any registered provider's redirectUri host (https://app.example.com baseUrl + a https://localhost Google redirect would catch this).
  • An OAuth provider with stateSigningSecret shorter than 32 bytes.

The threat model

duck-auth's THREAT-MODEL.md is a STRIDE-mapped enumeration of every threat the library mitigates and every threat the host application must own. Highlights:

ThreatMitigation
Session fixation7-row rotation matrix; every privilege change routes through sessions.rotateOrCreate().
Username enumerationConstant-time verify path; identical error code + timing for "wrong email" and "wrong password".
Credential stuffingPer-email + per-IP rate limiters; bundled top-10k common-password rejection.
Token theft__Host- cookie prefix; HttpOnly; SameSite=Lax; tokens hashed at rest.
Sender swapDPoP (RFC 9449) for sender-constrained tokens.
CSRFDouble-submit __Host-duck-csrf cookie + X-CSRF-Token header; constant-time compare.
OAuth code interceptionPKCE-S256 always, even for confidential clients.
OAuth state forgeryHMAC-signed state parameter with 10-minute expiry.
OIDC nonce replayOne-time nonce check on every complete().
Refresh-token theftFamily-id reuse detection - single reuse revokes the whole family.
JWT alg confusionalg pinned per kid; never inferred from the JWT header.
Session-fixation on MFArotateOrCreate after every MFA pass; AAL upgrade requires new session id.
Password leak via logsAuthError.toJSON() strips 13 sensitive meta keys on every serialise.
Cross-tenant readEvery store method takes tenantId; cross-tenant requests return null.
Timing attacks (passwords, magic-links, API keys)Constant-time compare via crypto.timingSafeEqual for every credential verify.
Webhook spoofingHMAC-SHA256 signature on every outbound webhook body; constant-time verify.
Lockout DoSLimiter buckets are per-email and per-IP; an attacker can't lock out a victim by spraying their email.
OIDC auth-code injectionMandatory S256 PKCE for public clients at /authorize; verifier compared constant-time at /token.
OIDC code substitution across clientsCodes are bound to the issuing client_id; /token rejects mismatches.
OIDC refresh-token theftFamily-id rotation: each refresh use rotates; reuse triggers family revoke.
SAML XML injection in SP metadataescapeXml on every user-supplied field; tested by saml-metadata-xsd-shape.test.ts > XML injection defense.
SAML signature wrappingSignature validation delegated to @node-saml/node-saml; no bespoke XML parsing in the wrapper.

Static-analysis safety net

The duck-auth source tree runs 12 grep-style assertions at test time that enforce security invariants Python-style linters can't catch. Source: src/__tests__/static-analysis.test.ts. Examples:

  • No Math.random() in core/, providers/, oidc/, transport/. Allowed elsewhere only when used for non-cryptographic instance IDs.
  • No === comparison on field names matching tokenHash / secretHash / clientSecret / codeChallenge / code_verifier / verifier in security paths.
  • No console.log in security paths (console.error for caught throws is OK).
  • OIDC OP code generation uses randomToken, never Date.now or Math.random.
  • OAuth state and nonce paths free of Math.random.
  • CookieTransport defaults assert HttpOnly, Secure, SameSite.
  • JwtTransport verify path pins alg against the registered verify key (defense vs RFC 8725 section 3.1 alg-confusion attacks).

If a refactor introduces one of the above patterns, this suite fails at PR time before the change can reach main.

Dependency audit

bun audit is run before every release and the result is checked in at AUDIT-RESULTS.md. Current advisories live entirely in dev / build / example tooling (vite, postcss, esbuild, storybook, uuid, qs, jsonpath-plus). None affect the runtime path of @gentleduck/auth. Re-run locally with:

bun audit

OWASP Top 10 mapping

OWASPHow duck-auth addresses it
A01 - Broken Access ControlNot duck-auth's job (use @gentleduck/iam). duck-auth proves who you are; iam enforces what you may do.
A02 - Cryptographic FailuresScrypt / Argon2id; AES-GCM dataAtRest; KMS-envelope for FIPS; alg-pinned JWT.
A03 - InjectionAll stores are typed; SQL bridge uses parameterised queries; profile size-bounded to prevent payload abuse.
A04 - Insecure Designstrict() enforces secure-by-default config. Compliance presets are first-class.
A05 - Security Misconfigurationstrict() catches every known mis-config at boot.
A06 - Vulnerable and Outdated ComponentsLazy-loaded peer deps; you only pay for what you wire.
A07 - Identification and Authentication FailuresThe entire library.
A08 - Software and Data Integrity FailuresWebhook HMAC; JWT alg pinning; signed OAuth state.
A09 - Logging and Monitoring FailuresEvent bus emits every lifecycle event; OTel instrumentation ships; strict mandates lockout listener.
A10 - Server-Side Request ForgeryChannel implementations validate destination URLs; OAuth callback hosts checked against baseUrl.
import Redis from 'ioredis'
import {
  AuthArgon2idHasher,
  ARGON2ID_COMPLIANCE,
  AuthEngine,
  authCsrfGuard,
  hipaa,
  authImpossibleTravelDetector,
  AuthWebhookDeliverer,
} from '@gentleduck/auth/core'
import { JwtTransport } from '@gentleduck/auth/core/transport'
import {
  AuthRedisDPoPNonceStore,
  AuthRedisEvents,
  AuthRedisLimiter,
  AuthRedisSessionStore,
} from '@gentleduck/auth/adapters/redis'
import { authCreateSqlStores } from '@gentleduck/auth/adapters/sql'
import { createDrizzlePgBridge } from '@gentleduck/auth/adapters/drizzle/pg'

const redis = new Redis(process.env.REDIS_URL!)
const drizzleBridge = createDrizzlePgBridge(db, schema)
const sql = authCreateSqlStores({ bridge: drizzleBridge })

export const auth = new AuthEngine({
  ...hipaa({
    stores: {
      identities: sql.identities,
      credentials: sql.credentials,
      sessions: new AuthRedisSessionStore(redis),
    },
    events: new AuthRedisEvents(redis),
    channels: { email: new AuthSesChannel({ ... }) },
  }),
  baseUrl: 'https://api.example.com',
  transport: new JwtTransport({
    signKey: { kid: process.env.JWT_KID!, key: process.env.JWT_KEY! },
    verifyKeys: [
      { kid: process.env.JWT_KID!, key: process.env.JWT_KEY! },
      { kid: process.env.JWT_KID_PREV!, key: process.env.JWT_KEY_PREV! },
    ],
    issuer: 'https://api.example.com',
    ttlMs: 15 * 60 * 1000,
    refresh: { cookieName: '__Host-duck-refresh', ttlMs: 30 * 24 * 60 * 60 * 1000 },
  }),
  limiter: new AuthRedisLimiter(redis),
  passwords: { hasher: new AuthArgon2idHasher(ARGON2ID_COMPLIANCE) },
})

auth.anomaly.register(authImpossibleTravelDetector({ ... }))
auth.events.on('lockout', alertOpsTeam)

const webhook = new AuthWebhookDeliverer({
  url: 'https://audit.example.com/auth',
  secret: process.env.WEBHOOK_SECRET!,
})
auth.events.onAny((type, event) => webhook.deliver({ type, ...event }))

auth.strict({ env: 'production' })   // throws if any gate fails