DEV Community

Cover image for Server-Side PDF Encryption with pikepdf in a Next.js App (No qpdf Required)
Shaishav Patel
Shaishav Patel

Posted on • Originally published at ultimatetools.hashnode.dev

Server-Side PDF Encryption with pikepdf in a Next.js App (No qpdf Required)

Most of our tools at Ultimate Tools run entirely in the browser — pdf-lib for merging, Canvas API for image compression, marked.js for Markdown conversion. But PDF password protection is one feature that cannot run client-side.

Here's how we implemented AES-256 PDF encryption using pikepdf (Python) called from a Next.js API route, and the problems we solved along the way.


Why Not Client-Side?

JavaScript has no reliable library for PDF encryption. The PDF spec's encryption standards (especially R=6, AES-256) require:

  • Low-level byte manipulation of the PDF's cross-reference table
  • Implementation of the PDF 2.0 encryption dictionary
  • AES-256-CBC with proper key derivation

Libraries like pdf-lib can read encrypted PDFs (if you supply the password), but they cannot encrypt them. The encryption implementation is simply not there.

We needed a server-side solution. The two main options:

  • qpdf — C++ command-line tool. Powerful, but requires system-level installation (apt install qpdf). Our Hostinger Node.js hosting doesn't allow apt or root access.
  • pikepdf — Python library built on top of QPDF's C++ core, but installed via pip. No root access needed. Just pip install pikepdf.

We went with pikepdf.


The Architecture

Browser → FormData POST → Next.js API Route → Python subprocess → pikepdf → encrypted PDF → response
Enter fullscreen mode Exit fullscreen mode

The flow:

  1. User uploads a PDF + password via FormData
  2. Next.js API route saves the PDF to a temp file
  3. A Python script is generated and executed as a subprocess
  4. pikepdf encrypts (or decrypts) the PDF
  5. The result is read and returned as a binary response
  6. All temp files are deleted

The API Route

The route handles two modes: add (encrypt) and remove (decrypt).

// app/api/pdf/protect/route.ts

export async function POST(req: NextRequest) {
    const form = await req.formData();
    const file = form.get("file") as File;
    const mode = form.get("mode") as string;        // "add" or "remove"
    const userPassword = form.get("userPassword") as string;
    const ownerPassword = form.get("ownerPassword") as string;
    const existingPassword = form.get("existingPassword") as string;

    if (!file || !mode) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
    if (mode === "add" && !userPassword) return NextResponse.json({ error: "Password required" }, { status: 400 });
    if (mode === "remove" && !existingPassword) return NextResponse.json({ error: "Password required" }, { status: 400 });

    // ... temp file handling and Python execution
}
Enter fullscreen mode Exit fullscreen mode

Temp File Management

We generate UUID-based filenames to avoid collisions:

const timestamp = Date.now();
const rand = Math.random().toString(36).slice(2, 8);
const tmpIn = path.join("./tmp", `pdf-protect-in-${timestamp}-${rand}.pdf`);
const tmpOut = path.join("./tmp", `pdf-protect-out-${timestamp}-${rand}.pdf`);
const tmpScript = path.join("./tmp", `pdf-protect-script-${timestamp}-${rand}.py`);
Enter fullscreen mode Exit fullscreen mode

Three files are created per request — input PDF, output PDF, and Python script. All three are deleted in a finally block:

finally {
    await Promise.allSettled([
        unlink(tmpIn),
        unlink(tmpOut),
        unlink(tmpScript)
    ]);
}
Enter fullscreen mode Exit fullscreen mode

We also run a background cleanup on every request that removes any temp files older than 24 hours — a safety net for edge cases where the finally block doesn't execute (server crash, OOM kill).


The Python Script

Instead of calling pikepdf via command-line args, we generate a Python script and execute it. This avoids shell escaping issues with passwords that contain special characters.

Encryption (add mode):

import pikepdf

pdf = pikepdf.open("${tmpIn}")
pdf.save(
    "${tmpOut}",
    encryption=pikepdf.Encryption(
        user="${userPassword}",
        owner="${ownerPassword || userPassword}",
        R=6
    )
)
Enter fullscreen mode Exit fullscreen mode

Decryption (remove mode):

import pikepdf

pdf = pikepdf.open("${tmpIn}", password="${existingPassword}")
pdf.save("${tmpOut}")
Enter fullscreen mode Exit fullscreen mode

R=6 is the key parameter. It tells pikepdf to use the PDF 2.0 encryption standard with AES-256. Without specifying R=6, pikepdf defaults to R=4 (AES-128), which is weaker.


Finding Python on the Server

Different hosting environments have different Python binary names. We try multiple options:

const pythonCommands = ["python3", "python", "py"];

async function findPython(): Promise<string> {
    for (const cmd of pythonCommands) {
        try {
            await execPromise(`${cmd} --version`);
            return cmd;
        } catch {}
    }
    throw new Error("Python not found");
}
Enter fullscreen mode Exit fullscreen mode

On our Hostinger production server, it's python3. On Windows dev machines, it's py or python. This fallback chain handles both.


The PYTHONPATH Problem

This was the hardest bug to diagnose. pikepdf was installed via pip install pikepdf, and python3 -c "import pikepdf" worked in the terminal. But when called from the Next.js subprocess, it failed with ModuleNotFoundError: No module named 'pikepdf'.

The issue: Hostinger's Passenger (their Node.js process manager) runs with a different environment than the login shell. The user site-packages directory isn't in sys.path.

The fix: inject the path into the Python script:

import sys
sys.path.insert(0, "/home/username/.local/lib/python3.x/site-packages")
import pikepdf
Enter fullscreen mode Exit fullscreen mode

We determine this path dynamically:

const sitePackages = await execPromise(
    `${python} -c "import site; print(site.getusersitepackages())"`
);
Enter fullscreen mode Exit fullscreen mode

This solved the import issue on Hostinger without affecting local development.


Error Handling: Wrong Password

When a user tries to remove a password with the wrong password, pikepdf throws a specific error. We catch it and return a structured response:

if (stderr.includes("invalid password") || stderr.includes("password")) {
    return NextResponse.json(
        { error: "incorrect_password" },
        { status: 422 }
    );
}
Enter fullscreen mode Exit fullscreen mode

The client shows a clear "Incorrect password" message instead of a generic error.


User vs Owner Password

The PDF spec defines two password types:

  • User password — Required to open the PDF. Without it, the content is encrypted and unreadable.
  • Owner password — Controls permissions (printing, editing, copying). The file can still be opened, but certain actions are restricted.

In our UI, the user password is required. The owner password is optional and defaults to the user password if not set.


Why Not Use a WASM Build of QPDF?

We considered compiling QPDF to WebAssembly to keep everything client-side. The problems:

  1. Binary size — QPDF compiled to WASM would be 5-10MB. A massive download for a feature most users need once.
  2. Build complexity — Cross-compiling C++ with its dependency chain (zlib, libjpeg) to WASM is fragile and maintenance-heavy.
  3. No clear benefit — For encryption, server-side processing is fine. The password is transmitted over HTTPS, and the file is deleted immediately.

pikepdf on the server is simpler, more reliable, and uses the same underlying C++ engine.


Deployment Notes

For anyone deploying this on shared hosting:

  1. Install pikepdf: pip install --user pikepdf
  2. Verify: python3 -c "import pikepdf; print(pikepdf.__version__)"
  3. Create tmp directory: mkdir -p ./tmp in your project root
  4. Check PYTHONPATH: If imports fail from Node.js but work in the terminal, the subprocess environment is missing your user site-packages
  5. Restart: After deploying, kill the existing Node.js process — Passenger caches the old code

On Hostinger specifically, we use pkill -f node to restart (not touch restart.txt, which doesn't reliably work for Node.js apps).


Try It

The tool is live at PDF Password Protect.

It's part of Ultimate Tools — a free, privacy-first collection of 24+ browser-based utilities for PDFs, images, QR codes, and developer tools. This is one of the few tools that uses server-side processing — because AES-256 encryption demands it.

Top comments (0)