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 allowaptor root access. -
pikepdf — Python library built on top of QPDF's C++ core, but installed via
pip. No root access needed. Justpip install pikepdf.
We went with pikepdf.
The Architecture
Browser → FormData POST → Next.js API Route → Python subprocess → pikepdf → encrypted PDF → response
The flow:
- User uploads a PDF + password via FormData
- Next.js API route saves the PDF to a temp file
- A Python script is generated and executed as a subprocess
- pikepdf encrypts (or decrypts) the PDF
- The result is read and returned as a binary response
- 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
}
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`);
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)
]);
}
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
)
)
Decryption (remove mode):
import pikepdf
pdf = pikepdf.open("${tmpIn}", password="${existingPassword}")
pdf.save("${tmpOut}")
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");
}
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
We determine this path dynamically:
const sitePackages = await execPromise(
`${python} -c "import site; print(site.getusersitepackages())"`
);
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 }
);
}
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:
- Binary size — QPDF compiled to WASM would be 5-10MB. A massive download for a feature most users need once.
- Build complexity — Cross-compiling C++ with its dependency chain (zlib, libjpeg) to WASM is fragile and maintenance-heavy.
- 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:
-
Install pikepdf:
pip install --user pikepdf -
Verify:
python3 -c "import pikepdf; print(pikepdf.__version__)" -
Create tmp directory:
mkdir -p ./tmpin your project root - Check PYTHONPATH: If imports fail from Node.js but work in the terminal, the subprocess environment is missing your user site-packages
- 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)