Every Next.js site with a blog eventually grows the same route. Someone wants the posts to look good when they are shared, so a file appears that takes a title, drops it into a nice layout with the site name and a logo, and returns a picture. It takes ten minutes to write. It demos beautifully. And then it sits there, public and unauthenticated, rendering whatever text the URL hands it, on your server, for years.
That route is the subject of CVE-2026-94545, a Critical vulnerability disclosed by Vercel on September 22, 2026. In the affected versions, attacker-controlled text fed into an ImageResponse could escape the picture it was supposed to stay inside, and under the right conditions that escape reached remote code execution on the server generating the image. The share card. The route nobody threat-models, because in everyone's head it just makes a PNG.
This post walks the whole chain slowly: what ImageResponse actually does under the hood, where the text stopped being text, a precise test for whether your app was ever exposed, the patch and why it is cheap to take, and an audit you can run against your own call sites this afternoon. It stays defensive on purpose. The mechanism matters more than any payload, and the mechanism is the part you can reuse the next time a feature turns user input into a rendered document.
What ImageResponse actually does
Start with the part most developers never look at. When you write an OG image route, you write JSX and get back an image. That gap between JSX in and PNG out is bridged by a pipeline, and every stage of that pipeline is a place where data changes meaning.

The request arrives at your route handler or your opengraph-image file. ImageResponse, from next/og, accepts the element you built. Then Satori, the library underneath, does the clever part: it takes that JSX, computes a layout, and produces an SVG document that describes the same picture. Finally, a native rasterization step parses that SVG and renders it into the PNG (or JPEG) bytes that get served to the crawler or the chat app that requested the card.
Read that chain again and notice what it is. Your server contains a document generator. It builds a structured document, an SVG, out of values that partly come from the incoming request, and then it feeds that document to a native parser. That is exactly the shape of every file-format vulnerability you have ever read about, from malicious PDFs to spreadsheet injection. The only novelty here is that the document generator was wearing a friendly React costume, so nobody treated it like one.
That framing tells you where to look for the bug. It will not be in your JSX. It will be at the boundary where a value that should stay data gets written into the structure of the document itself.
What actually broke
Satori's job is to take the strings in your element tree and write them into an SVG as content, attributes, and styles. To do that safely, every value has to be escaped for the position it lands in, so the SVG parser on the other side reads it as a value and never as markup. The Satori advisory, GHSA-wx4j-mvgx-mqwp, describes the failure plainly in its effect: certain values were not properly escaped before being written into the generated SVG, so crafted text could be interpreted as live SVG markup instead of plain text.
Once a value is markup, the rest of the chain does what it is built to do. The rasterizer parses the SVG as a document, not as a picture of your text, and elements and attributes that were never in your JSX are now in the document it is rendering. The Next.js advisory, GHSA-vcvr-r3jv-pc5j, rates the composed result Critical, with a CVSS v4 score of 9.5. Rated against Satori alone, the same underlying flaw is Moderate. Both numbers are in this post because they answer different questions. The library flaw is an escaping bug. The product flaw is an escaping bug sitting in front of a native parser, on a public route, inside a server process. Context is the severity.
Here is the vulnerable pattern, reduced to the shape that matters. The layout and the font stack are constants; the only thing that moves is a query-controlled string rendered as content, which is the dataflow the advisory is about. This is an original illustration of the pattern on affected versions, not a working exploit, and it is not evidence that this exact input, on its own, produces code execution:
// app/share/[slug]/route.tsx, the vulnerable PATTERN on affected versions, simplified
import { ImageResponse } from "next/og";
// Constant by design: fonts and layout never come from the request.
const FONT_STACK = "Inter, system-ui, sans-serif";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// Attacker-controlled: anyone can set this query parameter.
const title = searchParams.get("title") ?? "Untitled post";
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
fontFamily: FONT_STACK,
}}
>
{/* The value flows into the generated SVG as element content. */}
<div style={{ fontSize: 64 }}>{title}</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Nothing in that code looks dangerous, and that is the point. There is no eval, no shell call, no obvious sink. The sink is structural: a request parameter reaches a position inside a generated document, and the safety of the whole route depends on the escaping behaviour of a library two layers below the code you wrote. When that escaping has a gap, the route inherits it completely. Attributes and style values are positions inside the same document, and the advisory's affected scope names content, attributes, and styles alike; the example keeps fonts and layout fixed on purpose, so the one moving part is the untrusted-data boundary itself. If you want to see how the pieces fit together safely, the Vercel OG Playground runs the patched stack and is a good place for benign title and theme experiments. Do not test this pattern against a server running an affected version.
Two honest limits on what we know. First, the advisory offers no reliable way to confirm whether a vulnerable route was exercised before patching, so treat past exploitation as unconfirmable rather than ruled out. Second, public exploit code followed disclosure quickly: The Hacker News found no public exploit code on September 23, and EQSTLab published a working proof of concept on September 29, 2026, one week after the fix shipped. The window between patching and someone testing your forgotten route was never going to be long. Neither point is a reason to panic. Both are reasons the audit below is worth an hour.
Are you exposed? The three-part test
Exposure is narrower than the headlines suggested, and that is good news worth being precise about. Three conditions, each one checkable against your own code and configuration, had to hold at the same time:
- Version. Next.js 16.2.0 through 16.3.5. Earlier lines, including 15.x, were not vulnerable to this issue. The 15.5.26 release that shipped the same day was related hardening, not a fix for a live flaw in that line.
- Runtime. The Node.js runtime implementation of
ImageResponse. The Edge runtime implementation was not affected. - Untrusted input. Values an attacker controls, from a query string, a request body, a profile name, a page title supplied by a user, reaching the generated SVG as content, in attributes, or in styles. A route that renders only static, developer-authored strings does not meet this condition.
If any one condition fails, this CVE does not reach your app. If all three hold, you were exposed for as long as you ran an affected version, whether or not anyone noticed your route. Run the test against configuration and code, not against memory. OG routes are exactly the kind of code a team forgets it has, often added by a contractor, a starter template, or a weekend marketing push.
Why the Node.js path is the dangerous one
The second condition deserves its own explanation, because it is mechanism rather than a checkbox. On the Node.js runtime, the generated SVG is handed to a native rasterization path, native libraries parsing a document that now contains attacker-shaped markup. That parsing context is what turned an escaping bug into a code execution rating. The Edge implementation does not share that path, which is why the same library flaw produced a Moderate rating for Satori alone and a Critical one for the composed Node.js product.
Hold that distinction carefully in both directions. Injected markup does not automatically mean code execution: the Satori advisory itself notes that the impact depends on how the generated SVG is consumed, and an app that renders SVG in a sandboxed or purely client-side context is in a different position from one feeding it to the Node.js next/og pipeline. The three-part test above is stated in terms of the documented affected implementation and its documented context. Do not stretch it in either direction, neither by assuming every Satori user had an RCE, nor by treating the missing native path in some other stack as proof that escaping did not matter there.
The patch, and why it is cheap
Two facts need separating, because confusing them is how teams end up patched against last month's hole and exposed to this month's. The vulnerability was first fixed in Next.js 16.3.6, shipped as an out-of-band security release on September 22, 2026. But 16.3.6 is a historical minimum, not the release you should install today. Vercel has already shipped further security updates on top of it, including an out-of-band update on October 14 that brought 16.4.1 and 15.5.28. Upgrade to the latest supported, fully patched release on your maintenance line, and verify it against the current security advisories rather than stopping at the first version that closed this CVE. As of November 7, 2026, the latest release on npm is 16.4.5, published November 3.
If you use Satori directly, outside next/og, upgrade it to 0.33.5 or later; the affected range is 0.0.27 through 0.33.4. If you are on 15.x, your line was not the vulnerable one. Take the current 15.5.x anyway, 15.5.28 as of this writing; the September 22 release, 15.5.26, added related hardening for next/og, and later 15.5.x releases carry their own fixes.
Why call this cheap? The emergency fix existed for one reason, to close this hole, which is the lowest-risk kind of upgrade a framework ships, and moving from there to the current release on your own line is a normal, supported upgrade path. Do the upgrade first and the audit second. The audit below is how you learn whether the exposure conditions ever applied to you and how you harden the pattern for next time. It is not a substitute for removing the vulnerable versions from your deploy, and no amount of input filtering in your route should be treated as equivalent to the patched library.
Audit your call sites
With the patch rolling out, spend the next thirty minutes making sure you know where this API lives in your codebase. Run these searches from your repository root. They work the same in both project layouts, app/ at the root and src/app/, and they stay out of node_modules and build output:
# Every use of the OG image API, in source only
rg -n 'ImageResponse|next/og' -g '*.{ts,tsx,js,jsx}' -g '!**/.next/**' -g '!**/node_modules/**' .
# The conventional OG image files (some render without importing the API)
rg --files -g '*.{ts,tsx,js,jsx}' | rg '(^|/)(opengraph-image|twitter-image)\.(ts|tsx|js|jsx)$'
The first command needs ripgrep. Without it, the same searches with the tools already on your machine:
grep -rnE "ImageResponse|next/og" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" --exclude-dir=node_modules --exclude-dir=.next .
find . -path ./node_modules -prune -o -path ./.next -prune -o \( -name "opengraph-image.*" -o -name "twitter-image.*" \) -print
These were tested against both layouts before this post shipped. Searching from the root matters: aiming grep at app/ and src/ separately errors on whichever directory your project does not have, and a plain find . happily reports the compiled opengraph-image copy inside .next, which is build output, not a call site you own. The purpose of the audit is an inventory of your own code, not an exploit test.
For each hit, do three things. First, confirm the runtime. A route segment that sets export const runtime = "edge" was outside this CVE; a route on the default Node.js runtime was inside its scope. Second, list every dynamic value in the element tree and trace it back to its source. Classify each one honestly: developer-authored constant, trusted internal data, or request-influenced. The third category is where your attention belongs, and it is usually longer than you expect, because titles and names feel like content rather than input right up until a user edits theirs. Third, check where each request-influenced value lands: element content is one risk position, but attributes and style values are the positions developers audit least and the ones this flaw abused.
Keep the result as a short note per route: runtime, values, sources, positions. That note is your evidence if anyone later asks whether the exposure test applied, and it is the map you will want when the next rendered-document CVE arrives with a different name.
Defense in depth, even after patching
Patching closes this hole. It does not change the shape of the feature, and the shape deserves its own habits.
Treat every value that enters a rendered document as untrusted unless you can name why it is trusted. Prefer allowlists over free text for anything that lands in an attribute or a style: a theme from a fixed set of names, a colour from a fixed palette, a layout variant from an enum. Cap the length of text values; a display name that needs four hundred characters is not a display name. Never pass raw request text into SVG attributes or style properties at all if a mapped, validated value can go in its place. And consider the Edge runtime for public OG routes where your design allows it. Runtime choice is usually made for latency reasons; this CVE is a reminder that it is also a blast-radius decision, because the two implementations did not share this flaw.
The deeper habit is about code review. When a pull request introduces a feature that builds a document (an SVG, a PDF, an image, a spreadsheet, an email template) out of request data, the review question is always the same: where does data stop being data in this pipeline, and what guarantees that boundary? If nobody in the review can answer, the feature is not ready, however good the card looks in the preview.
The general lesson
CVE-2026-94545 will be remembered, if it is remembered at all, as the OG image bug. The reusable lesson is wider. Modern stacks hide document generators inside friendly APIs. You wrote a component; the framework built an SVG; a native parser rendered it. Each layer is reasonable. The vulnerability lived in the joins between them, in escaping behaviour nobody on your team wrote, tested, or probably knew existed.
So inventory your joins. Any feature that turns user input into a rendered document inherits the attack surface of the parser at the end of that pipeline. That inheritance is not a reason to stop building share cards. It is a reason to build them like what they are: public endpoints that generate documents on your server. Patch to the current release on your line (16.3.6 was only the first fix), audit the call sites, allowlist what goes into the document, and the prettiest route on your site stops also being the quietest one.
Sources
Check every claim in this post against the primary documents:
- Next.js advisory, GHSA-vcvr-r3jv-pc5j: https://github.com/vercel/next.js/security/advisories/GHSA-vcvr-r3jv-pc5j
- Satori advisory, GHSA-wx4j-mvgx-mqwp: https://github.com/vercel/satori/security/advisories/GHSA-wx4j-mvgx-mqwp
- Vercel's September 22, 2026 security update: https://nextjs.org/blog/nextjs-security-update-september-22-2026
- Next.js releases, for the current release on your line: https://github.com/vercel/next.js/releases
- Next.js docs,
opengraph-imageandtwitter-imagefile conventions: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image




