Security
next-api-layer includes a comprehensive security system with multiple layers of protection. This page covers all security features and their configuration.
Overview#
The security system provides:
- CSRF Protection - Prevents cross-site request forgery attacks
- Rate Limiting - Protects against brute force and DDoS attacks
- XSS Sanitization - Cleans malicious content from responses
- Audit Logging - Tracks security events for compliance and debugging
Security hardening in v0.3.0#
v0.3.0 is a security-focused release. If you are upgrading from 0.2.x, note:
- CSRF tokens are HMAC-signed and verified. Double-submit no longer trusts a bare cookie/header match — the signature is checked on every state-changing request, so a token planted on a sibling subdomain can't be forged. Token format is now
<hmac>.<randomValue>. - Web Crypto is required for CSRF. Token and secret generation use
crypto.subtle/crypto.getRandomValuesand throw if unavailable (no insecureMath.random()fallback). Works on Node.js 18+ and Edge runtimes. - Spoofed internal headers are stripped. Inbound
x-auth-user,x-refreshed-token, andx-localeheaders are removed before the proxy sets its own verified values, so a client can't impersonate a user or override the locale. - An auth redirect can no longer be bypassed by i18n. A terminal auth response (a redirect to
/login, or a401) now takes precedence over an i18n forward. - The allow-list sanitizer cleans attributes. Allowed tags can no longer carry
onerror=,style=, orjavascript:/data:URLs. - Rate-limit prefetch skip is
GET/HEAD-only, and the proxy502path no longer leakserror.message.
Breaking: if you call
createCsrfValidatordirectly, its methods are now async —await csrf.validateRequest(req). Set a stablecsrf.secretin production. See the changelog for full details.
CSRF Protection#
Cross-Site Request Forgery (CSRF) protection prevents malicious sites from making requests on behalf of authenticated users.
Configuration#
secret(strongly recommended). The key used to HMAC-sign CSRF tokens. If you omit it, a random per-process secret is generated and a warning is logged — tokens then fail to validate across restarts or multiple instances. Set a stable value (e.g. from an environment variable) in production. Web Crypto must be available or token generation throws.
When CSRF is enabled, createAuthProxy automatically sets the signed __csrf cookie on responses for authenticated requests — you don't mint tokens yourself.
Strategies#
fetch-metadata
Uses the Sec-Fetch-Site header sent by modern browsers (no token needed).
The decision is based entirely on Sec-Fetch-Site:
same-origin→ always allowedsame-site→ allowed only whentrustSameSite: true(rejected otherwise)none(direct navigation, e.g. a bookmark or typed URL) → allowed for safe methods onlycross-site, an unknown value, or a missing header → rejected for state-changing methods
Pros: No token management needed
Cons: Relies on the Sec-Fetch-Site header; on its own it rejects clients that don't send it, so pair it with double-submit via the both strategy for older clients.
double-submit
Classic double-submit cookie pattern.
How it works:
- When CSRF is enabled, the proxy sets a signed
__csrfcookie (<hmac>.<randomValue>) on authenticated responses. - The client reads that cookie and echoes it back in the
x-csrf-tokenheader on state-changing requests. - The server checks that the cookie and header match (constant-time) and verifies the HMAC signature — so a forged or tampered token is rejected even when the cookie/header pair matches.
Client-side usage:
both
Combines both strategies for maximum compatibility.
Validates:
- First checks Sec-Fetch-* headers (if available)
- Then falls back to double-submit validation
Recommended for production.
Trust Same-Site#
Controls how same-site requests are treated. same-origin requests are always trusted; same-site requests (e.g. from a sibling subdomain) are rejected by default to avoid subdomain-takeover risk. Set trustSameSite: true to accept them.
Ignore Methods#
Safe methods that don't need CSRF protection.
Rate Limiting#
Protects your API from abuse by limiting the number of requests per time window.
Configuration#
Options#
windowMs
Time window in milliseconds.
maxRequests
Maximum requests allowed per window.
keyFn
Function to generate rate limit key. Defaults to IP address.
skipRoutes
Routes to exclude from rate limiting.
skipPrefetch
Whether to skip rate-limit counting for Next.js <Link> prefetch requests.
Defaults to true. Prefetches are background navigations triggered by the
router, so counting them would burn through a user's quota before they ever
click. They are detected via the next-router-prefetch, sec-purpose,
purpose / x-purpose, and x-moz request headers.
Set it to false if you want prefetch requests to count against the limit.
Since
v0.3.0, the skip applies to safe methods only (GET/HEAD). Only those are ever prefetched, so a forged prefetch header on aPOST/PUT/etc. can no longer be used to slip past the limiter.
ipHeaders
Ordered list of headers used to resolve the real client IP for the default rate-limit key. The first header that contains a value wins. Defaults to:
This gives correct client identification behind CDNs and reverse proxies. For
x-forwarded-for, the first (left-most) address — the original client — is
used. Customize the order to match your infrastructure:
The
getClientIp(req, headerPriority?)helper that powers this is also exported, so you can reuse the same resolution logic in customkeyFnimplementations or your own middleware.isPrefetchRequest(req)andDEFAULT_IP_HEADERSare exported as well.
onRateLimited
Custom response when rate limited.
Headers#
Rate limit information is included in response headers:
XSS Sanitization#
The API client automatically sanitizes outgoing request bodies (the data you
send with POST, PUT, and PATCH) to prevent stored/persisted XSS. It follows
the same philosophy as DOMPurify: remove dangerous nodes, do not over-escape
plain text. By cleaning data before it reaches your backend, anything that gets
persisted stays safe to render later (including inside dangerouslySetInnerHTML,
v-html, [innerHTML]).
Configuration#
Modes#
| Mode | Behavior | Use case |
|---|---|---|
| strip (default) | Removes well-formed HTML tags; preserves plain text chars (', /, `, =, bare <, >, &). | Default for request bodies later rendered via JSX / templates. |
| escape | Escapes <, >, &, " to HTML entities. Leaves other chars untouched. | Strings going into raw HTML contexts (dangerouslySetInnerHTML, email templates). |
| allowList | Keeps only tags in allowedTags, strips the rest. | Rich-text / CMS content. |
Why strip is the new default#
Earlier versions defaulted to escape with an aggressive entity set (/, ',
`, = all escaped). That corrupted text like O'Reilly's Books into
O'Reilly's Books in the UI. The current defaults mirror how real-world
HTML sanitizers (DOMPurify, sanitize-html) behave: they operate at the tag level,
not at the character level.
How It Works#
All string values in the request body are sanitized before the request is sent:
Allowed Tags (for allowList mode)#
In allowList mode the attributes on the tags you allow are also cleaned: inline event handlers (onerror=, onclick=, …), style=, and any attribute whose value uses a javascript:, vbscript:, or data: scheme are stripped. So <a href="javascript:alert(1)"> keeps the <a> but drops the dangerous href.
Skip Fields#
Fields that should not be sanitized (use with caution):
Audit Logging#
Track security-relevant events for compliance, debugging, and monitoring.
Configuration#
Event Types#
| Event | Description |
|---|---|
auth:success | User successfully authenticated |
auth:fail | Authentication failed |
auth:refresh | Token was refreshed |
auth:guest | Guest token was created |
access:denied | Access to protected route denied |
csrf:fail | CSRF validation failed |
rateLimit:exceeded | Rate limit exceeded |
error | An error occurred |
Event Structure#
Logger Examples#
Console Logger
Database Logger
External Service
Security Best Practices#
Cookie Configuration#
Environment-Specific Settings#
Protect Sensitive Routes#
Rate Limit Sensitive Endpoints#
A single createAuthProxy instance has one global window (windowMs / maxRequests); there is no per-route limit map. To stop a burst on one endpoint from sharing a counter with the rest of the app, key the limiter by path so each (ip, path) pair gets its own bucket — or enforce tighter limits inside the route handler itself.
Security Headers#
next-api-layer does not inject HTTP security headers for you — that stays under your control. Set them centrally in next.config.js (applies to every response), or per-request in an afterAuth hook: