Security model
What's on by default. You opt out of hardening, not into it.
Brute-force defence
Per-account lockout
Tracked on the user row (failedLoginCount, lockoutUntil). Exponential backoff:
| Consecutive failures | Lockout window | | -------------------- | -------------- | | 1 – 4 | none | | 5 | 1 minute | | 6 | 5 minutes | | 7 | 15 minutes | | 8 | 1 hour | | 9+ | 24 hours |
The lockoutUntil check runs before bcrypt.compare — locked accounts don't leak the password-correctness bit via timing.
Counter resets to 0 on any successful login.
Per-email throttle (LoginEmailThrottlerGuard)
Caps /v1/auth/password/login at 5 attempts / minute per email. Sits upstream of the lockout so the lockout itself can't be hammered.
Per-IP horizontal defence (IpFloodGuard)
Per-email throttling protects each account in isolation, but a botnet rotating through many accounts from one IP is invisible at the email-bucket level.
IpFloodGuard tracks the set of distinct emails attempted from each IP in a 5-minute window. When the IP touches more than 30 distinct accounts, further requests are rejected with 429 and an auth.flood.ip_blocked audit row is written.
The Redis SET TTL means the bucket self-prunes.
Password hygiene
HIBP — Have I Been Pwned?
Optional, enabled via HIBP_ENABLED=true. Every new password (registration + reset) is k-anonymised (SHA-1 prefix of 5 hex chars) and checked against api.pwnedpasswords.com. Passwords seen in known breaches are rejected with a structured error the UI surfaces as "this password appears in N data breaches, pick a unique one."
Defaults:
HIBP_MIN_BREACH_COUNT=1— any sighting tripsHIBP_TIMEOUT_MS=1500— HIBP downtime fails open, never blocks registrationHIBP_API_BASE— overridable for an internal mirror
Password change notifications
After every successful password change, the user receives an email with a "if this wasn't you, reset now" link. Compromise-recovery surface for credential-stuffing victims.
Failed-login + lockout notifications
- 3 failed attempts → email warning with security recommendations (enable 2FA, add a passkey). Once per hour per user.
- 5+ failed attempts → account locked → email with the lockout end time + a one-click reset link.
Debounced via Redis SETNX so a sustained burst doesn't email-spam the user.
OAuth client security
Per-client throttling (token endpoint)
Stock @nestjs/throttler keys per IP — wrong for M2M, where a fleet behind one load balancer self-starves. TokenEndpointThrottlerGuard keys per client_id for client_credentials and falls back to IP for user-flow grants.
Default: 60 mints / min per client.
Per-client throttling (revoke + introspect)
ClientIdThrottlerGuard does the same for /v1/oauth/revoke (30/min) and /v1/oauth/introspect (60/min). One misbehaving caller can't starve unrelated clients.
bcrypt timing equaliser
validateClient pays one bcrypt.compare even when the client_id doesn't exist — using a pre-computed dummy hash. Stops timing-channel enumeration of valid client_ids.
Authorization-code single-use
authorizationCode rows are claimed via a single UPDATE … WHERE used=false — Postgres' atomic single-row update guarantees only one concurrent request wins. Code-replay attempts (using the same code twice) trigger refresh-token family revocation per RFC 6819 §4.4.1.1.
Audience binding (M2M)
See Service tokens. Tokens minted with audience=A only validate at resource server A.
Secret rotation with grace window
See Service tokens. Old secret accepted alongside the new one during a configurable window so fleet rollouts don't fail.
Session security
Dual-cookie model
| Cookie | SameSite | Set when | Sent to |
| --------------------- | -------- | --------------------------------------------------------- | --------------------------------------- |
| inite.sid | lax | Same-origin / FRONTEND_URL | Same-origin only — CSRF defence intact |
| inite.sid.embed | none | Registered partner Origin (via OAuth client allowlist) | Cross-origin embedded sessions |
CSRF on the embed surface is contained by the CORS Origin allowlist — an unallowed origin can't reach the endpoint.
CSP per partner
connect-src and frame-ancestors are built per request from the OAuth client redirectUris allowlist. Partners can XHR / iframe-embed; everyone else gets blocked.
New device notification
Every login compares the (UA + IP) SHA-256 fingerprint against UserKnownDevice. New fingerprint → email the user with device + IP snippet. Catches credential stuffing where the password works but the device is new.
Audit log
oauth_audit_log table, written fire-and-forget so DB hiccups never tail-latency the OAuth flow.
Captured events
token.issued.client_credentials M2M token minted
token.issued.authorization_code user-flow access token minted
token.refreshed refresh_token grant
token.failed.invalid_credentials bad client_secret
token.failed.scope_violation requested scope not allowed
token.failed.audience_violation requested audience not allowed
token.failed.unsupported_grant grant not in allowedGrants
client.created / .updated / .deactivated / .deleted
client.secret_rotated
auth.login.password user signed in
auth.login.failed wrong password
auth.flood.ip_blocked IpFloodGuard tripped
Each row carries ts, clientId, companyId, sub, ip, userAgent, scopes, audience, success, errorMessage, metadata.requestId.
Querying
- End users see their own events at
GET /v1/auth/security/audit(also rendered in/account → Recent activity). - Operators see everything at
GET /v1/admin/audit-logwithclientId,event,success,since,untilfilters, scoped to their tenant unless superadmin.
Cryptographic agility
id_token_signing_alg_values_supported lists RS256 today. The signing path picks the algorithm from the active key's alg, so adding ES256 / EdDSA keys to the JWKS rotates the signing path without a code change.
dpop_signing_alg_values_supported advertises every algorithm the DPoP verifier accepts (ES256/384/512, EdDSA).
What to harden next
For environments that need FAPI-grade hardening:
- Flip
require_pushed_authorization_requests=trueper client → forces all/authorizerequests through/par. - Set
RP_IDto your apex domain to allow passkeys across subdomains. - Set
JWT_PRIVATE_KEYto an EC or Ed25519 key instead of HS256 (RS256 is fine for most). - Mirror HIBP locally to remove the external dependency.