[BUG] Security remediation generates vercel.json with mutually-exclusive routes + headers — security headers silently not applied

Resolved 💬 1 comment Opened Apr 24, 2026 by Ariel-Meneses Closed May 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report

What's Wrong?

When Claude Code performs a security audit and remediation on a Vercel-hosted static site, it may generate a vercel.json combining the legacy routes array with the modern headers array. These two top-level fields are mutually exclusive in Vercel: the presence of routes silently disables headers processing, so all security headers never reach the client.

Steps to Reproduce

  1. Have a static Vercel site (HTML + no build step)
  2. Ask Claude Code to run a security audit and apply fixes
  3. Claude Code generates vercel.json with routes (for path-blocking) AND headers (for security headers)
  4. Deploy succeeds with no errors
  5. Run curl -sI https://your-domain.com — security headers are absent

Generated (broken) vercel.json:

{
  "routes": [
    { "src": "/.env",  "status": 404 },
    { "src": "/.git",  "status": 404 }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Content-Security-Policy", "value": "default-src 'self'..." },
        { "key": "X-Frame-Options",         "value": "DENY" },
        { "key": "X-Content-Type-Options",  "value": "nosniff" }
      ]
    }
  ]
}

What Should Happen?

Claude Code should generate a vercel.json where security headers actually reach the browser. When path-blocking is also needed, it should use compatible alternatives:

  1. middleware.js for path blocking (compatible with headers)
  2. redirects / rewrites (modern syntax, coexists with headers)
  3. NOT routes when headers are also needed

Working approach using middleware:

// middleware.js
import { NextResponse } from 'next/server'
export function middleware(request) {
  const url = new URL(request.url)
  const blocked = ['/.env', '/.git', '/wp-admin']
  if (blocked.some(p => url.pathname.startsWith(p))) {
    return new Response('Not Found', { status: 404 })
  }
}

Root Cause

Vercel has two routing systems:

  • Legacy (routes) — overrides ALL other config processing when present
  • Modern (redirects, rewrites, headers, cleanUrls) — composable, coexist fine

When routes is present, Vercel silently ignores the headers block. This is documented in Vercel's config reference but not prominently surfaced. Claude Code correctly knows both systems exist but generates them together, producing a configuration that looks correct but silently fails.

Impact

  • Silent security failure — no error in build logs, no warning in Vercel dashboard
  • Invisible to users — headers appear correct in vercel.json but never reach the browser
  • Real-world incident — after remediating 20/22 security audit findings (commit e043f73), only Strict-Transport-Security (set by Vercel automatically) was present in responses; CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, X-Content-Type-Options were all silently absent
  • Verification required — discovery only via curl -sI https://domain.com/?bust=$(date +%s%N) (random query to bypass Edge cache)

Workaround

Replace routes with middleware.js for path blocking. Keep headers in vercel.json as-is — it applies correctly once routes is removed.

Confirmed working in production after migration (nuvaus.com, Vercel Pro, static HTML+Tailwind+Alpine.js).

Suggested Fix

When generating vercel.json with security headers:

  1. Detect if routes is present alongside headers and warn/refuse to generate
  2. Auto-generate middleware.js instead of routes entries when path-blocking is needed
  3. Or add a post-generation verification step: curl -sI <deployed-url> and compare actual headers against vercel.json

Environment

  • OS: Windows 11 Pro 10.0.26200
  • Claude Code: latest (2.1.119+)
  • Vercel: Pro plan
  • Site type: static (HTML + Tailwind CDN + Alpine.js v3, no build step)

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗