Nest Authbeta

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:

HeaderWhy
Content-TypeStandard JSON request bodies
AuthorizationBearer tokens in header mode
x-access-token-typeAuto-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).

app.enableCors({
  origin: ['https://app.example.com'],
  credentials: true,
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'x-access-token-type',
    'nest_auth_device_trust',
    'x-csrf-token',
    'x-tenant-id',
  ],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  maxAge: 86_400,    // cache preflight for a day
});

If accessTokenType: 'cookie':

  • credentials: true is mandatory (server side).
  • origin: '*' is forbidden — pick explicit origins.
  • The frontend's HTTP layer must send credentials: 'include'. The library's FetchAdapter does 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:

NestAuthModule.forRoot({
  session: { accessTokenType: 'cookie' },
  security: {
    csrf: {
      enabled: true,
      // Trusted browser origins. A state-changing request whose Origin/Referer
      // is present and NOT listed is rejected (defense in depth).
      allowedOrigins: ['https://app.example.com'],
      // Optional overrides:
      // cookieName: 'nest_auth_csrf',   // the non-httpOnly double-submit cookie
      // headerName: 'x-csrf-token',     // the header your SPA echoes it in
    },
  },
});

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:

const { csrfToken } = await fetch('/auth/csrf', { credentials: 'include' }).then((r) => r.json());
await fetch('/auth/logout', {
  method: 'POST',
  credentials: 'include',
  headers: { 'x-csrf-token': csrfToken },
});

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:

NestAuthModule.forRoot({
  security: {
    rateLimit: {
      enabled: true,
      keyBy: 'both', // 'ip' | 'identifier' | 'both' (default)
      // Per-bucket overrides (built-in defaults shown):
      buckets: {
        login:            { windowMs: 60_000, max: 5 },
        signup:           { windowMs: 60_000, max: 5 },
        forgotPassword:   { windowMs: 60_000, max: 3 },
        verifyOtp:        { windowMs: 60_000, max: 5 },
        passwordlessSend: { windowMs: 60_000, max: 3 },
        mfaVerify:        { windowMs: 60_000, max: 5 },
        adminLogin:       { windowMs: 60_000, max: 5 },
      },
    },
  },
});
  • 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. Set app.set('trust proxy', …) correctly so req.ip is the real client IP behind a proxy/load balancer.
  • Response. Over-limit requests get 429 Too Many Requests with a Retry-After header 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 small IRateLimitStore interface — 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.

security: {
  lockout: { enabled: true, maxFailedAttempts: 10, window: '15m', lockDuration: '15m' },
}

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:

security: {
  captcha: {
    enabled: true,
    verify: async (token, { ip }) => {
      const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET, response: token, remoteip: ip }),
      }).then((res) => res.json());
      return r.success === true;
    },
  },
}

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:

import helmet from 'helmet';
 
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      connectSrc: ["'self'", 'https://api.example.com'],
      // Add provider domains if doing client-side OAuth:
      scriptSrc: ["'self'", "https://accounts.google.com"],
      frameSrc: ["'self'", "https://accounts.google.com"],
    },
  },
  hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
  crossOriginEmbedderPolicy: false,    // OAuth popups need this off
}));

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:

const config = new DocumentBuilder()
  .addBearerAuth()
  .addCookieAuth('accessToken')
  .build();

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.

On this page