wkhtmltopdf Is Archived. What to Use Instead

The awkward thing about wkhtmltopdf is that it still works. Invoices still come out, the flags still behave, and nothing in your logs has changed. That is exactly why it is still sitting in production images three years after the project stopped existing, usually one layer down inside a wrapper nobody has opened in a while: pdfkit in Python, wicked_pdf in Ruby, KnpSnappy in PHP, DinkToPdf in .NET.

A tool that produces correct output and receives no security patches is a worse position than one that is visibly broken, because nothing prompts you to act.

What actually happened to the project

The dates are worth knowing precisely, because they tend to settle the argument in a planning meeting:

  • The GitHub repository was archived on 2 January 2023 and is read-only.
  • The organisation itself was marked archived by an administrator in July 2024.
  • The last stable release, 0.12.6, shipped in 2020.
  • The rendering engine is a patched Qt WebKit from roughly 2012.

So the engine doing the work predates CSS grid, and the last person able to change it left half a decade ago. Distributions have been dropping the package, official builds never caught up with current Debian and Ubuntu releases or arm64, and most Docker images that still offer it are third-party rebuilds of an unmaintained binary.

The vulnerability that cannot be fixed

CVE-2022-35583 is a server-side request forgery rated CVSS 9.8. There is no version to upgrade to. If a scanner flags it, the only remediation is migration.

The mechanism is simple enough to explain in one sentence: if an attacker can get an <iframe> into the HTML you render, your server fetches whatever that iframe points at, and the response is drawn into the PDF. On a cloud instance the interesting target is the metadata endpoint:

<iframe src="http://169.254.169.254/latest/meta-data/iam/security-credentials/">
</iframe>

Render that and the PDF can come back containing role credentials for the machine. The same class of trick reaches internal services that were never meant to face the internet, because the request originates from inside your network. A second flaw, CVE-2020-21365 at CVSS 7.5, allows reading local files by directory traversal.

The project's own status page is blunt about it and tells users not to run wkhtmltopdf against any untrusted HTML. CiviCRM issued a security advisory recommending removal, and Pantheon dropped it from its PHP runtime.

Here is the part that matters for choosing a replacement, and it is the reason this post is not simply a list of tools. This is not a missing patch. It is the shape of the thing. A renderer that is permitted to make network requests will fetch what it is told to fetch. Nothing about that is unique to wkhtmltopdf, which means a migration can very easily carry the exposure along with it.

The quieter problem: it renders 2012 CSS

Security aside, there is a second reason to move, and it produces no error at all. Grid, flexbox gaps and custom properties are simply not understood by a 2012 engine. It does not fail; it lays the page out wrong and hands you a valid PDF.

That failure mode is worse than a crash for anything customer-facing. A crash reaches your alerts. A subtly wrong invoice reaches your customer.

It also means the templates you have today may have been shaped around the quirks. Expect to re-test them, not just repoint them.

Picking a replacement honestly

Four real options, and the right one depends entirely on whether your HTML needs JavaScript and on how much infrastructure you want to own:

OptionBest whenYou operate
WeasyPrintStatic HTML with print CSS, no JavaScript. Python stacks especially.A library
Playwright / PuppeteerJavaScript or modern CSS, and you want full control.A browser
GotenbergYou want the browser wrapped in an HTTP service you host.A container
Hosted APIYou would rather this not be your pager at 3am.Nothing

To be direct about it: if you are rendering static invoices from print CSS in a Python service, use WeasyPrint and stop reading. It is a library call, there is no browser to patch, no headless process to supervise, and no reason to send your documents to anyone, us included. Recommending an API for that job would be selling you complexity.

The calculus changes when your templates need a real browser. A Chromium-based renderer is a large, actively exploited attack surface with a full JavaScript engine inside it, and running one means owning cold starts, memory ceilings, zombie processes and a patch cadence that does not wait for your release schedule. That is a genuine operational commitment, which is the honest argument for handing it to someone else rather than any claim about output quality.

The step most migration guides skip

Swap wkhtmltopdf for default Puppeteer and you have fixed the engine, not the exposure. That iframe above still resolves. You have upgraded from an unpatched renderer that reaches the metadata endpoint to a patched renderer that reaches the metadata endpoint.

Closing it properly takes two independent layers, because either one alone is a single point of failure:

  • Request interception with an allowlist. Not a denylist of internal ranges, which DNS rebinding and redirects walk straight through. Allow the schemes a document legitimately needs and abort everything else.
  • No network at the OS layer. Put the renderer in a network namespace with no interface at all, so a bypass in the browser layer has nowhere to go.

For reference, that is how our own endpoint is built. Rendering is Chromium driven by Playwright, and every render happens inside a bubblewrap jail created with --unshare-all, so the process has no network namespace to reach the metadata endpoint through in the first place. Inside it, a route guard permits only data:, about: and blob: and aborts every other request. The system tree is mounted read-only, service secrets are not mounted at all, each job gets a private tmpfs and a cgroup memory cap, and a syscall denylist applies throughout. One render means one short-lived browser, so nothing persists between documents.

The layering is deliberate. The route guard would be enough if Blink never had a bug, and the network namespace would be enough if the guard were never misconfigured, so we assume both and require an attacker to defeat two unrelated mechanisms. The same reasoning behind the sandbox is covered in the parser attack surface, and if you also accept image uploads, SVG deserves the same treatment for much the same reasons.

Doing the migration

The mechanical part is easier than it looks. Page size, margins, orientation, headers and footers have direct equivalents in every Chromium-based renderer, so translating the flags is mostly clerical. Budget your time for CSS verification instead, and move one template at a time with the old and new output side by side.

Then finish the job: uninstall the binary and drop the wrapper dependency. A migration that leaves wkhtmltopdf in the image has moved your traffic without closing your exposure, and your scanner will keep saying so, correctly.

If the API route fits, one endpoint does it and nothing is retained after the response is written:

curl -X POST https://privconvert.com/api/convert/html-to-pdf \
  -H "Authorization: Bearer pk_live_..." \
  -F "[email protected]" \
  -o invoice.pdf

The document is rendered in memory and returned in the response body. There is no job id to poll and no stored result to fetch later, because nothing is kept - the reasoning behind that is in adding conversion without storing files. If you are generating documents from Markdown rather than HTML, the Markdown guide covers that path, and for bulk template regeneration the retry rules are worth reading first.

You can also try it in the browser without a key to compare output against your current renderer before writing any code.

Developer resources:
Convert endpoint Try HTML to PDF Plans & pricing