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:

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:

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