Securing Your Generated PDFs: Encryption, Watermarks, and Access Control
PDFs are often the final delivery format for things you really don't want leaking: invoices, contracts, medical reports, payslips, internal financial statements. Once a PDF leaves your server, you've lost most of your control over it. The job of "securing a PDF" is really about three overlapping layers: encrypting the file so unauthorized readers can't open it, watermarking it so leaks are traceable, and controlling who can fetch it in the first place.
Here's how to think about each layer, and how to implement them without painting yourself into a corner.
Layer 1: Encryption (so the file itself is protected)
PDF encryption is built into the format. Modern PDFs support AES-128 and AES-256 with two passwords:
- User password — required to open the document.
- Owner password — controls permissions (printing, copying, editing) for users who can already open it.
A common mistake is setting only the owner password and assuming the document is "protected." It isn't — anyone can still open and read it. If the contents are sensitive, set a user password.
Generating a password-protected PDF with pdf-lib plus qpdf (since pdf-lib doesn't ship encryption itself):
bashqpdf --encrypt user-pass owner-pass 256 -- input.pdf output.pdf
If you're using Kamy, encryption is part of the render request:
tsimport { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2041", total: 1280 }, security: { userPassword: deriveUserPassword(customerId), ownerPassword: process.env.PDF_OWNER_PASSWORD, permissions: { print: "highResolution", copy: false, modify: false, }, }, });
A few practical rules:
- Never reuse a single global password. Derive per-recipient passwords (e.g.
HMAC(secret, customerId)truncated and formatted) so a leaked password compromises one document, not all of them. - Communicate passwords out-of-band. Don't email the PDF and password together. Send the password via a different channel — SMS, in-app message, a value the user already knows like the last four digits of their tax ID.
- AES-256 only. RC4 and older PDF encryption schemes are broken. If your library defaults to anything less than AES-128, switch.
Layer 2: Watermarks (for traceability and deterrence)
Watermarks won't stop a determined leaker, but they shift behavior and make leaks attributable. There are two useful flavors:
Visible watermarks — diagonal text across each page like Confidential — issued to [email protected] on 2025-03-14. The recipient sees it. If a screenshot ends up on the internet, you know who leaked it.
Invisible / forensic watermarks — subtle per-recipient variations (micro-shifts in text positioning, invisible metadata, zero-width characters in text fields). Harder to remove, harder to notice. Useful for high-value documents.
For most use cases, a visible per-recipient watermark is enough. With Kamy:
tsconst pdf = await kamy.documents.render({ template: "contract", data: contractData, watermark: { text: `Issued to ${user.email} • ${new Date().toISOString()}`, opacity: 0.15, rotation: -45, fontSize: 48, repeat: "tiled", }, });
If you're rolling your own with pdf-lib:
tsimport { PDFDocument, rgb, degrees, StandardFonts } from "pdf-lib"; const doc = await PDFDocument.load(existingPdfBytes); const font = await doc.embedFont(StandardFonts.HelveticaBold); const text = `CONFIDENTIAL — ${userEmail}`; for (const page of doc.getPages()) { const { width, height } = page.getSize(); page.drawText(text, { x: width / 6, y: height / 2, size: 36, font, color: rgb(0.75, 0.75, 0.75), opacity: 0.25, rotate: degrees(-45), }); } const watermarkedBytes = await doc.save();
Tip: include something that's expensive for the recipient to remove or fake — their own email, account ID, or a timestamp. Generic "CONFIDENTIAL" stamps are ignored.
Layer 3: Access control (who can fetch the PDF)
Most data leaks happen not because someone cracked AES-256, but because a PDF was sitting at a guessable URL on S3. Securing the delivery matters as much as securing the file.
A few patterns that work:
Signed, short-lived URLs. Generate a URL that expires in minutes, not days. Tie it to the requesting user when possible.
tsconst { url } = await kamy.documents.getDownloadUrl({ documentId: pdf.id, expiresIn: 300, // 5 minutes restrictToIp: req.ip, });
Authenticated proxy endpoints. Never serve PDFs directly from object storage to end users. Put an authenticated endpoint in front:
tsapp.get("/invoices/:id.pdf", requireAuth, async (req, res) => { const invoice = await db.invoices.findById(req.params.id); if (invoice.userId !== req.user.id) return res.sendStatus(403); const stream = await storage.getStream(invoice.pdfKey); res.setHeader("Content-Type", "application/pdf"); res.setHe