CORS & Security
Headers, CSRF, Helmet, and CSP for auth-mode browsers.
The auth flow uses two custom request headers and (in cookie mode) browser cookies. CORS misconfigurations are the #1 cause of "auto-refresh isn't working" tickets.
Required CORS headers
Whatever CORS solution you use (@nestjs/common's enableCors, cors middleware, a reverse proxy), your allowedHeaders must include:
| Header | Why |
|---|---|
Content-Type | Standard JSON request bodies |
Authorization | Bearer tokens in header mode |
x-access-token-type | Auto-detect signal between header and cookie mode |
nest_auth_device_trust (or your trustDeviceHeaderName) | Trusted-device tokens for MFA |
x-csrf-token (or your security.csrf.headerName) | CSRF double-submit token in cookie mode |
Plus any custom headers you've added (tracing, app-version).
Cookie mode + CORS
If accessTokenType: 'cookie':
credentials: trueis mandatory (server side).origin: '*'is forbidden — pick explicit origins.- The frontend's HTTP layer must send
credentials: 'include'. The library'sFetchAdapterdoes this automatically when in cookie mode.
If any of these are wrong, the browser silently strips the cookie and you'll see auth fail with no useful console message.
CSRF protection
In header mode (Bearer), CSRF is not an issue — no cookie is auto-attached, so cross-site requests can't piggyback your auth. The built-in protection leaves Bearer/header requests untouched.
In cookie mode, the session cookie is ambient (the browser attaches it to any request a malicious page triggers), so state-changing requests need CSRF protection. The library ships it — enable security.csrf:
How it works. On login (and refresh) the server sets a non-httpOnly nest_auth_csrf cookie. Your SPA reads that token and echoes it in the x-csrf-token header on every POST/PUT/PATCH/DELETE. The server accepts the request only when the header matches the cookie (a double-submit token — an attacker can't read the victim's cookie to forge the match) and, when allowedOrigins is set, the Origin/Referer is allowed. GET/HEAD/OPTIONS are never blocked. This applies to both the main API (cookie mode) and the admin console.
Getting the token in the SPA. Same-domain, the SPA can read the non-httpOnly cookie directly. Cross-domain (sameSite: 'none'), call GET /auth/csrf with credentials — it returns { csrfToken, headerName, cookieName } and sets the cookie:
Add x-csrf-token to your CORS allowedHeaders (see above). And still prefer sameSite: 'lax'/'strict' over 'none' — the library warns at boot when cookie auth (or a sameSite: 'none' cookie) is configured without security.csrf.enabled.
Rate limiting
The sensitive endpoints — login, signup, forgot-password, passwordless/OTP send and verify, MFA verify, and admin login — can be throttled to blunt brute-force and abuse. Opt in with security.rateLimit:
- Keys.
keyBy: 'both'(default) enforces an IP limit and a per-account (email/phone) limit, so an attacker can't dodge it by rotating either. Setapp.set('trust proxy', …)correctly soreq.ipis the real client IP behind a proxy/load balancer. - Response. Over-limit requests get
429 Too Many Requestswith aRetry-Afterheader and{ code: 'RATE_LIMITED', retryAfter }. - Multi-instance. The default store is in-memory (per-process). Behind more than one instance, supply a shared
store(e.g. Redis-backed) that implements the smallIRateLimitStoreinterface —increment(key, windowMs) => { count, resetAt }— so limits are global. - The limiter runs before authentication, so unauthenticated brute-force (login/signup/forgot-password) is covered.
Account lockout
Soft lockout complements rate limiting: after maxFailedAttempts failed logins for an account within window, further login attempts are rejected for lockDuration.
Keyed by identifier + IP, so a failing attacker can't lock a victim's logins from other IPs (avoids lockout-DoS); a successful login clears the counter. Over-limit → 429 with Retry-After and { code: 'ACCOUNT_LOCKED' }. In-memory / per-instance.
CAPTCHA
Provider-agnostic CAPTCHA on abuse-prone routes (signup, forgot-password). You supply verify — call Turnstile / hCaptcha / reCAPTCHA's siteverify:
The client sends the token in the x-captcha-token header (or a captchaToken body field). Missing → 400 CAPTCHA_REQUIRED; invalid → 400 CAPTCHA_FAILED. Add x-captcha-token to your CORS allowlist.
Helmet
Recommended headers via Helmet:
Why both Bearer and cookie auth in Swagger
When accessTokenType: null (auto-detect), the /auth/* endpoints accept either. Register both security schemes in Swagger so the docs let you "Try it out" with either:
Trusted-device header collisions
trustDeviceHeaderName defaults to nest_auth_device_trust. If that name conflicts with another header in your stack, change it via mfa.trustDeviceStorageName and update your CORS allowlist accordingly. See the custom-trusted-device-header recipe.
TLS
- Enforce HTTPS at the load balancer.
- Set
cookieOptions.secure: true— never serve auth cookies over HTTP. - HSTS preload list submission for production hosts.