
Auditing a Next.js Deployment for RCE Before and After Patching
The first mistake I keep seeing in Next.js incident writeups is treating the package announcement as the whole story. A framework RCE report matters, but the real question is whether your deployed artifact still runs the vulnerable code path after the patch lands.
My position is straightforward: audit the runtime, not the dependency tree. In a Next.js deployment, stale containers, cached build output, and partial rollouts are usually what keeps an advisory alive after the repo was “updated.”
Scope and disclosure
This post stays on the defensive side of the line. I am not reproducing an exploit payload or naming an attack string that would help someone abuse a live target. What I am doing is laying out a practical audit path for a Next.js deployment when a report says “critical vulnerabilities enable remote code execution.”
What I can confirm from the provided source is limited: a news report says there are critical Next.js vulnerabilities associated with RCE. What I cannot confirm from that source alone is the exact affected version range, the precise trigger, or whether the report is quoting a vendor advisory directly. Those details belong in the official Next.js advisory or release notes.
What the Next.js RCE reports are actually saying
Separate confirmed advisory claims from news-summary noise
The confirmed part is usually pretty small:
- there is a vulnerability in Next.js
- the issue is serious enough to be called critical
- the impact can include remote code execution
Everything else in the news cycle is often just a reworded version of that sentence.
What you should not assume without checking the primary source:
- exact affected versions
- whether the bug hits only the Node runtime or also edge/serverless deployments
- whether the exploit requires an authenticated request
- whether a patch in
package.jsonis enough without rebuilding
That last point is the one I care about most. A fixed dependency in source control does not mean the live service is fixed.
Why a deployment audit matters even after a patch lands
In practice, “we upgraded Next.js” can still leave you exposed if:
- the container image was not rebuilt
- the old image tag was reused
- a serverless bundle was cached by the platform
- one replica in a rolling deployment never got replaced
- a sidecar or warm pool kept serving the previous artifact
So I do not trust dependency metadata by itself. I want evidence from the running workload.
The attack surface you need to check first
Which runtime path is exposed: Node server, edge layer, or static export
Not every Next.js deployment has the same blast radius.
| Deployment shape | Audit priority | Why it matters |
|---|---|---|
Node server (next start, custom Node hosting) | Highest | Server-side code runs in-process with filesystem and environment access |
| Serverless / platform SSR | High | The vulnerable code may run in a managed runtime, but the deployment artifact can drift |
| Edge runtime | Medium | Still server-side code, but the execution model and reachability are different |
| Static export | Lower | There is no live Next.js server process for request handling |
The subtle point is that “lower” does not mean “safe.” It just means the classic RCE risk is less likely to be reachable through the exported site itself. If you also ship API routes, middleware, or another Node service, those paths still need the same review.
Where trust breaks: headers, routing, middleware, and server actions
When I review a Next.js deployment after an RCE report, I focus on the places where request data crosses into server code:
- Headers: never treat attacker-controlled headers as proof of identity or trust.
- Routing: rewrites and redirects can send requests into code paths you did not mean to expose.
- Middleware: it runs early, so it can widen the impact of a server-side bug if it touches secrets or changes routing.
- Server actions and API routes: these are direct server execution surfaces, and they deserve the same scrutiny as any other backend entry point.
A good audit asks a boring question: “What code runs when a request comes in, and what can that code reach?”
Reproduce and verify exposure in a safe lab
Build a minimal test deployment that matches production settings
Do not test this against production first. Recreate the shape of the deployment in a lab:
npx create-next-app@latest next-audit-lab
cd next-audit-lab
npm install
npm run build
npm run start
If your production app uses a container, use a container in the lab too. If production runs behind a reverse proxy, put the lab behind one. If production relies on environment variables, provide the same class of variables, minus secrets.
The goal is not to clone your app perfectly. The goal is to preserve the same runtime path.
Note: package metadata in
package.jsononly tells you what the source tree asked for. It does not prove what the running container or serverless bundle is actually using.
Check version, build output, and response behavior with reproducible commands
These checks are boring, which is why they work.
node -p "require('next/package.json').version"
npm ls next
A healthy source tree check should look like this:
$ node -p "require('next/package.json').version"
15.x.y
$ npm ls next
[email protected] /work/my-app
└── [email protected]
Now verify the deployed artifact, not just the repo:
docker run --rm <your-image> node -p "require('next/package.json').version"
docker image inspect <your-image> --format '{{.RepoTags}} {{index .Config.Labels "org.opencontainers.image.revision"}}'
If you are on Kubernetes, check the live pod and the rollout:
kubectl rollout status deploy/<app-name>
kubectl exec deploy/<app-name> -- node -p "require('next/package.json').version"
kubectl get pods -o wide
Capture observed results instead of relying on package metadata alone
This is the difference between a real audit and wishful thinking.
| Evidence source | What it confirms | What it does not confirm |
|---|---|---|
package.json / lockfile | Intended dependency version | Running image version |
npm ls next | Installed node_modules in the current workspace | Deployed artifact |
| Container image inspect | Image metadata and revision | That the service actually restarted |
Live pod node -p ... | Version inside the running process | Whether every replica matches |
curl to the service | Public request behavior | Hidden internal traffic paths |
If the live pod version and the repo version disagree, the deployment is stale. At that point, I stop asking “did we patch?” and start asking “what else is still old?”
What to look for before patching
Detect risky request paths and code execution opportunities
Before you patch, map the paths that can actually execute server code:
middleware.tsapp/api/*- server actions marked with
'use server' - custom Node servers
- any route handler that reads request data and then calls filesystem, child process, or templating code
A quick inventory command helps:
rg -n "use server|middleware|route\.ts|route\.js|child_process|fs\.|exec\(" app pages src middleware.ts
That does not prove exploitability. It tells you where the blast radius would go if the framework bug made an unsafe request reach server execution.
Validate whether the issue is reachable from the public internet
Do not confuse “the app is internal” with “the app is safe.” I have seen private apps reachable through:
- exposed preview environments
- forgotten staging URLs
- misconfigured ingress rules
- public reverse proxies that were supposed to be temporary
Use a clean external vantage point and test the public hostname:
curl -i https://your-app.example.com/
curl -i https://your-app.example.com/api/health
If the vulnerable path is behind authentication, say that clearly. If it is public, treat the incident as internet-reachable until proven otherwise.
Show how a vulnerable and a non-vulnerable response differ
I would describe the difference this way:
- Vulnerable or unpatched behavior: the request reaches the server-side code path that should have been blocked, and you see the handler or middleware execute.
- Patched behavior: the same request is rejected earlier, or it no longer reaches the dangerous code path.
The evidence I want is not just a status code. I want a log line, a trace, or a request counter that shows which code ran.
For example, in a lab with request logging enabled, the difference should be obvious:
before patch:
middleware invoked for /<test-path>
server handler executed
after patch:
middleware invoked for /<test-path>
request rejected before handler
I am being intentionally abstract here. The exact trigger belongs in the vendor advisory, not in a blog post.
Confirm remediation after patching
Upgrade, rebuild, redeploy, then retest the same request path
The sequence matters:
- upgrade the dependency
- rebuild the app
- rebuild the container or serverless bundle
- redeploy
- retest the same path from the same vantage point
If you skip rebuild, you may keep the old artifact. If you skip redeploy, the cluster may keep serving the old pod. If you skip retesting, you are trusting process instead of evidence.
Verify the fixed version is actually running in production
This is the check I would put in a release gate:
node -e "console.log(require('./node_modules/next/package.json').version)"
kubectl exec deploy/<app-name> -- node -e "console.log(require('next/package.json').version)"
If those versions differ, stop. If they match but the live traffic still behaves like the old build, look at your build cache, image tag reuse, or rollout strategy.
Look for silent failures such as stale containers, cached artifacts, or partial rollout
The failure modes I see most often are mundane:
- a deployment kept the old image tag
- only part of the fleet rolled forward
- the CI job updated dependencies but never rebuilt the production image
- the platform reused a cached artifact
- a preview environment got patched while production was forgotten
If the platform offers deployment history, use it. If the image digest changed, confirm the running pods reference that digest. If the artifact hash did not change, assume the patch did not really ship.
Hardening steps that reduce blast radius
Run the app with the least privileged OS and filesystem access possible
If a server-side bug does land, the first thing it can touch is the environment it runs in. Reduce that environment:
- run as a non-root user
- mount the filesystem read-only when possible
- avoid writable directories except where the app truly needs them
- remove shell tooling from production images unless required
That does not prevent RCE, but it can turn “full host compromise” into “limited process compromise,” which is a much better outcome.
Restrict secrets, environment variables, and outbound network access
Assume that if code execution happens, the process can read its environment. That means your secret layout matters.
Practical controls:
- keep unrelated secrets out of the app container
- scope API keys to the minimum required permission
- use short-lived credentials where possible
- restrict outbound network access so a compromised process cannot freely call home
This is where a lot of teams under-defend. They patch the framework and leave a container with broad cloud credentials and unrestricted egress.
Add deployment checks for vulnerable Next.js versions and drift
I would put a version gate in CI and a runtime drift check in deployment.
A simple policy check can look like this:
#!/usr/bin/env bash
set -euo pipefail
current="$(node -p "require('./node_modules/next/package.json').version")"
blocked="${BLOCKED_NEXT_VERSIONS:-}"
if printf '%s\n' "$blocked" | grep -qx "$current"; then
echo "blocked Next.js version is present: $current" >&2
exit 1
fi
echo "Next.js version allowed: $current"
That script is not a substitute for the vendor advisory. It is a guardrail that forces someone to update the blocked list when a new report lands.
What I would put in the incident report
Impact statement, evidence, and what was not tested
If I were writing the incident report, I would keep the impact statement short and specific:
- a Next.js deployment was exposed to a framework-level RCE risk
- the running artifact was verified against the patch version
- the live request path was retested after redeploy
- secrets exposure and lateral movement were not assumed; they were only reported if confirmed
I would also separate evidence from inference.
Confirmed
- the running Next.js version
- the image or bundle digest
- the rollout state
- the request path that reached server code
Not tested
- arbitrary secret exfiltration
- network pivoting
- whether every edge region or preview environment was patched at the same time
That distinction keeps the report honest.
A short remediation checklist for developers and operators
- identify every Next.js runtime path in production
- verify the official advisory and affected version range
- patch source, rebuild, and redeploy
- confirm the live process reports the fixed version
- check rollout history for stale pods or old images
- reduce container privileges and secret scope
- add a version/drift gate so the next advisory is easier to catch
Further reading and primary sources
- Next.js deployment docs
- Next.js environment variables docs
- Next.js security documentation
- Node.js child process documentation
If the vendor publishes a specific security advisory for the RCE report, that advisory should be the first link in your incident ticket. The news article is a heads-up; the primary source is what you should use to make patching decisions.
Share this post
More posts

Next.js Monthly Security Releases: How to Build a Patch Pipeline That Actually Ships

Auditing npm Trust Paths: How Maintainer Reputation and Commit Signing Fail to Stop Dependency Takeovers
