# React Frontend Security - Security Checklist

Securing the React + Vite app and its Vercel serverless proxies: security headers, CSP, CORS, rate limiting, auth, and safe handling of secrets and user input.

Part of the TIGZIG security checklist (112 items across 12 categories, distilled from hardening 20+ live microservices). Full checklist: https://www.tigzig.com/security

### 1.1. Security Headers

**THE RISK:** Without security headers, browsers don't enforce basic protections. Attackers can embed your site in an iframe to steal clicks, trick browsers into executing files as the wrong type, or downgrade your HTTPS connection. These headers are free to add and stop entire categories of attacks.

**THE SOLUTION:** Add a few lines to your hosting configuration that tell every browser visiting your site: always use HTTPS, don't guess file types, don't allow framing, and don't leak the full URL when navigating away. These are one-time settings that protect every page on your site automatically.

**THE FIX:**
```
// vercel.json - add to headers array
{
  "source": "/(.*)",
  "headers": [
    { "key": "Strict-Transport-Security",
      "value": "max-age=31536000; includeSubDomains" },
    { "key": "X-Content-Type-Options", "value": "nosniff" },
    { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
    { "key": "Referrer-Policy",
      "value": "strict-origin-when-cross-origin" }
  ]
}
```

*WARNING - EMBEDDING GOTCHA: Use DENY if your app should never be embedded. But if your app IS embedded by your own parent site (e.g., myapp.com embeds dashboard.myapp.com), REMOVE X-Frame-Options entirely and use CSP frame-ancestors instead (see 1.2). X-Frame-Options only supports DENY or SAMEORIGIN - it cannot whitelist specific external domains. Having both X-Frame-Options: DENY and frame-ancestors set will block embedding even if frame-ancestors allows it, because browsers enforce whichever is stricter.*

### 1.2. Content Security Policy (CSP)

**THE RISK:** Without CSP, any XSS vulnerability can load external scripts, send data to attacker-controlled servers, or embed your page in malicious iframes. CSP is the most powerful browser-side defense - it tells the browser exactly which sources are allowed for scripts, styles, images, and connections.

**THE SOLUTION:** You add a Content Security Policy header that acts like a whitelist for your browser. You tell it: only run scripts from my own domain, only load fonts from Google Fonts, only connect to my API servers, and don't let anyone embed my page in a frame. If anything else tries to load, the browser blocks it automatically.

**THE FIX:**
```
// vercel.json - add alongside other security headers
{ "key": "Content-Security-Policy",
  "value": "default-src 'self'; script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://*.yourdomain.com;
  frame-ancestors 'self'" }
```

*Start with Content-Security-Policy-Report-Only to find violations without breaking your site, then switch to enforcing mode. IMPORTANT: If this app is embedded by a parent site on a different subdomain, set frame-ancestors to whitelist the parent (e.g., frame-ancestors 'self' https://*.yourdomain.com https://yourdomain.com http://localhost:*) AND remove X-Frame-Options from your security headers (item 1.1). X-Frame-Options cannot whitelist specific domains - it will override frame-ancestors and block your own parent from embedding. CSP MAINTENANCE: Every time you add a new API endpoint, analytics tool, CDN resource, or self-host an asset, update connect-src/script-src. Missing entries cause SILENT fetch failures - no console errors, just broken features. Review CSP after every architecture change. INLINE ANALYTICS: If your app uses inline analytics scripts (StatCounter, PostHog, GA), strict script-src 'self' will break them. Options: nonces (complex for static Vite builds), hashes (brittle), or skip script-src and rely on other protections. For public read-only dashboards with no user input, CSP is lower priority than rate limiting and SQL validation.*

### 1.3. CORS Origin Whitelisting

**THE RISK:** Using Access-Control-Allow-Origin: * on your API endpoints means any website on the internet can call your API from a user's browser. An attacker's site can make requests to your feedback endpoint, booking system, or any other API using the victim's browser - and your server will happily respond.

**THE SOLUTION:** Instead of allowing every website to call your API, maintain a list of your own domains that are allowed. When a request comes in, check if the caller's website is on your list. If it is, allow it. If not, reject it. This way only your own frontend can talk to your backend.

**THE FIX:**
```
// Vercel serverless function
const ALLOWED_ORIGINS = [
  'https://yourdomain.com',
  'http://localhost:5173',  // dev only
];

function getCorsOrigin(req) {
  const origin = req.headers.origin;
  if (ALLOWED_ORIGINS.includes(origin)) return origin;
  return ALLOWED_ORIGINS[0]; // safe default
}

res.setHeader('Access-Control-Allow-Origin', getCorsOrigin(req));
```

### 1.4. Rate Limiting (Serverless)

**THE RISK:** Without rate limiting, anyone can call your API endpoints thousands of times per second. This burns your serverless function quota, overloads your backend, and can be used to brute-force codes or spam your feedback/email systems. Serverless functions have no built-in rate limiting.

**THE SOLUTION:** Use a fast counter (like Redis) to track how many requests each visitor has made in the last few minutes. If someone exceeds the limit - say 10 requests per hour for a feedback form - reject additional requests with a "slow down" message. Each endpoint can have its own limit based on how sensitive it is.

**THE FIX:**
```
// Using Upstash Redis (@upstash/redis)
import { Redis } from '@upstash/redis';
const redis = new Redis({
  url: process.env.KV_REST_API_URL,
  token: process.env.KV_REST_API_TOKEN
});

const rateKey = \
```

*Typical limits: 5/15min for auth, 10/hour for feedback, 30/min for data APIs. RACE CONDITION: incr and expire are two separate HTTP calls. If the serverless function crashes between them, the Redis key has no TTL and lives forever - that IP is permanently rate-limited. Safety check: if count > 1 and TTL is -1, re-set the expiry. Or use @upstash/ratelimit which handles this atomically via Lua scripts.*

### 1.5. Login Brute Force Protection

**THE RISK:** General rate limiting still allows many password guesses per minute. A login endpoint needs a separate pattern: count only failed attempts, lock out after 5 failures, and reset the counter on a successful login. Without this, an attacker can systematically try passwords within your general rate limit.

**THE SOLUTION:** Keep a separate counter just for failed login attempts per visitor. After 5 wrong passwords, lock them out for 15 minutes. When they log in successfully, reset their failure count to zero. This is different from general rate limiting because only failures count, and a correct password clears the slate.

**THE FIX:**
```
const failKey = \
```

*The key difference from general rate limiting: only failures count, and success resets the counter. Alternative: check-first pattern (redis.get before processing) avoids incrementing on already-blocked requests, but costs an extra Redis call (~20ms). Both patterns are valid.*

### 1.6. Environment Variable Safety

**THE RISK:** In Vite, any environment variable starting with VITE_ gets bundled into your frontend JavaScript - visible to anyone who views source. If you put an API key, database URL, or secret in a VITE_ variable, it's public. This is the most common accidental secret exposure in React apps.

**THE SOLUTION:** Only use the VITE_ prefix for values that are meant to be public (like your login page domain or API URL). Keep all secrets - API keys, database passwords, private tokens - as regular environment variables without the VITE_ prefix. Those stay on the server and never reach the browser.

**THE FIX:**
```
# Safe as VITE_ (these are public anyway):
VITE_AUTH0_DOMAIN=yourapp.auth0.com
VITE_API_BASE_URL=https://api.yourdomain.com

# NEVER use VITE_ prefix for these:
BREVO_API_KEY=xkeysib-...     # server-side only
DATABASE_URL=postgres://...    # server-side only
KV_REST_API_TOKEN=...          # server-side only
```

*Also ensure no .env files are committed to git. Add .env, .env.local, .env.*.local to .gitignore. VITE GOTCHA: When using loadEnv() in vite.config.ts to read non-VITE_ env vars (for dev server proxy config), pass empty string as third parameter: loadEnv(mode, process.cwd(), ''). Default prefix is 'VITE_' which silently filters out your secrets. The loaded vars only exist in vite.config.ts (server-side), not in the frontend bundle.*

### 1.8. Source Maps and Console Stripping

**THE RISK:** Source maps expose your entire unminified source code to anyone using browser DevTools. Console.log statements in production can leak sensitive data like user IDs, API responses, and internal state. Vite disables source maps by default - don't re-enable them.

**THE SOLUTION:** Add a build setting that automatically strips out all console.log and debugger statements when your app is built for production. This removes any accidental data leaks from debug messages. Also make sure source maps are turned off so nobody can read your original source code in the browser.

**THE FIX:**
```
// vite.config.ts - strip debug logs in production
export default defineConfig({
  esbuild: {
    drop: ['debugger'],
    pure: ['console.log', 'console.info',
           'console.debug', 'console.trace']
  }
});
```

*Do NOT use drop: ['console'] - it kills console.error and console.warn too, silently swallowing all errors in production. Users see blank screens with zero feedback. Use pure: [...] for selective removal - it strips debug messages but keeps console.error and console.warn alive for real errors.*

### 1.9. Iframe Embedding Security

**THE RISK:** If your app embeds other apps in iframes without sandbox restrictions, those embedded apps get full access to your page. They can read cookies, modify the DOM, or navigate your page. Without postMessage origin validation, any page can send messages that your app trusts.

**THE SOLUTION:** When you embed another app inside your page using an iframe, add a sandbox attribute that restricts what it can do - like allowing scripts but blocking access to your cookies or navigation. Also, when receiving messages from embedded apps, always check where the message came from before acting on it.

**THE FIX:**
```
// Restrict iframe capabilities
<iframe
  src="https://embedded-app.com"
  sandbox="allow-same-origin allow-scripts
           allow-popups allow-forms"
  allow="clipboard-read; clipboard-write"
/>

// Always validate postMessage origin
window.addEventListener('message', (e) => {
  if (!ALLOWED_ORIGINS.includes(e.origin)) return;
  // handle message
});
```

*Never grant allow-popups-to-escape-sandbox or camera/microphone unless explicitly needed.*

### 1.10. URL Parameter Validation

**THE RISK:** If your React app uses URL parameters in fetch() calls or navigation without validation, an attacker can craft URLs with path traversal sequences (../../etc/passwd) or inject arbitrary paths. The browser will send whatever you put in the fetch URL.

**THE SOLUTION:** Before using any value from the URL (like a page slug or ID), check that it only contains safe characters - letters, numbers, and hyphens. If it contains anything unexpected like dots, slashes, or special characters, reject it immediately. This prevents attackers from tricking your app into loading unintended files.

**THE FIX:**
```
const slug = useParams().slug;
// Only allow alphanumeric and hyphens
if (!/^[a-z0-9\\-]+$/i.test(slug)) return <NotFound />;
fetch(\
```

### 1.11. DOMPurify for Dynamic HTML

**THE RISK:** Using dangerouslySetInnerHTML without sanitization is the #1 XSS vector in React apps. If you render HTML from blog posts, API responses, or markdown-to-HTML conversion, an attacker can inject <script> tags, <img onerror=> handlers, or javascript: links that execute in the user's browser.

**THE SOLUTION:** Use a library called DOMPurify to clean any HTML before displaying it. DOMPurify strips out dangerous elements like script tags and event handlers, keeping only safe content like paragraphs, headings, links, and images. You can also specify exactly which HTML tags and attributes are allowed.

**THE FIX:**
```
import DOMPurify from 'dompurify';

// Sanitize before rendering
<div dangerouslySetInnerHTML={{
  __html: DOMPurify.sanitize(htmlContent)
}} />

// Or restrict to specific tags
DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['p','br','strong','em','a',
                 'ul','ol','li','h1','h2','h3',
                 'code','pre','img'],
  ALLOWED_ATTR: ['href','src','alt','class'],
});
```

### 1.12. Auth0 Configuration

**THE RISK:** Misconfigured Auth0 can lead to session fixation, stale tokens, or users getting stuck in login loops. Common mistakes: not using refresh tokens (sessions expire abruptly), not handling access_denied errors (infinite redirects), and not clearing stale auth state on retry.

**THE SOLUTION:** Configure your Auth0 login to use refresh tokens so sessions don't expire unexpectedly. Store the login state in localStorage so it survives page refreshes. When a user retries login, clear any leftover state first to avoid getting stuck in a loop. Always force a fresh login prompt so users can't accidentally reuse an old session.

**THE FIX:**
```
// Auth0Provider configuration
<Auth0Provider
  domain={import.meta.env.VITE_AUTH0_DOMAIN}
  clientId={import.meta.env.VITE_AUTH0_CLIENT_ID}
  cacheLocation="localstorage"
  useRefreshTokens={true}
>

// Force fresh login
loginWithPopup({ authorizationParams: { prompt: 'login' } });

// On retry - clear stale state
localStorage.removeItem('auth0...');
sessionStorage.clear();
```

### 1.13. JWT Validation in Serverless

**THE RISK:** If your Vercel serverless function accepts a JWT token but doesn't validate it, anyone can forge a token and access protected endpoints. The token must be verified against Auth0's public keys, with issuer and audience checked. Without this, authentication is just theater.

**THE SOLUTION:** When your serverless function receives a login token, verify it against Auth0's public keys to confirm it's genuine. Also check that the token was issued by your Auth0 account (issuer) and intended for your app (audience). If any check fails, reject the request. This ensures nobody can create a fake token to access your protected endpoints.

**THE FIX:**
```
import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(\
```

*AUTH0_DOMAIN and AUTH0_AUDIENCE must be non-VITE_ server-side env vars. Real-world learning: during a security audit, one endpoint was found that checked for the Bearer prefix in the Authorization header but never actually verified the JWT signature. The code did `if (!token) return unauthorized()` - confirming a token string existed - but skipped the jwtVerify() call for certain request paths (like cron jobs and webhooks that shared the same handler). An attacker could send `Authorization: Bearer anything` and bypass auth entirely on those paths. Lesson: every code path that accepts a Bearer token must call jwtVerify() - checking that the header exists is not the same as validating the token. Audit every conditional branch in multi-purpose handlers (endpoints that serve cron, webhook, AND user requests). Related learning: public endpoints that legitimately cannot use JWT - webhooks (signature-verified) and cron (shared-secret) - must still LOG their rejects. If a bad-signature or wrong-secret probe returns 403 but writes nothing to your logs, an attacker can poke those endpoints invisibly. Log the outcome (method, path, status) for every request including the rejected ones, ideally at a thin wrapper around the handler so no reject path is missed.*

### 1.14. PII Cleanup in Server Logs

**THE RISK:** Console.log statements in serverless functions persist in Vercel's log dashboard. If you log email addresses, full request bodies, or API response data, that personal information sits in plain text in your hosting provider's logs - accessible to anyone with dashboard access and potentially subpoenaed or leaked.

**THE SOLUTION:** Only log operational information like which endpoint was called, the HTTP method, status code, and how long it took. Never log personal data like email addresses, phone numbers, or full request bodies. If you add debug logging temporarily during development, remove it before deploying to production.

**THE FIX:**
```
// WRONG - leaks PII
console.log('User:', user.email, 'Body:', JSON.stringify(req.body));

// RIGHT - log only operational data
console.log('Request received:', {
  endpoint: req.url,
  method: req.method,
  status: 200,
  ms: Date.now() - start
});
```

*If you add debug logging temporarily, remove it before committing.*

### 1.15. Cookie Auth Hardening

**THE RISK:** Cookies without proper attributes are vulnerable to theft and misuse. Without HttpOnly, any XSS can steal the cookie via JavaScript. Without Secure, the cookie is sent over plain HTTP. Without SameSite, the cookie is sent on cross-site requests (CSRF). Never store auth tokens in localStorage - any XSS can read them.

**THE SOLUTION:** When setting a cookie, add three protective flags: HttpOnly (JavaScript cannot read it), Secure (only sent over HTTPS), and SameSite (not sent when requests come from other websites). Also set an expiry time so the cookie doesn't last forever. Never store login tokens in localStorage - cookies with these flags are much safer.

**THE FIX:**
```
res.setHeader('Set-Cookie', [
  \
```

*SameSite=Lax is the sensible default for most apps. With Strict, the cookie is NOT sent when users click links TO your site from email, Slack, or search results - they appear logged out on first click and must navigate/refresh. Lax sends the cookie on top-level navigations (clicking links) but blocks cross-site POST (CSRF protection). Use Strict only for high-security scenarios (banking, admin-only tools) where the UX tradeoff is acceptable.*

### 1.16. Backend URL Exposure in Frontend

**THE RISK:** After migrating to a serverless proxy architecture, leftover backend URLs in your frontend code reveal your infrastructure. Even unused URLs are visible in DevTools or bundled JavaScript. Attackers use this to map your endpoints, probe for vulnerabilities, and replay requests with captured headers.

**THE SOLUTION:** After any architecture change (like switching from direct API calls to a serverless proxy), search your frontend code for old backend URLs and remove them. Your frontend should only reference its own domain - the serverless proxy handles forwarding to the actual backend. Also check for old config files, unused imports, and commented-out fetch calls.

**THE FIX:**
```
// WRONG - exposes backend infrastructure
const API_URL = "https://api.mybackend.coolify.app/query";

// RIGHT - calls own domain, proxy forwards
const API_URL = "/api/query";

// After architecture changes, search for leaked URLs:
// grep -r "coolify\\|hetzner\\|backend" src/
```

*Backend URLs in vite.config.ts (dev server proxy config) are NOT bundled into frontend JS - Vite strips configureServer() during build. But they ARE committed to git. For public repos, use env vars for backend URLs in vite.config.ts too. For private repos, this is acceptable. BUNDLE AUDIT: Don't just grep source files - build and grep the PRODUCTION bundle (the minified JS output). Bundlers can inline values from config files, env vars, or imported constants that look clean in source but appear in the final output. After any architecture change: npm run build, then grep the output JS for infrastructure names (AWS, RDS, Hetzner, Coolify, internal IPs, backend subdomains). Source-level cleanup alone gives false confidence.*

### 1.18. Vite Dev Server Network Exposure

**THE RISK:** Setting server.host = true in vite.config.ts (or using --host flag) binds the dev server to 0.0.0.0, exposing it to everyone on your network. Combined with dev auth stubs that bypass login (always return authenticated), anyone on the same WiFi - coffee shops, hotel networks, coworking spaces - can access your app including admin panels.

**THE SOLUTION:** Only use host: true when you actively need to test on a mobile device on your local network. Remove it when you're done. If your dev environment stubs out authentication (common for faster development), the combination of network exposure + no auth means your app is wide open to anyone nearby. Default to host: false (localhost only) which is safe even on shared networks.

**THE FIX:**
```
// vite.config.ts
export default defineConfig({
  server: {
    // host: true,  // ONLY enable for mobile testing
    host: false,    // default - localhost only (safe)
  }
});

// If you MUST use host: true on shared networks:
// 1. Don't stub auth in dev (use real Auth0 dev tenant)
// 2. Or add a simple dev password middleware
// 3. Disable when done - don't leave it as default
```

*This is especially dangerous when combined with dev auth stubs (if (isDev) return { ok: true }). The fix is simple: default to host: false, enable only when needed.*

### 1.19. Serverless Proxy Architecture

**THE RISK:** When your frontend calls a backend directly (fetch("https://api.mybackend.com/query")), you expose your backend URL to anyone who opens DevTools. Attackers can bypass your frontend entirely - calling your backend directly, replaying requests, probing for vulnerabilities, and brute-forcing endpoints. Any API key or secret needed for that call must be in the frontend code (or absent), and you have no server-side layer to add authentication, rate limiting, input validation, or logging before the request reaches your backend.

**THE SOLUTION:** Route all frontend API calls through serverless functions on your own domain (e.g., Vercel API routes at /api/query). The frontend only ever calls its own domain. The serverless function receives the request, adds the real API key from environment variables, validates input, applies rate limiting (see 1.4), and forwards to the actual backend. The backend URL and API key never reach the browser. This gives you a server-side security layer (auth, rate limiting, input validation, logging, error sanitization) without running your own server.

This pattern works for calling your own backends AND third-party APIs - the serverless function can call any external API server-side, adding your API keys and handling auth without exposing anything to the browser. It also solves CORS issues: many third-party APIs don't set CORS headers, so browsers block direct calls from frontend JavaScript. Since serverless functions run server-side, there are no CORS restrictions - the function calls the API freely and returns the result to your frontend on your own domain.

Vercel free tier allows up to 300 seconds (5 minutes) per function invocation, which covers most API calls. For operations that exceed this - large file uploads, heavy database imports, long-running data processing - use one-time tokens or signed URLs (see 10.6): the serverless function issues a short-lived token, and the frontend uses it to call the backend directly for that single operation.

**THE FIX:**
```
// WRONG - frontend calls backend directly
const resp = await fetch("https://api.mybackend.com/query", {
  headers: { "X-API-Key": "sk-secret-123" }  // exposed in browser
});

// RIGHT - frontend calls own domain
const resp = await fetch("/api/query", {
  method: "POST",
  body: JSON.stringify({ sql: query })
});

// api/query.ts (Vercel serverless function)
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_URL, token: process.env.UPSTASH_TOKEN });

export default async function handler(req, res) {
  // 1. Rate limit (see item 1.4)
  const ip = req.headers['x-forwarded-for']?.split(',')[0] || 'unknown';
  const count = await redis.incr(\
```

*This is the foundational security pattern for frontend apps. Items 1.3 (CORS), 1.4 (rate limiting), 1.13 (JWT validation), 1.14 (PII logging), and 9.3 (API keys never in browser) all build on this architecture. Vercel free tier: 300s timeout, 4.5MB payload limit. For large uploads exceeding these limits, use the one-time token pattern from item 9.6 - the serverless function issues a short-lived token, the frontend calls the backend directly with that token for the single operation. The serverless function is also where you add Upstash rate limiting (1.4), CORS origin checking (1.3), authentication verification (1.13), and error sanitization (2.6) - all server-side, all invisible to the browser.*

### 1.20. Cloudflare Turnstile (Invisible Bot Protection)

**THE RISK:** ADDITIONAL HARDENING - This is an extra layer of protection beyond what most apps implement. Standard rate limiting (items 1.4, 5.2) and authentication (section 9) already provide solid security for most use cases. Turnstile adds value specifically for high-security apps, admin dashboards, or apps where you want to make absolutely sure no automated tool can interact with your sensitive endpoints. If your app already has rate limiting + authentication and you're not dealing with targeted attacks, you can skip this.

Your login page, access code form, or any sensitive endpoint can be hit directly by scripts - someone writes a Python script or uses curl to send thousands of POST requests to your API, trying different passwords or access codes. They never open a browser, never see your UI. Your rate limiting helps, but a clever attacker can slow down their requests to stay under the limit and still brute-force their way in over time. Browser-based protections like Cloudflare's Browser Integrity Check don't help here because the attacker isn't using a browser at all.

**THE SOLUTION:** Add Cloudflare Turnstile to your sensitive forms. Turnstile is an invisible captcha - your users never see a puzzle, never click "I'm not a robot", nothing changes in their experience. Behind the scenes, when your page loads, Cloudflare's script silently checks if the visitor is using a real browser (it looks at JavaScript execution, browser APIs, mouse behavior, and dozens of other signals). If it's a real browser, it generates a one-time cryptographic token. Your frontend sends this token along with the form data. Your server then calls Cloudflare's verification API to confirm the token is real before processing anything.

The key thing to understand: this is completely invisible to real users. They fill in the form and click submit - same as before. But a script running curl or Python requests can't generate a valid Turnstile token because it's not a real browser. So the server rejects the request before it even looks at the password or access code. The attacker gets "Bot verification failed" and your actual security logic (code checking, IP blocking) is never reached.

**THE FIX:**
```
// 1. Add Turnstile script to your HTML <head>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
  async defer></script>

// 2. Create a Turnstile widget via Cloudflare API or dashboard
//    Mode: "invisible" (no UI shown to users)
//    Domains: your production domain + localhost for testing
//    You get back: a Site Key (public) and Secret Key (private)

// 3. Frontend - render invisible widget + send token with form
const widgetId = window.turnstile.render(container, {
  sitekey: 'YOUR_SITE_KEY',
  callback: (token) => { turnstileToken = token; },
  size: 'invisible',
});

// Include token when submitting the form
fetch('/api/your-endpoint', {
  method: 'POST',
  body: JSON.stringify({
    code: accessCode,
    turnstileToken: turnstileToken  // <-- add this
  })
});

// 4. Server - verify token with Cloudflare BEFORE processing
const resp = await fetch(
  'https://challenges.cloudflare.com/turnstile/v0/siteverify',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      secret: process.env.TURNSTILE_SECRET_KEY,
      response: turnstileToken,
      remoteip: clientIp,
    })
  }
);
const data = await resp.json();
if (!data.success) {
  return res.status(403).json({ error: 'Bot verification failed' });
}
// Only NOW check the actual password/code/form data
```

*Turnstile has three modes: Managed (shows a checkbox only when suspicious), Invisible (never shows anything), and Non-Interactive (shows a loading spinner briefly). For login forms and access gates, Invisible is the best choice - zero friction for users, full protection against scripts. The token expires after a few minutes, so if a user leaves the page open for a while and then submits, the token may be stale. Handle this by resetting the widget on error. Free tier: unlimited verifications, up to 1M siteverify calls per month. Create widgets via the Cloudflare dashboard (Turnstile section) or via their API.*

### 1.21. Application-Level IP Blocking

**THE RISK:** ADDITIONAL HARDENING - This goes beyond standard security practices. Most apps handle failed logins with temporary lockouts (item 1.5) and rely on Cloudflare WAF for IP-level blocking (item 5.2). Application-level permanent blocking is useful when you want zero tolerance for unauthorized access attempts - typically for private admin tools, internal dashboards, or single-user systems where any wrong attempt is suspicious by definition. For consumer-facing apps with thousands of users who might genuinely mistype their password, this would be too aggressive.

Cloudflare's WAF rate limiting (item 5.2) blocks IPs that send too many requests in a short window - but the block is temporary (typically 10 seconds to 10 minutes). fail2ban (item 8.2) works at the SSH level, not at the application level. Neither of these can permanently block an IP based on application-specific logic, like "this person entered the wrong access code" or "this account failed login 5 times." You need a way to make blocking decisions inside your application code and have those blocks persist across deploys and server restarts.

**THE SOLUTION:** Use a fast key-value store like Upstash Redis to maintain a blocklist of IP addresses. When something suspicious happens - a wrong access code, too many failed logins, a detected attack pattern - write the IP to Redis with a key like "blocked:{ip}". On every page load, check Redis first: if the IP is blocked, show a "you're blocked" screen immediately without even rendering the login form. This all happens in milliseconds because Redis is fast.

The blocking can be permanent (stays until an admin removes it) or time-limited (set a Redis expiry of 24 hours, 7 days, etc.). You decide based on your use case. For a private admin tool, permanent blocking on the first wrong attempt makes sense - any wrong guess is suspicious by definition. For a consumer app, you'd want temporary blocks after multiple failures (see item 1.5). The key difference from Cloudflare or fail2ban: this runs inside your application logic, so you can block based on business rules, not just request volume.

**THE FIX:**
```
// Redis setup (Upstash - works in serverless)
import { Redis } from '@upstash/redis';
const redis = new Redis({
  url: process.env.KV_REST_API_URL,
  token: process.env.KV_REST_API_TOKEN,
});

const BLOCKED_KEY = (ip) => \
```

*Important UX detail: while the block-check API call is in-flight, show a blank screen (not the login form). Otherwise, blocked visitors see the login page flash for a split second before the block screen appears - this looks unprofessional and tells attackers "there's a login form here, I just need to get unblocked." Also: always get the real visitor IP from the cf-connecting-ip header when behind Cloudflare (see item 2.8). The default request IP will be Cloudflare's proxy IP, not the actual visitor. For the admin unblock endpoint, protect it with a separate admin key - don't reuse your main API key.*

### 1.22. Blocking Direct Access via .vercel.app URLs

**THE RISK:** ADDITIONAL HARDENING - This matters specifically for apps hosted on Vercel behind Cloudflare. If your app isn't on Vercel, or isn't behind Cloudflare, you can skip this.

Every app deployed on Vercel gets a public .vercel.app URL in addition to your custom domain. For example, if your custom domain is myapp.com (which goes through Cloudflare), Vercel also creates something like myapp-abc123-yourteam.vercel.app. This second URL goes directly to Vercel, completely bypassing Cloudflare. That means all the Cloudflare protections you set up - WAF rate limiting (item 5.2), Browser Integrity Check (item 5.3), Bot Fight Mode, security level challenges - none of them apply when someone uses the .vercel.app URL. They're talking directly to your server with no bouncer at the door.

Anyone can discover this URL. It appears in your Vercel dashboard, in deployment logs, and can sometimes be found through DNS enumeration or by guessing the pattern (project-name.vercel.app). Once they have it, they can hit your API endpoints directly, bypassing your entire Cloudflare defense layer.

**THE SOLUTION:** Enable Standard Protection in your Vercel project settings. This is available on ALL plans, including Hobby (free). It blocks all .vercel.app URLs at Vercel's infrastructure level - visitors hitting the .vercel.app URL get redirected to Vercel's auth page (307), and only your custom domain remains publicly accessible. No code changes needed, no workarounds, no spoofing possible.

To enable: Vercel Dashboard > Your Project > Settings > Deployment Protection > select "Standard Protection". That's it. One toggle, problem solved.

After enabling, .vercel.app URLs return 307 (redirect to Vercel auth). Your custom domain (e.g., myapp.com via Cloudflare) continues to work normally. All your Cloudflare protections (WAF, rate limiting, bot protection) remain in the path since traffic goes through your custom domain.

**THE FIX:**
```
// Enable Standard Protection:
// Vercel Dashboard > Project > Settings > Deployment Protection
// Select "Standard Protection"
//
// Before: .vercel.app URLs → 200 (bypasses Cloudflare entirely)
// After:  .vercel.app URLs → 307 (redirected to Vercel auth page)
//         Custom domain    → works normally through Cloudflare
//
// This is free on all plans including Hobby.
// No code changes needed - it's an infrastructure-level block.
```

*This used to require a Pro plan ($20/month) but is now free on all plans. It's the clean, definitive solution - no redirect hacks in vercel.json, no Host header checks in your code, no spoofing possible. Just enable it and the .vercel.app bypass problem goes away entirely.*

### 1.23. SPA Catch-All Path Blocking

**THE RISK:** ADDITIONAL HARDENING - Discovered during penetration testing. This applies to any Single Page Application (React, Vue, Angular) hosted on Vercel, Netlify, or similar platforms.

SPAs use a catch-all rewrite rule that sends every URL to index.html - that is how client-side routing works. The problem is that this applies to ALL paths, including paths that automated scanners and attackers love to probe: /.env (looking for your secrets file), /.git/config (looking for your git repo), /wp-admin (looking for WordPress admin panels), /phpmyadmin, /xmlrpc.php, and so on.

None of your actual files are exposed - the server just returns your app's HTML page with a 200 OK status. But from the scanner's perspective, it sees 200 OK and thinks it found something. This creates noise in security reports and makes your app look like it might be running WordPress or has exposed secrets, when it doesn't. More importantly, a human attacker probing your site sees 200 OK responses everywhere, which encourages them to dig deeper rather than move on.

**THE SOLUTION:** Add explicit redirect rules that return 404 (Not Found) or 403 (Forbidden) for known-bad paths BEFORE the SPA catch-all rule kicks in. The key insight is that redirects in vercel.json (or _redirects on Netlify) are evaluated before rewrites, so you can intercept these scanner paths first.

You don't need to block every possible bad path - just the most commonly probed ones. The usual suspects are: /.env and variants, /.git/*, /wp-admin, /wp-login.php, /wp-content, /wp-includes, /xmlrpc.php, /phpmyadmin, and /administrator.

Bonus: if you have Cloudflare in front, its WAF may catch some of these first (like /wp-admin returning 403 automatically). The vercel.json rules act as a second layer for paths Cloudflare doesn't catch.

**THE FIX:**
```
// vercel.json - add these BEFORE the SPA catch-all rewrite
// Redirects are evaluated before rewrites, so these take priority
{
  "redirects": [
    { "source": "/.env:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/.git/:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/wp-admin/:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/wp-login.php", "destination": "/", "statusCode": 404 },
    { "source": "/wp-content/:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/wp-includes/:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/xmlrpc.php", "destination": "/", "statusCode": 404 },
    { "source": "/phpmyadmin/:path(.*)", "destination": "/", "statusCode": 404 },
    { "source": "/administrator/:path(.*)", "destination": "/", "statusCode": 404 }
  ],
  "rewrites": [
    { "source": "/api/(.*)", "destination": "/api/$1" },
    { "source": "/((?!api/).*)", "destination": "/index.html" }
  ]
}

// For Netlify, use _redirects file instead:
// /.env*    /  404
// /.git/*   /  404
// /wp-*     /  404
// /* /index.html 200

// WHAT THIS LOOKS LIKE IN PRACTICE:
// Before: GET /.env → 200 (index.html) - scanner thinks it found secrets
// After:  GET /.env → 404 - scanner moves on, nothing to see
// Before: GET /.git/config → 200 - scanner thinks repo is exposed
// After:  GET /.git/config → 404 - correctly signals "not here"
// Normal SPA routes still work: GET /dashboard → 200 (index.html)
```

*This is a cosmetic hardening measure - no actual files are leaked even without these rules, because the SPA catch-all only returns your app's HTML page, never real file contents. The value is reducing noise in security scans, discouraging manual probing, and presenting a cleaner security posture. If Cloudflare WAF is in front of your app, it may catch some of these paths first (returning 403 instead of your 404), which is equally good - the result is the same: the scanner sees a rejection, not a 200 OK.*

### 1.24. waitUntil for Reliable Background Work in Serverless

**THE RISK:** Serverless functions on Vercel (and similar platforms) terminate immediately after the response is sent. Any "fire-and-forget" work - logging to an external service, sending analytics, writing audit trails - that runs as an unawaited Promise will be killed mid-flight when the function exits. The platform doesn't wait for background Promises to resolve.

Real-world impact: during a security audit, centralized logging was found to be dropping 83% of log entries. The serverless function sent the response to the client, then tried to POST the log event to an external logging service - but the function was killed before that fetch() completed. Out of 6 test requests, only 1 was actually logged. This created a massive security blind spot: attacks, errors, and access patterns were invisible because the logs never arrived.

This is particularly dangerous for security-critical logging (access attempts, blocked IPs, auth failures) where missing logs means missing evidence of an ongoing attack.

**THE SOLUTION:** Use the waitUntil() function from @vercel/functions to tell the platform: "I've sent the response, but keep the function alive until this Promise resolves." The function continues running in the background until the logging fetch() completes, then shuts down. This is the official Vercel mechanism for background work after response.

The pattern: instead of just calling fetch() and hoping it completes, wrap it in waitUntil(). The function sends the response immediately (no latency impact for the user) but stays alive long enough for the background work to finish.

After deploying this fix, the same 6-request test showed 100% log capture - all 6 entries appeared in the database. The fix is a single function call wrapping existing code.

**THE FIX:**
```
// WRONG - fire-and-forget drops ~83% of logs
function logEvent(data) {
  // This Promise is NOT awaited - function may exit before it resolves
  fetch('https://logger.example.com/log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  }).catch(() => {});
}

export default async function handler(req, res) {
  logEvent({ endpoint: '/api/data', status: 200 });
  return res.json({ data: 'response' });
  // Function exits HERE - logEvent fetch is killed mid-flight
}

// RIGHT - waitUntil keeps function alive for background work
import { waitUntil } from '@vercel/functions';

function logEvent(data) {
  const logPromise = fetch('https://logger.example.com/log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  }).catch(() => {});

  // Tell Vercel: keep function alive until this resolves
  waitUntil(logPromise);
}

export default async function handler(req, res) {
  logEvent({ endpoint: '/api/data', status: 200 });
  return res.json({ data: 'response' });
  // Function sends response but stays alive for logPromise
}

// RESULT: 83% drop rate → 0% drop rate (100% log capture)
```

*waitUntil() is imported from @vercel/functions (install: npm i @vercel/functions). It works in both serverless functions and Edge Middleware. The background work does NOT delay the response - the user gets their response immediately, and the function continues running in the background. There is no extra cost for the background execution time on Vercel's Hobby plan. This pattern applies to any background work after response: logging, analytics, cache warming, webhook notifications, cleanup tasks. If you're using fire-and-forget fetch() calls anywhere in your serverless functions, they are almost certainly being dropped silently - wrap them in waitUntil().*

### 1.25. RPC API Surface Obfuscation

**THE RISK:** In a typical serverless architecture, each API endpoint has its own URL (/api/dashboard, /api/aws-costs, /api/duckdb). These URLs are visible in the frontend JavaScript bundle - anyone can open DevTools, read the minified JS, and build a complete map of your API surface. Even with authentication, exposed endpoint names reveal what services you use, what infrastructure you run, and which endpoints to probe for vulnerabilities. For portfolio or showcase apps published on social media, this is especially problematic - people WILL inspect the bundle.

**THE SOLUTION:** Route all API calls through a single POST endpoint (like /api/rpc) using numeric action codes. The frontend sends { a: 2, p: { type: "status" } } instead of GET /api/aws-costs?type=status. The server-side mapping from numbers to handler functions never reaches the browser. Move handler files to a directory that the hosting platform ignores (Vercel skips underscore-prefixed directories like api/_h/). After migration, delete the old endpoint files - they remain reachable even if no frontend code calls them.

The result: anyone inspecting the bundle sees only /api/rpc and a set of meaningless numbers. They can't tell what action 2 does without access to the server code.

**THE FIX:**
```
// Frontend: rpc helper (only this file knows the URL)
const RPC_URL = '/api/rpc';

export async function rpc(token, action, params, body, method) {
  return fetch(RPC_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: \
```

*After migrating to RPC, verify the bundle is clean: build and grep the minified JS for old endpoint names. React Router paths (/aws-costs as a page URL) and UI labels ("Cloudflare Analytics" as a heading) will still be visible - that's fine, they're disconnected from API endpoints. File uploads can't go through a JSON RPC endpoint - use the one-time token pattern (item 10.6): get an upload token via RPC, then upload directly to the backend server. Similarly, file downloads via <a href> links can't send JWT headers - use programmatic fetch + blob URL instead (see item 10.7 note).*

### 1.26. Noscript Fallback for SPA

**THE RISK:** Single Page Applications render entirely in JavaScript. Bots, scrapers, and users with JavaScript disabled see a blank page. Search engines may index nothing. Security scanners may report the site as empty or broken, triggering false alarms.

**THE SOLUTION:** Add a <noscript> block in your index.html that displays key information about the site - what it is, what it does, and a link to any static/server-rendered version. This gives bots and no-JS users something meaningful instead of a blank page.

**THE FIX:**
```
<!-- index.html - inside <body>, after the root div -->
<noscript>
  <div style="padding: 2rem; font-family: sans-serif;">
    <h1>My App - Description</h1>
    <p>This application requires JavaScript to run.</p>
    <p>Key features: ...</p>
    <p>Contact: email@example.com</p>
  </div>
</noscript>
```

*This is a defense-in-depth measure, not a replacement for proper SSR or pre-rendering. If SEO is critical, consider server-side rendering or static generation for key pages.*

### 1.27. Global Daily Rate Limit

**THE RISK:** Per-IP rate limiting stops a single attacker but not a distributed attack. If 100 different IPs each send 10 submissions, they all stay under per-IP limits while flooding your system with 1,000 entries. Feedback forms, email endpoints, and file upload APIs are especially vulnerable.

**THE SOLUTION:** Add a global daily counter (across all IPs) for sensitive submission endpoints. When the total exceeds a threshold (e.g., 200 submissions per day), reject further requests and send an alert email to the site owner. This catches distributed attacks that per-IP limits miss.

**THE FIX:**
```
// Serverless function - Upstash Redis global counter
const globalKey = \
```

*The daily cap should be generous enough for normal usage but low enough to catch abuse. Monitor actual daily volumes for a week before setting the threshold. The alert email on first breach is critical - you want to know about distributed attacks immediately.*

### 1.28. HTML Entity Escaping in Email Bodies

**THE RISK:** When user-supplied content (feedback form text, names, URLs) is inserted directly into an HTML email template, an attacker can inject HTML or JavaScript. If the recipient's email client renders HTML, this can lead to phishing links, invisible tracking pixels, or layout manipulation.

**THE SOLUTION:** Escape all user-supplied content before inserting it into HTML email templates. Replace &, <, >, and " with their HTML entity equivalents. This is a simple string transformation that prevents any HTML injection.

**THE FIX:**
```
// Simple HTML entity escaping function
function escHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

// Usage in email template
const emailBody = \
```

*This applies to any system that sends HTML emails with user content - feedback forms, notification emails, admin alerts. Plain-text emails don't need this, but most transactional email services render HTML.*
