Blocking vulnerability scanner bots on Vercel and Next.js
Vercel logs full of HTTP 500s for paths like /wp-Blogs.php that don't exist on the site. The fix uses two layers: Vercel Firewall at the edge plus Next.js middleware. Root cause and patterns to block.
by Zechim
I was scrolling through Vercel logs when I saw this:
500 GET /wp-Blogs.php Error: Page changed from static to dynamic
500 GET /css.php Error: Page changed from static to dynamic
500 GET /chosen.php Error: Page changed from static to dynamic
500 GET /1.php Error: Page changed from static to dynamic
500 GET /wp-mail.php Error: Page changed from static to dynamic
404 GET /wp-content/index.php
A whole sequence of WordPress scans hitting zechim.com, a Next.js site without a single line of PHP. Classic script-kiddie bot traffic, expected on any public site. What was NOT expected: the 500 status code.
These should have returned a clean 404. Instead they returned 500. Each one cost a Vercel Function invocation. Polluted the logs. And carried a strange error message: "Page changed from static to dynamic".
Took me a while to figure out. But the root cause is interesting, and the fix has two layers worth documenting.
Why 500 and not 404
The Next.js middleware (in our case src/proxy.ts running next-intl) had a matcher like this:
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"]
That pattern EXCLUDES paths with a dot. Reasonable at first glance: static assets (/icon.svg, /favicon.ico) have dots, and you don't want middleware running for them.
But /wp-Blogs.php also has a dot. So the middleware skips. The request goes straight to the Next.js router.
That's where the confusion starts. Next.js looks for a page matching /wp-Blogs.php. Nothing static matches. Falls through to a dynamic catch-all route: /[locale]/[...rest]. Tries to match:
locale="wp-Blogs.php"rest=[]
The catch-all page is simple: if locale is valid, render 404. But the parent layout ([locale]/layout.tsx) has generateStaticParams returning only pt-BR and en-US. That layout was prerendered statically for those two locales at build time.
When a request comes in with locale = "wp-Blogs.php", the layout isn't prerendered for it. Next.js tries a fresh dynamic render. But the parent layout was supposed to be static. Conflict.
Result: HTTP 500, error message "Page changed from static to dynamic". Each scan becomes a function invocation that crashes.
Summary: three things conspired to produce the 500.
- Middleware matcher excluding paths with dots (including
.php) - Dynamic catch-all accepting any locale parameter
- Parent layout statically prerendered only for the real locales
The fix in two layers
The simplest fix would be to change the middleware matcher to include the bait paths. It works, but adds compute on every scan (still invokes an edge function, even if it rejects fast).
The defense-in-depth solution is better: block at the edge AND at the middleware.
Layer 1: Vercel Firewall (at the edge, no compute)
Vercel Pro has Firewall built in. Project dashboard, Firewall > Rules, lets you create rules that reject requests before reaching any function.
Three rules I configured.
Rule 1: common script extensions used in probes
| If | Operator | Value |
|---|---|---|
| Request Path | Ends with | .php |
| OR Request Path | Ends with | .aspx |
| OR Request Path | Ends with | .asp |
| OR Request Path | Ends with | .jsp |
| OR Request Path | Ends with | .cgi |
| OR Request Path | Ends with | .env |
| Then | Deny |
Rule 2: CMS and admin probes
| If | Operator | Value |
|---|---|---|
| Request Path | Starts with | /wp- |
| OR Request Path | Starts with | /xmlrpc |
| OR Request Path | Starts with | /phpmyadmin |
| OR Request Path | Starts with | /adminer |
| OR Request Path | Starts with | /cpanel |
| Then | Deny |
Rule 3: hidden file probes
| If | Operator | Value |
|---|---|---|
| Request Path | Starts with | /.git |
| OR Request Path | Starts with | /.aws |
| OR Request Path | Starts with | /.ssh |
| OR Request Path | Starts with | /.svn |
| OR Request Path | Starts with | /.DS_Store |
| Then | Deny |
Use Deny, not Challenge. No bot will solve the CAPTCHA, and no legitimate user is requesting /wp-Blogs.php. Deny returns a fast 403 with zero downstream cost.
Layer 2: Next.js middleware (fallback)
If the firewall is off (free tier, or a rule accidentally disabled), the middleware still catches it. In src/proxy.ts:
import createMiddleware from "next-intl/middleware";
import { NextRequest, NextResponse } from "next/server";
import { routing } from "./i18n/routing";
const intlMiddleware = createMiddleware(routing);
const SCAN_PATH_PATTERNS = [
/\.php$/i,
/\.aspx?$/i,
/\.jsp$/i,
/\.cgi$/i,
/^\/wp-/i,
/^\/xmlrpc(\.|$)/i,
/^\/phpmyadmin(\/|$)/i,
/^\/\.(env|git|aws|ssh|svn|hg|DS_Store)(\b|\/|$)/i,
/^\/(adminer|webmail|cpanel|plesk)(\/|$)/i,
];
export default function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
if (SCAN_PATH_PATTERNS.some((re) => re.test(path))) {
return new NextResponse("Not Found", {
status: 404,
headers: { "content-type": "text/plain" },
});
}
return intlMiddleware(req);
}
export const config = {
matcher: [
"/((?!api|_next|_vercel|.*\\..*).*)",
"/:path*.php",
"/:path*.aspx",
"/wp-admin/:path*",
"/wp-content/:path*",
"/xmlrpc.php",
"/phpmyadmin/:path*",
],
};
Two important changes:
-
The matcher now has multiple entries. The first is the original one for real app paths. The others include bot patterns explicitly so they reach the middleware instead of bypassing it.
-
The function detects scan patterns and responds 404 before invoking next-intl.
The effect
Before the fix, typical hourly logs:
- ~3,000 scan requests
- ~2,500 returning 500 (
Page changed from static to dynamic) - ~500 returning 404 (paths Next.js could match as 404)
- Each one: 1 Function invocation
After the fix:
- Same ~3,000 requests per hour
- 0 returning 500
- All returning 404 or 403
- Compute per scan: ~zero (firewall catches before the function)
Logs clean. Cost drops to essentially nothing for this traffic category.
Why two layers
Firewall alone would do the job. Middleware alone would too. But combining has three advantages.
Operational resilience. If you accidentally disable a firewall rule, the middleware still catches it. If a new pattern appears and you update the code first, firewall doesn't block but middleware does.
Host migration. If you ever move from Vercel to Cloudflare Pages, AWS Amplify, Netlify, or self-host, the middleware travels with the code. The firewall rules stay behind.
Split visibility. The firewall shows blocks in a dedicated tab (Firewall > Activity). Middleware shows up in function logs. Being able to see them separately is useful for understanding who's hitting you.
What not to do
A few moves that look smart but make things worse.
Try to capture EVERY possible bot pattern. Exhaustive lists become a mess and break legitimate cases. Cover the 90% most common (.php, wp-*, .env) and let the rest hit the normal 404 path.
Log or alert on every block. You'll get thousands of alerts per day. Log aggregate (count by pattern per hour) or nothing at all. Noisy bot scans are the default state of the public internet. Not an actionable signal.
Move to a third-party WAF. Cloudflare, AWS WAF, and similar tools are good, but for a Next.js site on Vercel the built-in Firewall + middleware combo covers 99% of cases with zero extra infrastructure. The WAF only pays off if you need more sophisticated blocking rules (per-IP rate limit, geo-blocking, anti-DDoS).
Takeaway
The question is not "how do I block the bots". Bots will always try. The question is "how do I make sure they cost me nothing and don't pollute my logs". Firewall + middleware together solve that.
If you run Next.js on Vercel and you have a public site in production, spend 15 minutes configuring the three firewall rules above. You don't even need to touch code if you don't want to. The edge layer alone takes out 99% of the problem.
And check your logs once in a while. 500s hiding behind bot traffic are the kind of bug nobody reports to you, but they're costing you money silently.
If your Next.js infrastructure needs a similar audit, or you're in production and you haven't looked at bot traffic in your logs lately, it's worth a conversation.