Open the 404 log of any public API and you will find requests for files that were never meant to be served: /.env, /.git/config, /.aws/credentials. The list has grown. Scanners now ask for the config files of AI coding tools too, because those files hold API keys and MCP server tokens.
This post covers three things for a Node API: confirming none of those paths can return anything, answering them with a fast 404 before your app does any work, and counting the IPs behind them so repeat offenders get blocked.
What the probes look like now
Slobodan Manic published 24 hours of his own site's logs (Search Engine Journal, Aug 14). About 100 credential-hunting paths drew 1,028 requests and sent zero referrals. A few of the counts:
| Path | Requests |
|---|---|
/.ssh/known_hosts | 42 |
/.mcp.json | 30 |
/.env.production | 29 |
/.continue/config.json | 24 |
Most of it carried a crawler user agent (CCBot), and the author says he couldn't verify whether it was the real crawler or someone borrowing the name. That matters for the fix: a user agent is whatever the client says it is, so don't build your blocking on it.
First, make sure none of it can succeed
A fast 404 is pointless if the file is being served. Run the paths against production, and print the size as well as the status, because an SPA fallback can return 200 with your index.html for everything:
for p in /.env /.env.production /.git/config /.aws/credentials \ /.ssh/known_hosts /.mcp.json /.continue/config.json; do curl -s -o /dev/null -w "%{http_code} %{size_download} $p\n" "https://api.example.com$p" done Anything other than a 404 or 403 gets a closer look. Then check how static files are served:
- Point the static handler at a
publicfolder, never at the project root orprocess.cwd(). That one line is how.envand.git/end up on the internet. - In Express, set
express.static('public', { dotfiles: 'deny' })explicitly instead of relying on the default for your version. - With
@fastify/static, leaveserveDotFilesoff (it defaults tofalse). - Keep
.envfiles out of the build artifact. Secrets belong in your host's environment settings, not in a file next to the code.
Fail fast in Express and Fastify
Once nothing can leak, make the probes cheap. They should get a 404 before session lookup, body parsing, auth or a database query. Match on any path segment, since scanners also try /api/.env and /app/.git/config, and decode the path first so /%2eenv doesn't slip past:
// probe-guard.js export const PROBE = /(?:^|\/)(?:\.env(?:\.[\w-]{1,32})?|\.git|\.aws|\.ssh|\.mcp\.json|\.continue|\.cursor|\.vscode|\.idea)(?:\/|$)/i; export function decodedPath(url) { const path = url.split('?')[0]; try { return decodeURIComponent(path); } catch { return path; } } Express: mount it first, above everything else.
import express from 'express'; import { PROBE, decodedPath } from './probe-guard.js'; import { recordProbe, banCheck } from './probe-counter.js'; const app = express(); app.use(banCheck); app.use((req, res, next) => { if (!PROBE.test(decodedPath(req.originalUrl))) return next(); recordProbe(req.ip, req.get('user-agent'), req.originalUrl); res.status(404).set('Cache-Control', 'no-store').end(); }); app.use(express.json()); // ...auth, routes Fastify: onRequest runs before the body is parsed, which is where this belongs.
fastify.addHook('onRequest', async (request, reply) => { if (!PROBE.test(decodedPath(request.url))) return; recordProbe(request.ip, request.headers['user-agent'], request.url); reply.code(404).header('cache-control', 'no-store').send(); return reply; }); /.well-known/ doesn't match, so ACME challenges and security.txt keep working.
Count, then act
One probe is background noise. Twenty from the same address in a few minutes is someone working through a list. Log each hit with the IP, user agent and path, and deny an address that crosses a threshold:
// probe-counter.js (per process; use Redis or your edge for several instances) const WINDOW_MS = 10 * 60_000, LIMIT = 5, BAN_MS = 60 * 60_000; const hits = new Map(); const banned = new Map(); export function recordProbe(ip, ua, path) { const now = Date.now(); const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS); recent.push(now); hits.set(ip, recent); console.warn(JSON.stringify({ msg: 'probe', ip, ua, path, count: recent.length })); if (recent.length > LIMIT) banned.set(ip, now + BAN_MS); } export function banCheck(req, res, next) { const until = banned.get(req.ip); if (until && until > Date.now()) return res.status(403).end(); if (until) banned.delete(req.ip); next(); } The same banCheck logic works as a Fastify onRequest hook. Before you rely on it, check three things:
- Key on the IP (or a /24 or /64 range for persistent sources), never the user agent.
-
req.ipis only the real client whentrust proxynames your actual proxies. Trust every hop and a scanner can write any address intoX-Forwarded-For, including one of your customers'. - Clear old entries from
hitson a timer, or the map grows with every scanner that has ever visited.
Keep the list current
The paths change. AI tool configs are a recent addition, and the next tool's config folder will show up in your logs before it shows up in a blog post. Once a month, group your 404s by path, look at the top 50, and add anything that is clearly a credential or config file. Good candidates today: .env variants, .git/, .aws/credentials, .ssh/, .mcp.json, and editor or agent folders such as .vscode/, .cursor/ and .continue/.
Doing this with ApiTraffic
ApiTraffic's firewall runs inside your Node app through the SDK. Its managed rule set has Scanners & Probes and Sensitive Path Probing categories, and a custom rule can block a path regex on every server; policy changes reach connected servers without a deploy. Behavioral protection scores each IP over a window on signals that include 404 responses and requests to sensitive paths, and in Enforcing mode it pushes a temporary deny to every server running your app, so the counting above works across instances without Redis.
See what's hitting your API: apitraffic.io


