Guide
Migrating off wkhtmltopdf on AWS Lambda — the unpatched CVE and the form-fill swap
wkhtmltopdf's GitHub repo was archived in January 2023, its organization followed in July 2024, and its last release still carries an unpatched, CVSS-9.8 server-side request forgery. None of that has stopped it from running inside a Lambda Layer behind thousands of invoice and receipt endpoints — because "swap out the rendering engine" sounds like a multi-week project. Usually it isn't. If what you're generating is the same layout with different data every time, the fix is smaller than a rendering migration: stop rendering HTML and fill a form instead.
The state of wkhtmltopdf, plainly
Three facts, so you can decide how urgent this is without taking my word for it:
- Archived. The wkhtmltopdf repo was archived by its owner on January 2, 2023; the wkhtmltopdf GitHub organization was archived on July 10, 2024. No maintainer is triaging issues.
- Stale. The last release, 0.12.6, shipped in 2020 and is still what most Lambda Layer builds bundle today.
- Unpatched, critical. CVE-2022-35583 is an SSRF (server-side request forgery — the renderer can be tricked into making requests to internal addresses on your behalf) via iframe injection, scored CVSS 9.8 (critical), and there will be no 0.12.7 to fix it.
None of this is a knock on the original project — it did its job for over a decade. But "archived + unpatched critical CVE" is the point where a security review stops asking "is this still fine" and starts asking "why is this still here."
Why it's worse specifically on Lambda
wkhtmltopdf isn't a library — it's a compiled binary wrapping a decade-old fork of QtWebKit, which means running it in a function means shipping the binary alongside your code, not importing a package:
- The binary usually arrives as a Lambda Layer, 50-80MB, eating into the 250MB unzipped deployment limit before your own code counts.
- It needs a writable filesystem for its own temp files — Lambda only offers
/tmp, so the handler has to manage a scratch path and clean up after itself. - Spinning up a headless-browser-shaped binary is real cold-start weight on top of your runtime's own init — separate from, and additive to, whatever your handler already pays.
- It can't run at all in a V8-isolate-style runtime (Workers, Deno Deploy, Vercel Edge). Build on wkhtmltopdf today and moving this function off Lambda later means rewriting the render step, not just redeploying it.
When this migration doesn't apply
Be honest about what you're actually rendering before doing this. PDFops fills fields in a PDF template — it doesn't render arbitrary HTML and CSS. If your output genuinely varies in layout — long-form articles, portfolio pages, dashboards with charts, anything where the structure changes per document, not just the values — you still need a real browser renderer. That's a different migration (Puppeteer or Playwright in a proper sandbox, not a Lambda Layer binary), and this guide isn't it.
But check what you're actually generating. Most wkhtmltopdf endpoints render the same HTML template every time — an invoice, a receipt, a monthly statement, a contract — with only the data changing. That's a form problem wearing an HTML renderer's clothes, and it's the case this guide covers.
The swap
Re-author the HTML template once as a real PDF with AcroForm
fields — where a Liquid/Handlebars variable used to sit,
there's now a named form field. Acrobat's Prepare Form tool,
LibreOffice Draw's Form Controls toolbar, or pdftk
on an existing PDF all work; it's a one-time job per template,
not a per-request cost. Don't have one handy to test against?
Grab the sample invoice-template.pdf
— it ships with customer_name and total
fields already wired up.
Once the template has real fields, POST /api/fill-form replaces the entire render step: upload the template plus a JSON object of field values, get back a filled PDF. No HTML string assembly, no CSS, no rendering engine — just data going into boxes that already exist.
The Lambda handler
Node.js 22.x runtime, ESM, no Layer. The template ships inside the deployment zip and is read once at cold start — outside the handler — so a warm invocation does zero filesystem work before calling the API:
// index.mjs — AWS Lambda (Node.js 22.x), Function URL
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PdfOps, PdfOpsError } from 'pdfops-sdk';
const here = dirname(fileURLToPath(import.meta.url));
// Bundled in the deployment package — no Layer, no /tmp write.
const template = await readFile(join(here, 'invoice-template.pdf'));
const pdfops = new PdfOps({ apiKey: process.env.PDFOPS_API_KEY });
export const handler = async (event) => {
if (event.requestContext?.http?.method !== 'POST') {
return { statusCode: 405, body: 'POST a JSON body: { customer_name, total }' };
}
const invoice = JSON.parse(event.body ?? '{}');
try {
const filled = await pdfops.fillForm(template, {
customer_name: String(invoice.customer_name ?? ''),
total: String(invoice.total ?? ''),
});
return {
statusCode: 200,
headers: { 'content-type': 'application/pdf' },
body: Buffer.from(filled).toString('base64'),
isBase64Encoded: true,
};
} catch (e) {
if (e instanceof PdfOpsError) {
return { statusCode: e.status, body: JSON.stringify({ error: e.code, details: e.message }) };
}
throw e;
}
};
npm install pdfops-sdk, zip index.mjs,
node_modules, and invoice-template.pdf
together, set PDFOPS_API_KEY as an environment
variable, done. Compare that deployment package — a handful of
KB of pure JavaScript — to a Lambda Layer carrying a 50-80MB
native binary, and the size difference alone is most of why
cold starts get faster: there's no subprocess to spin up before
your code even runs.
What you get beyond removing the CVE
Filling a form is a narrower operation than rendering HTML, and narrower means more predictable. The same input bytes and the same field values produce byte-identical output every time — no font-substitution drift, no headless-renderer version bump silently reflowing last month's invoices. If that determinism matters for hashing, caching, or diffing PDFs in CI, that's the whole argument in the case for deterministic PDF filling.
Getting a key
The handler above needs a key to run past the anonymous trial. POST /api/signup with an email gets a free one — 250 requests/month, no card, delivered to your inbox (never in the response body, so inbox possession doubles as ownership proof):
curl -X POST https://pdfops.dev/api/signup \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
Anonymous calls (no X-API-Key header) still work
at 100 requests/IP/month — enough to run the example below
before deciding whether to wire in a key.
Try it
Prove the fill step from a terminal before touching Lambda:
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@invoice-template.pdf" \
-F 'fields={"customer_name":"Acme Co","total":"$1,250.00"}' \
-o filled.pdf
filled.pdf opens with both fields populated — that
curl call is exactly what the Lambda handler above does under
the hood, just wrapped in the SDK's typed fillForm
method instead of raw multipart.
Related
- POST /api/fill-form reference — full field-type mapping, error codes, and a dedicated section on migrating from hosted HTML→PDF services (PDFMonkey and others)
- The case for deterministic PDF filling — why byte-identical output matters once you can hash and diff PDFs in CI
- Generating invoice PDFs from Stripe webhooks on Cloudflare Workers — the same fill-form call on an edge runtime instead of Lambda