Security
saaskip implements a defense-in-depth strategy through composable middleware. Each security layer runs in the proxy chain and can be independently enabled or disabled via environment variables.
Middleware Chain
All security middleware is registered in src/proxy-stack.ts and composed by src/proxy.ts. The chain follows an onion model — the first middleware is the outermost layer (wraps everything, sees the final response) and the last is the innermost (closest to next()):
Request → withSecureCookies → withIntl → withAxiom → withCsp → withCsrf → withBodySizeLimit → withArcjet → next() → Response
←─────────────────────────────────────────────────────────────────────────────────────| Order | Middleware | Purpose |
|---|---|---|
| 1 | withSecureCookies | Enforces HttpOnly, Secure, SameSite on cookies |
| 2 | withIntl | Locale detection, sets NEXT_LOCALE cookie |
| 3 | withAxiom | Request tracing, logging |
| 4 | withCsp | Content-Security-Policy header |
| 5 | withCsrf | CSRF protection via Origin check |
| 6 | withBodySizeLimit | Rejects oversized request bodies (413) |
| 7 | withArcjet | Rate limiting, bot detection, shield |
Each middleware calls next() to pass control to the next layer. If a middleware rejects the request (e.g. CSRF fail, body too large, Arcjet deny), it returns a response directly without calling next().
Why withSecureCookies is outermost
withSecureCookies modifies the response (adds HttpOnly, SameSite, Secure to every Set-Cookie header). In the onion model, response-modifying middleware must be outermost so that no other middleware can alter the Set-Cookie header after it has been secured.
If withSecureCookies were placed innermost (after withIntl), withIntl would copy the Set-Cookie header from next-intl onto the response after withSecureCookies had already run — bypassing the security attributes entirely and leaving the NEXT_LOCALE cookie without HttpOnly/SameSite.
File structure
| File | Purpose |
|---|---|
security/arcjet/middlewares/with-arcjet.ts | Rate limiting, bot detection, shield (SQLi/XSS). Bypassed if ARCJET_KEY is unset. |
security/csp/middlewares/with-csp.ts | Content-Security-Policy header with nonce generation and Sentry reporting |
security/csrf/middlewares/with-csrf.ts | CSRF protection via Origin header check on state-changing methods |
security/body/middlewares/with-body-size-limit.ts | Rejects request bodies larger than 1MB (413) |
security/cookies/middlewares/with-secure-cookies.ts | Enforces HttpOnly, Secure, SameSite=Strict on all response cookies |
security/email-whitelist.ts | isAuthorizedEmail() — restricts access to whitelisted emails (dev/testing) |
Arcjet
Arcjet provides rate limiting, bot protection, and shield (SQL injection, XSS detection).
Configuration
| Variable | Required | Description |
|---|---|---|
ARCJET_KEY | Optional | Arcjet API key. If unset, Arcjet is bypassed — the middleware passes through with no protection. |
ARCJET_ENV | Optional | development (default), production, or staging. Controls LIVE vs DRY_RUN mode. |
Rules
- Shield — Detects common web attacks (SQL injection, XSS).
DRY_RUNin development,LIVEin production. - Detect Bot — Blocks automated clients. Allows Google/Bing crawlers and curl.
DRY_RUNin development,LIVEin production. - Token Bucket — Rate limit per IP: 300 tokens/hour, capacity of 100.
DRY_RUNin development,LIVEin production.
Fail-open strategy
If the Arcjet API is unavailable (protect() throws), the middleware logs the error to Axiom and Sentry but allows the request through. This prevents Arcjet outages from blocking legitimate traffic.
Audit logging
When a request is denied, the middleware logs to both Axiom and Sentry:
- Axiom —
logger.warn('Request denied by Arcjet', { event, reason, ip, method, path, statusCode }) - Sentry —
captureMessage('Request denied by Arcjet', { level: 'warning', tags: { reason } })
The reason field is one of: bot, rate_limit, or other.
Bypass when unconfigured
If ARCJET_KEY is not set, the Arcjet client is not initialized and withArcjet calls next() immediately. No protection is applied. This is useful for local development or CI.
Content Security Policy (CSP)
The withCsp middleware sets the Content-Security-Policy header on every response.
Features
- Nonce-based script protection — Dynamic routes (e.g.
/dashboard) receive a per-request nonce. Scripts without the nonce are blocked. Uses'strict-dynamic'for compatibility. - Fallback to
'unsafe-inline'— Static routes use'unsafe-inline'forscript-srcsince no nonce is generated. - Development vs production — In development,
'unsafe-eval'and WebSocket (ws:,wss:) sources are allowed. In production,upgrade-insecure-requestsis enforced. - Sentry CSP reporting — If
NEXT_PUBLIC_SENTRY_DSNis configured, the middleware extracts the project ID and public key to build a Sentry CSP report URL. TheReporting-Endpointsheader is set accordingly.
Connected services
The CSP allows connections to:
https://*.sentry.io— Error trackinghttps://*.arcjet.com— Security ruleshttps://api.axiom.co— Logginghttps://va.vercel-scripts.com— Vercel Analyticshttps://vercel.live— Vercel live preview
CSRF Protection
The withCsrf middleware protects against Cross-Site Request Forgery using the OWASP 2023+ Origin header strategy.
How it works
- Safe methods bypass —
GET,HEAD,OPTIONSpass through without checking. - Origin check — For
POST,PUT,PATCH,DELETE, theOriginheader must matchNEXT_PUBLIC_APP_URL. - No Origin = allowed — Requests without an
Originheader (curl, server-to-server, API clients) are allowed through. These are not subject to CSRF by definition. - Mismatch = 403 — If
Originis present but doesn't match, the request is rejected with403 CSRF check failed.
This approach requires no tokens, no server-side state, and works with all modern browsers. The Origin header is set by the browser and cannot be overridden by JavaScript.
Request Body Size Limit
The withBodySizeLimit middleware rejects request bodies larger than 1MB to prevent memory exhaustion DoS attacks.
- Applies to
POST,PUT, andPATCHmethods. - Checks the
Content-Lengthheader before the body is parsed. - Returns
413 Payload Too Largeif the limit is exceeded. - Requests without
Content-Length(e.g. chunked transfer encoding) are allowed through — the runtime handles those. - Invalid
Content-Lengthvalues (non-numeric) are also rejected with413.
Secure Cookies
The withSecureCookies middleware enforces secure attributes on all cookies in the response.
Enforced attributes
| Attribute | When | Purpose |
|---|---|---|
HttpOnly | Always | Prevents JavaScript access via document.cookie (mitigates XSS cookie theft) |
SameSite=Strict | Always | Prevents cookies from being sent on cross-site requests (complements CSRF) |
Path=/ | Always | Ensures cookie is available across the entire site |
Secure | Production only | Ensures cookies are only sent over HTTPS |
In development, Secure is omitted to allow HTTP testing on localhost.
Existing attributes are preserved — the middleware does not duplicate attributes already present on a cookie (e.g. Max-Age, Expires, custom Path).
Email Whitelist
The isAuthorizedEmail() function in src/core/security/email-whitelist.ts restricts access to specific email addresses during development or testing.
Configuration
Set EMAIL_WHITELIST in your .env with comma or semicolon-separated email addresses:
EMAIL_WHITELIST=tester1@example.com,tester2@example.com;tester3@example.comBehavior
- Empty or unset — All emails are authorized (fail-open). This is the default for production.
- Set — Only emails in the whitelist are authorized.
- Validation — Invalid email entries (e.g.
not-an-email) are filtered out usingzodemail validation. - Normalization — All emails are trimmed and lowercased before comparison. Casing differences don't cause false negatives.
- Deduplication — Duplicate entries are removed via
Set.
Usage
import { isAuthorizedEmail } from '@/core/security/email-whitelist';
if (!isAuthorizedEmail(userEmail)) {
return new Response('Unauthorized', { status: 403 });
}Typical use cases: restricting sign-ups during private beta, limiting newsletter subscriptions to test addresses, gating authentication in staging.