# Authentication and Authorization - Security Checklist

Authentication and authorization: session and JWT handling, brute-force protection, and enforcing who can do what.

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

### 10.1. When OAuth is Mandatory vs Optional

**THE RISK:** Without clear rules for when to require authentication, developers either add friction to apps that should be frictionless (killing adoption of public demos) or skip auth on apps that handle sensitive data (creating a wide-open door). Both are expensive mistakes - one costs users, the other costs security.

**THE SOLUTION:** OAuth is mandatory when your app handles private or sensitive data, when it's for internal use or a small specific audience, when you need per-user rate limiting or audit trails, or when you need role-based access control (who can see what). It's optional for public demo apps where frictionless access drives adoption. Even for public apps, OAuth gives you benefits: user behavior monitoring, a contact list for marketing, and abuse tracking tied to real identities. The trade-off is real friction - login walls reduce casual usage. For public apps, it's your call. For private apps, there is no choice.

**THE FIX:**
```
# OAuth is MANDATORY when:
# - App handles private/sensitive data
# - Internal tools for specific team/audience
# - Per-user rate limits needed
# - Role-based access control required
#   (e.g., only certain users query certain DBs)
# - Audit trail of who did what is required

# OAuth is OPTIONAL (developer choice) when:
# - Public demo app - frictionless access matters
# - Open data - nothing sensitive exposed
# - Portfolio/showcase apps

# Even for public apps, OAuth gives you:
# - User behavior analytics
# - Marketing: email list of users
# - Abuse tracking: ban by identity, not just IP
# - Rate limiting per user (more accurate than IP)

# If public app + optional OAuth:
# Rely on the REST of the hardening stack
# (rate limits, SQL validation, CORS, etc.)
```

### 10.2. Backend Token Verification

**THE RISK:** Frontend authentication is just a UI convenience - it tells the browser who is logged in. It is NOT security. Anyone with curl, Postman, or browser DevTools can call your API directly, bypassing all frontend auth checks. If your backend doesn't independently verify the token, your API is effectively public.

**THE SOLUTION:** Your backend must verify the JWT on every single request that needs authentication. Extract the Bearer token from the Authorization header, verify its cryptographic signature against the OAuth provider's public keys (JWKS endpoint), and check the audience, issuer, and expiry claims. If any of these checks fail, reject the request with a 401. The backend should never trust the frontend - it should trust only the cryptographic proof in the token.

**THE FIX:**
```
# FastAPI - verify Auth0 JWT on every request
from jose import jwt as jose_jwt, JWTError
import httpx

async def verify_token(request: Request):
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(401, "Missing bearer token")
    token = auth.split(" ", 1)[1]

    # Fetch provider's public keys (cache in production)
    jwks_url = f"https://{AUTH0_DOMAIN}/.well-known/jwks.json"
    async with httpx.AsyncClient() as client:
        jwks = (await client.get(jwks_url)).json()

    try:
        payload = jose_jwt.decode(
            token, jwks,
            algorithms=["RS256"],
            audience=AUTH0_AUDIENCE,
            issuer=f"https://{AUTH0_DOMAIN}/",
        )
        return payload  # contains user info (sub, email)
    except JWTError:
        raise HTTPException(401, "Invalid or expired token")

# Apply to protected endpoints:
@app.get("/api/data", dependencies=[Depends(verify_token)])
```

*Cache the JWKS response - don't fetch it on every request. Refresh on restart or on a schedule.*

### 10.3. API Keys - Never in the Browser

**THE RISK:** Any API key in your frontend JavaScript is visible to everyone. Browser DevTools, View Source, or a simple network tab inspection reveals keys embedded in bundled code or sent in fetch headers. Once someone has your key, they can use your API quota, access your data, or impersonate your app - and you'll pay the bill.

**THE SOLUTION:** API keys must live server-side only - in environment variables on your hosting platform (Vercel, Coolify). Use a serverless proxy pattern: your frontend calls a Vercel API route on your own domain, and that server-side function adds the API key before forwarding the request to the actual backend. The key never reaches the browser. This is the same pattern for any secret: database URLs, signing keys, OAuth client secrets.

**THE FIX:**
```
// Frontend - calls own domain, no keys visible
const res = await fetch("/api/query", {
    method: "POST",
    body: JSON.stringify({ sql: query }),
});

// Vercel API route (api/query.ts) - adds key server-side
export default async function handler(req, res) {
    const API_KEY = process.env.BACKEND_API_KEY; // server-side only
    const response = await fetch(BACKEND_URL + "/query", {
        method: "POST",
        headers: {
            "Authorization": \
```

*Remember: VITE_ prefix variables are bundled into frontend JS and visible to everyone. Only use VITE_ for non-secret config like Auth0 domain or public API base URLs.*

### 10.4. Role-Based Access Control (RBAC)

**THE RISK:** Authentication tells you WHO someone is. Without authorization, every authenticated user can do everything - query any database, delete any file, access any admin panel. A logged-in user with no role restrictions has the same power as an admin.

**THE SOLUTION:** After verifying identity, check what each user is allowed to do. This can be as simple as an admin vs user role, or as granular as per-file or per-table permissions. Store roles in your auth provider (Auth0 app_metadata, Clerk publicMetadata) or your own database. Check the role on every request - not just in the UI. The backend must enforce roles independently, because frontend role checks are just UI cosmetics.

**THE FIX:**
```
# Simple RBAC pattern in FastAPI
async def require_admin(request: Request):
    payload = await verify_token(request)  # from 9.2
    role = payload.get("app_metadata", {}).get("role", "user")
    if role != "admin":
        raise HTTPException(403, "Admin access required")

# Granular: file-level access control
async def check_file_access(user_email: str, filename: str):
    # Query your DB: does this user have access to this file?
    allowed_files = await get_user_files(user_email)
    if filename not in allowed_files:
        raise HTTPException(403, "Access denied to this file")

# Apply to endpoints:
@app.delete("/api/files/{name}", dependencies=[Depends(require_admin)])
@app.get("/api/query/{file}", dependencies=[Depends(verify_token)])
async def query_file(file: str, request: Request):
    user = request.state.user  # set by verify_token
    await check_file_access(user["email"], file)
```

*Roles in the UI (showing/hiding buttons) are for UX only. The backend must enforce the same rules - never trust the frontend to restrict access.*

### 10.5. OAuth for MCP Servers

**THE RISK:** MCP servers give AI clients direct access to your databases and tools. An open MCP server means any AI client on the internet can query your data. For demo servers with public data, this may be acceptable (with other hardening). But for servers with private data, this is a direct path to data exfiltration.

**THE SOLUTION:** fastapi-mcp supports OAuth out of the box via its AuthConfig. The practical pattern is to run two MCP endpoints: an open one at /mcp for public demos (protected by rate limiting, SQL validation, and other hardening), and a secured one at /mcp-secure that requires Auth0 JWT authentication. The secured endpoint verifies the token on every request and can restrict access by user identity. For team use, combine this with role-based access to control which team members can query which databases.

**THE FIX:**
```
from fastapi_mcp import FastApiMCP, AuthConfig
from fastapi import Depends

# Open MCP - for public demos (relies on other hardening)
mcp_open = FastApiMCP(app, name="MCP (Open)", ...)
mcp_open.mount()  # /mcp

# Secured MCP - requires Auth0 OAuth
mcp_secure = FastApiMCP(
    app,
    name="MCP (Secured)",
    auth_config=AuthConfig(
        issuer=f"https://{AUTH0_DOMAIN}/",
        authorize_url=f"https://{AUTH0_DOMAIN}/authorize",
        audience=AUTH0_AUDIENCE,
        client_id=AUTH0_CLIENT_ID,
        client_secret=AUTH0_CLIENT_SECRET,
        dependencies=[Depends(verify_oauth_token)],
        setup_proxies=True,
    ),
)
mcp_secure.mount(mount_path="/mcp-secure")
```

*For open MCP servers: all your other hardening (rate limits, SQL validation, concurrency caps, read-only mode) still applies. OAuth is an additional layer, not a replacement.*

### 10.6. One-Time Tokens for Sensitive Operations

**THE RISK:** Some operations need to bypass the normal API flow - like large file uploads that exceed your serverless platform's payload limit (Vercel caps at 4.5MB). If you issue a long-lived or reusable token for these operations, a leaked token can be replayed indefinitely. If you skip tokens entirely and let the frontend call the backend directly, you expose your API key.

**THE SOLUTION:** Generate short-lived, single-use tokens server-side for sensitive operations. The frontend requests a token through your serverless proxy (which has the API key), receives a random token, and uses it to call the backend directly for that one operation. The backend verifies the token exists, hasn't expired, and then deletes it after use. The token works once and is gone - even if intercepted, it cannot be replayed.

**THE FIX:**
```
import secrets, time

UPLOAD_TOKENS = {}  # In production, use Redis

@app.post("/upload-token")  # called via serverless proxy
async def create_upload_token(request: Request):
    verify_api_key(request)  # only proxy has the key
    token = secrets.token_urlsafe(32)
    UPLOAD_TOKENS[token] = {
        "created": time.time(),
        "expires_at": time.time() + 600,  # 10 min
    }
    return {"token": token}

@app.post("/upload-direct/{token}")  # frontend calls directly
async def upload_direct(token: str, file: UploadFile):
    if token not in UPLOAD_TOKENS:
        raise HTTPException(401, "Invalid or expired token")
    if time.time() > UPLOAD_TOKENS[token]["expires_at"]:
        del UPLOAD_TOKENS[token]
        raise HTTPException(401, "Token expired")
    # Process upload...
    del UPLOAD_TOKENS[token]  # consume - one-time use
    return {"status": "uploaded"}
```

*For production, store tokens in Redis with TTL instead of an in-memory dict. The dict approach works but is lost on server restart.*

### 10.7. Signed URLs for File Downloads

**THE RISK:** If uploaded files are served at predictable URLs (like /files/report.csv), anyone who guesses or discovers the filename can download it. Directory listing attacks, URL enumeration, and shared links that never expire are all common vectors for unauthorized file access.

**THE SOLUTION:** Generate cryptographically signed URLs that embed the filename and an expiry timestamp. The signature prevents tampering - changing the filename or extending the expiry invalidates the URL. Set a reasonable expiry (hours to days depending on use case) so shared links don't work forever. When a user requests a file, generate a fresh signed URL and redirect them to it.

**THE FIX:**
```
from itsdangerous import URLSafeTimedSerializer

SIGNING_SECRET = os.getenv("SIGNING_SECRET")
url_signer = URLSafeTimedSerializer(SIGNING_SECRET)

def generate_signed_url(filename: str, base_url: str) -> str:
    token = url_signer.dumps({"file": filename})
    return f"{base_url}/secure-download/{token}"

@app.get("/secure-download/{token}")
async def secure_download(token: str):
    try:
        data = url_signer.loads(token, max_age=172800)  # 48h
    except Exception:
        raise HTTPException(403, "Invalid or expired link")

    filepath = UPLOAD_DIR / data["file"]
    if not filepath.exists():
        raise HTTPException(404, "File not found")

    return FileResponse(
        filepath,
        headers={
            "Content-Disposition": f'attachment; filename="{data["file"]}"',
            "X-Content-Type-Options": "nosniff",
        },
    )
```

*Always serve with Content-Disposition: attachment so the browser downloads instead of rendering. This prevents uploaded HTML/SVG files from executing in your domain's security context. JWT AUTH GOTCHA: If your download endpoint requires a Bearer token (JWT auth), you cannot use <a href> links or window.open() - browsers send plain GET requests with no Authorization header, so the download silently fails with 403. Instead, use programmatic download: fetch the file with auth headers, convert the response to a blob, create a temporary object URL, trigger a click on a hidden <a> element, then revoke the URL. Example: const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const blob = await resp.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href);*

### 10.8. Choosing an OAuth Provider

**THE RISK:** Picking the wrong auth provider costs you twice - once during setup and again when you migrate. Some providers charge for basic features (Clerk charges $25/mo for email allowlists). Some have great developer experience but less flexibility. Some are rock-solid but take longer to wire up. The wrong choice can lock you into a provider that doesn't fit your needs or charge you for features that others offer free.

**THE SOLUTION:** Auth0 is the long-term standard - 12+ years old, owned by Okta, most features, free allowlist via Actions, HIPAA-capable. It takes longer to set up (more config spread across Dashboard, Actions, APIs) but it's the most flexible and the most likely to exist in 10 years. Clerk is the fastest to set up - AI coders wire it up in minutes, excellent React components, but allowlists require the paid Pro plan. Supabase Auth is good if you're already on Supabase (50K free MAU, allowlist via RLS). Neon Auth is new (built on Better Auth), interesting for branchable auth in dev workflows, but still Beta. For quick temporary lockdowns, Clerk works fast. For everything else, standardize on Auth0.

**THE FIX:**
```
# Decision framework:

# Auth0 - default choice for production apps
# + Free allowlist (via Actions)
# + 7,500-25,000 free MAU
# + Most features, extensive customization
# + HIPAA, enterprise compliance
# + 12+ years stable (owned by Okta)
# - More wiring (15-30 min vs 5 min for Clerk)
# - Config spread across Dashboard/Actions/APIs
# - AI coders need 2-4 iterations to get it right

# Clerk - fastest setup, great DX
# + AI coders set it up in minutes
# + Excellent pre-built React components
# + 10,000 free MAU
# - Allowlist requires Pro ($25/mo)
# - Less customization than Auth0
# Good for: quick lockdowns, prototypes

# Supabase Auth - if already on Supabase
# + 50,000 free MAU (most generous)
# + Allowlist via Row Level Security
# + Integrated with Supabase DB
# - Tied to Supabase ecosystem

# Neon Auth - watch but wait
# + Branchable auth (auth branches with DB)
# + Built on Better Auth (open source)
# - Still in Beta
# - No allowlist feature yet
# - Tied to Neon ecosystem
```

*Standardize on one provider across your apps. Mixing providers creates maintenance overhead - different dashboards, different token formats, different debugging patterns. Use a second provider (like Clerk) only for quick temporary lockdowns.*

### 10.9. Pre-Authentication Access Gate (Frontend-Only - Cosmetic, Easily Bypassed)

**THE RISK:** WARNING: This item describes a FRONTEND-ONLY access gate pattern. It is cosmetic and can be easily bypassed by anyone who opens browser DevTools and fakes the API response (e.g., changing a 403 into a 200 with {granted: true}). The frontend JavaScript trusts whatever the fetch() call returns - an attacker simply intercepts the response in the Network tab. This provides zero real security against anyone with basic browser knowledge.

The recommended approach is to enforce the access gate server-side using Routing Middleware - see item 9.11 (Server-Side Access Gate via Routing Middleware). That approach checks the gate at the infrastructure level before your API code even runs. No amount of DevTools trickery can get around it. If you only implement one, implement 9.11.

If you choose not to go with the server-side approach, or if you want a lightweight cosmetic layer on top of it, the frontend pattern below still has some value. It hides the login form from casual visitors, makes your app look like a normal Google login page, and adds a psychological speed bump. It deters unsophisticated visitors who don't know about DevTools - but it stops nobody who does. Think of it as a curtain, not a wall.

**THE SOLUTION:** Add a code barrier before the OAuth login. The user sees a text input asking for an access code. They type the code and click the button. If the code is correct, they're forwarded to the normal OAuth login (Google, etc.). If the code is wrong, they're immediately blocked - no second chance, no "try again" message, just a permanent block screen.

The clever part: the button looks like a normal "Sign in with Google" button, so casual visitors think it's just a regular Google login with a password field. They don't realize the access code is checked first. This means even if someone screenshots or shares your login page, it looks like a standard Google login - not a secret access gate.

How the flow works: User enters code → frontend sends code to your serverless function → server checks code using constant-time comparison (prevents timing attacks where an attacker can guess characters based on how fast the server responds) → if correct, returns "granted" and frontend triggers the real OAuth redirect → if wrong, server blocks the IP in Redis (see item 1.21) and frontend shows a permanent block screen.

Combine this with Turnstile (item 1.20) so bots can't even attempt the code, and IP blocking (item 1.21) so humans get one shot. The result: an attacker needs to (1) be a real browser to pass Turnstile, (2) know the access code on the first try, (3) have an allowed Google account, and (4) pass Auth0's email allowlist - all without any indication of what's expected at each step.

**THE FIX:**
```
// Frontend - looks like a normal Google login with a code field
const handleAuthenticate = async () => {
  // 1. Send code + Turnstile token to server
  const resp = await fetch('/api/access-gate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      code: accessCode.trim(),
      turnstileToken: turnstileToken
    })
  });
  const data = await resp.json();

  if (data.granted) {
    // 2. Code correct - NOW trigger real OAuth
    loginWithRedirect();
  } else {
    // 3. Wrong code - show block screen
    setBlocked(true);
  }
};

// Server (Vercel serverless function)
const ACCESS_CODE = process.env.ACCESS_GATE_CODE;
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY;

export default async function handler(req, res) {
  const clientIp = getClientIp(req); // cf-connecting-ip

  // Step 1: Check if already blocked (see item 1.21)
  const blocked = await redis.get(\
```

*This pattern works best for single-user or small-team apps where any unauthorized access attempt is inherently suspicious. For consumer-facing apps, permanent blocking on one wrong attempt would be too aggressive - use temporary lockouts instead (item 1.5). The access code should be stored as a server-side environment variable (never in frontend code) and should be strong enough that it can't be guessed - alphanumeric, 8+ characters. Constant-time comparison (Buffer.from().equals() in Node.js, hmac.compare_digest() in Python) is important because without it, an attacker can figure out the code one character at a time by measuring how long the server takes to respond. Also add an admin DELETE endpoint protected by a separate admin key so you can unblock your own IP if you mistype the code during testing.*

### 10.10. Multi-Factor Authentication (MFA / Google Authenticator)

**THE RISK:** ADDITIONAL HARDENING - MFA is strongly recommended for admin tools and apps with sensitive data, but it's an extra step that adds friction to every login. For public-facing apps with many users, consider making it optional or risk-based (only trigger MFA for suspicious logins). For private dashboards and admin panels, MFA is one of the most effective security measures you can add - it stops an attacker even if they have your password.

Passwords and access codes can be stolen - through phishing, shoulder surfing, keyloggers, or data breaches. If someone gets hold of your Google account password (through a phishing email, a leaked database, or someone watching you type), they can log into your app as you. OAuth helps because you're relying on Google's security, but if your Google account itself is compromised, every app that uses "Sign in with Google" is wide open. A password alone - no matter how strong - is a single point of failure.

**THE SOLUTION:** Add a second factor: something you physically have, not just something you know. After signing in with Google, Auth0 asks for a 6-digit code from an authenticator app on your phone (Google Authenticator, Authy, etc.). This code changes every 30 seconds and can only be generated by the app on your physical device. Even if an attacker has your Google password, they can't log in without your phone.

The setup is a one-time process: the first time you log in after MFA is enabled, you see a QR code on screen. You open Google Authenticator on your phone, tap the "+" button, scan the QR code with your camera - done. From that point on, the app shows a new 6-digit code every 30 seconds. Nobody else can generate these codes unless they scanned that same QR code at that exact moment (and the QR code is only shown once, behind your Google login, so an attacker would need access to your Google account AND be looking at your screen during setup).

The best part: if you're using Auth0, this requires zero code changes in your app. Auth0 handles the entire MFA flow - the QR code screen, the code verification, the recovery codes - all on their hosted login page. You just enable it in Auth0's settings or via an Action, and it works.

With allowRememberBrowser enabled, Auth0 remembers your browser for 30 days after a successful MFA verification. So you're not typing a 6-digit code every single login - just on new browsers, new devices, or after 30 days. Minimal friction for you, maximum friction for attackers.

**THE FIX:**
```
// OPTION 1: Enable MFA for a specific app via Auth0 Action
// (recommended - doesn't affect your other apps on the same tenant)

// In your existing post-login Action, add one line:
exports.onExecutePostLogin = async (event, api) => {
  const clientId = event.client.client_id;

  // Only require MFA for your admin dashboard
  if (clientId === 'YOUR_APP_CLIENT_ID') {
    // Require MFA - shows Google Authenticator prompt
    // allowRememberBrowser: remembers this browser for 30 days
    api.multifactor.enable('any', { allowRememberBrowser: true });
  }
};

// OPTION 2: Enable MFA for ALL apps on the tenant
// Auth0 Dashboard > Security > Multi-factor Auth
// Toggle "One-time Password" to ON
// Set policy to "Always"
// (simpler but applies to every app on this tenant)

// PREREQUISITE: Enable the OTP factor on your Auth0 tenant
// Via Auth0 Management API:
// PUT /api/v2/guardian/factors/otp  {"enabled": true}
// PUT /api/v2/guardian/factors/recovery-code  {"enabled": true}

// Or via Auth0 CLI:
// auth0 api put "guardian/factors/otp" --data '{"enabled": true}'
// auth0 api put "guardian/factors/recovery-code" --data '{"enabled": true}'

// FIRST-TIME USER EXPERIENCE:
// 1. User logs in normally (Google, email, etc.)
// 2. Auth0 shows a QR code on screen
// 3. User opens Google Authenticator > tap "+" > "Scan QR code"
// 4. Points phone camera at QR code - app starts showing 6-digit codes
// 5. User types current 6-digit code to confirm setup
// 6. Auth0 shows recovery codes (SAVE THESE - needed if phone is lost)
// 7. Done - future logins ask for the 6-digit code after OAuth

// RECOVERY: If user loses their phone
// Admin can reset MFA via Auth0 Dashboard > User Management > user > MFA
// Or via API: DELETE /api/v2/guardian/enrollments/{enrollment_id}
```

*Recovery codes are critical - if you lose your phone and don't have recovery codes, you'll need to reset MFA from the Auth0 dashboard as an admin. Save recovery codes in a password manager or print them. For the Auth0 Action approach (Option 1), the MFA line can go in the same Action as your email whitelist - no need for a separate Action. The 'any' parameter means Auth0 accepts any enabled factor (OTP, push notification, etc.). If you only want Google Authenticator specifically, keep OTP as the only enabled factor on your tenant. Auth0 free tier supports MFA with up to 7,500 MAU.*

### 10.11. Server-Side Access Gate via Routing Middleware (The Real Gate)

**THE RISK:** A frontend-only access gate (item 9.9) is cosmetic - anyone who opens DevTools can fake the API response and bypass it. The frontend JavaScript trusts whatever fetch() returns, so an attacker intercepts the response in the Network tab, changes the status to 200, and they're in. This was confirmed during penetration testing: the tester bypassed the frontend gate in under a minute using Chrome DevTools response overrides.

The real security gap: even after bypassing the frontend gate, the attacker reaches your actual API endpoints. If those endpoints rely on JWT auth alone, the attacker still can't read data - but they can probe endpoints, discover your API surface, and test for misconfigurations. A server-side gate adds a layer that runs before your API code even executes, blocking unauthorized requests at the infrastructure level.

Real-world learning: during a security audit, a frontend-only gate was bypassed trivially. The fix was adding server-side enforcement via Vercel Routing Middleware - a middleware layer that intercepts every request before it reaches serverless functions. After deployment, the same pen tester confirmed they could no longer reach any API endpoint without passing the gate.

**THE SOLUTION:** Use Vercel Routing Middleware (middleware.ts at the project root) to enforce the access gate server-side. This middleware runs on Vercel's Edge network before every request reaches your serverless functions. It checks Redis for two keys per client IP:

1. gate:blocked:{ip} - if present, return 403 immediately (permanent block from wrong access code)
2. gate:passed:{ip} - if NOT present, return 403 (hasn't passed the access gate yet)

Only if the IP has passed the gate AND is not blocked does the request continue to your serverless function. This cannot be bypassed from the browser because the middleware runs at Vercel's infrastructure level - the request never reaches your JavaScript code unless the IP is pre-authorized in Redis.

Key design decisions:
- Exempt the gate endpoint itself (/api/access-gate) so users can submit their code
- Exempt cron job requests (valid CRON_SECRET in Authorization header)
- If your app receives webhooks, exempt only specific webhook paths (see note below)
- Use Upstash Redis REST API directly (no SDK) for Edge compatibility
- Extract real client IP from cf-connecting-ip header when behind Cloudflare (not x-forwarded-for, which contains the Cloudflare proxy IP)
- Fail open if Redis is unreachable (let the request through to the JWT auth layer rather than locking everyone out)

The middleware does NOT count toward Vercel's 12-serverless-function limit on the Hobby plan - it runs on the Edge runtime, separate from your serverless functions.

Combined with the frontend gate (item 9.9), this creates defense-in-depth: frontend gate handles UX (hides login, shows block screen, deters casual visitors), server-side gate handles security (blocks unauthorized API access at the infrastructure level).

**THE FIX:**
```
// middleware.ts - at project root (not in /api/)
// Vercel Routing Middleware - runs before every request
import { next } from '@vercel/functions'
import type { RequestContext } from '@vercel/functions'

const KV_REST_API_URL = process.env.KV_REST_API_URL || ''
const KV_REST_API_TOKEN = process.env.KV_REST_API_TOKEN || ''
const CRON_SECRET = process.env.CRON_SECRET || ''

// Upstash REST API (Edge-compatible, no SDK needed)
async function redisGet(key: string): Promise<string | null> {
  if (!KV_REST_API_URL || !KV_REST_API_TOKEN) return null
  try {
    const resp = await fetch(
      \
```

*Real-world gotcha: Vercel's built-in ipAddress() function from @vercel/functions reads the wrong header when behind Cloudflare - it returns the Cloudflare proxy IP (172.70.x.x range) instead of the real visitor IP. Always extract the IP manually from cf-connecting-ip first. This was discovered during testing when the middleware was blocking the wrong IPs. Also: do NOT add localhost exemptions (127.0.0.1, ::1) - an attacker who gains any form of server-side access could exploit a localhost exemption to bypass the gate entirely. Test directly against the remote deployment. The access gate endpoint (/api/access-gate) handles setting the Redis keys when a user enters the correct code - see item 9.9 for the code verification flow. Webhook consideration: if your app receives webhooks from external services (Stripe, Calendly, GitHub), those requests come from the provider's servers - they have no user IP context and will fail the gate check. You'll need to exempt those paths, but do it by specific path only - never use a generic query parameter like ?action=webhook to bypass the gate, because an attacker could append that parameter to any endpoint URL and skip the gate entirely. Example: `const WEBHOOK_PATHS = ['/api/webhooks/stripe', '/api/webhooks/calendly']; if (WEBHOOK_PATHS.includes(pathname)) return next()`. The webhook endpoint itself must have its own authentication (provider signature verification) - the gate exemption only gets the request to the endpoint, it doesn't grant access to data.*
