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.