Securing your generated PDFs: encryption, watermarks, and access control

A practical guide to protecting generated PDFs with password encryption, dynamic watermarks, and time-limited access controls — with code examples you can drop into a backend service.

pdfsecurityencryptionwatermarksaccess-controlnode.js

Securing your generated PDFs: encryption, watermarks, and access control

PDFs are everywhere in backend systems: invoices, contracts, payslips, medical records, statements. Once a PDF leaves your server, you've lost control of it — unless you build that control into the file itself. This post walks through three layers of PDF security you should consider for any document containing sensitive data: encryption, watermarks, and access control at the delivery layer.

We'll keep it practical. Each section includes code you can adapt, and we'll point out where things get sharp.

Layer 1: Encryption and password protection

PDF encryption is a feature of the PDF spec itself. When a viewer opens an encrypted PDF, it prompts for a password before rendering content. Internally, the file is encrypted with AES (use AES-256 if you can — RC4 is broken).

A PDF supports two passwords:

  • User password — required to open the document.
  • Owner password — required to change permissions (printing, copying, editing).

If you're rolling your own with something like pdf-lib or qpdf, here's how it looks with qpdf from a Node service:

bash
qpdf --encrypt user-pass owner-pass 256 \ --print=low --modify=none --extract=n -- \ input.pdf output.pdf

From Node, shelling out works but isn't great for high throughput. With Kamy you can request encryption as part of the render call:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "statement", data: { accountId: "A-9921", month: "2024-11" }, security: { encryption: { algorithm: "AES-256", userPassword: deriveUserPassword(user), ownerPassword: process.env.PDF_OWNER_PASSWORD, permissions: { print: "lowRes", copy: false, modify: false, }, }, }, });

A few rules of thumb:

  • Never reuse the owner password across documents. Treat it like an API key.
  • Derive user passwords deterministically from something the recipient already knows (last 4 of SSN, date of birth, account number). Don't email them a password.
  • Don't store the passwords in plaintext alongside the PDFs. If your blob store is compromised, you want the PDF and its key to be in different blast radii.

A simple derivation pattern:

ts
import { createHmac } from "node:crypto"; function deriveUserPassword(user: { id: string; dob: string }) { return createHmac("sha256", process.env.PDF_PASSWORD_SECRET!) .update(`${user.id}:${user.dob}`) .digest("hex") .slice(0, 12); }

The recipient enters their DOB (or whatever input you chose) into a portal; your backend derives the same password and either decrypts server-side or hands it to the user.

Layer 2: Watermarks

Encryption stops unauthorized opens. Watermarks deter authorized leaks. If "John Smith — [email protected] — 2024-11-14" is stamped diagonally across every page, John thinks twice before forwarding it.

There are two flavors:

  • Visible watermarks — stamped text or images, usually semi-transparent across the page.
  • Invisible watermarks — metadata fields or near-invisible micro-text used for forensic tracing.

Visible watermarks are easy to add at render time. With Kamy:

ts
const pdf = await kamy.documents.render({ template: "contract", data: contractData, watermark: { text: `${user.email} • ${new Date().toISOString()}`, opacity: 0.15, angle: 45, fontSize: 48, repeat: true, }, });

If you're building it yourself with pdf-lib:

ts
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib"; const doc = await PDFDocument.load(existingBytes); const font = await doc.embedFont(StandardFonts.HelveticaBold); for (const page of doc.getPages()) { const { width, height } = page.getSize(); page.drawText(`CONFIDENTIAL — ${user.email}`, { x: width / 2 - 200, y: height / 2, size: 36, font, color: rgb(0.7, 0.7, 0.7), opacity: 0.2, rotate: degrees(45), }); } const stamped = await doc.save();

For forensic tracking, embed a per-recipient ID in the PDF metadata:

ts
doc.setSubject(`recipient:${user.id};issued:${Date.now()}`);

When a leaked copy shows up, you can match the metadata back to the user — even if they screenshotted and re-rendered, visible per-recipient watermarks usually survive.

Layer 3: Access control at delivery

The PDF itself is one layer. How you serve it is another — and the one developers screw up most often.

Don't do this:

txt
https://cdn.acme.com/pdfs/invoice-12345.pdf

That URL is forever, guessable, and shareable. Instead, use signed URLs with short TTLs and bind them to the requester.

ts
const url = await kamy.documents.signedUrl({ documentId: pdf.id, expiresIn: 300, // 5 minutes maxDownloads: 1, // single use ipBinding: req.ip, // optional, fragile behind proxies });

If you're rolling your own, the pattern is:

  1. Store PDFs in private object storage (S3 with no