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:
bashqpdf --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:
tsimport { 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:
tsimport { 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:
tsconst 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:
tsimport { 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:
tsdoc.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:
txthttps://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.
tsconst 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:
- Store PDFs in private object storage (S3 with no