Securing Your Generated PDFs: Encryption, Watermarks, and Access Control

A practical guide to protecting PDFs you generate at scale: password encryption, dynamic watermarks, signed URLs, and audit-friendly access control.

pdfsecurityencryptionwatermarksaccess-controlnode.js

Securing Your Generated PDFs: Encryption, Watermarks, and Access Control

PDFs generated by your backend often carry sensitive data: invoices with bank details, contracts, medical reports, payslips. Once a file leaves your server, you lose direct control over it. The good news is PDFs support several layers of security you can apply at generation time, and combining them well gives you something close to defense in depth.

This post walks through the three controls that matter most in practice — encryption, watermarking, and access control — and how to wire them up.

1. Encryption: password-protecting the file itself

PDF encryption is built into the format. There are two passwords:

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

For programmatic generation, you almost always want AES-256. Older RC4 encryption is still common in legacy tooling and should be avoided.

If you're using pdf-lib or similar low-level libraries, encryption support is limited — many devs end up shelling out to qpdf as a post-processing step:

bash
qpdf --encrypt "userpass" "ownerpass" 256 \ --print=full --modify=none --extract=n -- \ input.pdf output.pdf

With Kamy, encryption is a render option, so you don't need a second pass:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2024-001", total: 1299.0 }, security: { encryption: { algorithm: "AES-256", userPassword: deriveUserPassword(customerId), ownerPassword: process.env.PDF_OWNER_PASSWORD, permissions: { printing: "highResolution", modifying: false, copying: false, annotating: false, }, }, }, });

A few practical notes:

  • Never hardcode passwords. Derive user passwords from something the recipient already knows (last 4 digits of their account, date of birth, etc.) and communicate the scheme out-of-band.
  • Owner passwords should be high-entropy and stored in your secret manager. They protect permissions, not content.
  • Encryption ≠ DRM. A determined user with the open password can always screenshot or re-print. Encryption raises the cost of casual leaks, nothing more.

2. Watermarks: making leaks traceable

Watermarks serve two purposes: deterrence ("DO NOT DISTRIBUTE") and forensics (identifying who leaked a document). For the second purpose, the watermark must be unique per recipient.

A solid pattern is to embed:

  • Recipient email or user ID
  • Timestamp of generation
  • A short request ID you can correlate to logs

Visual watermarks are good for human readability. For forensic use, you also want the data baked into the PDF metadata so it survives screenshotting (at least when the file itself is shared).

ts
const pdf = await kamy.documents.render({ template: "contract", data: contractData, watermark: { text: `Confidential — ${user.email} — ${new Date().toISOString()}`, opacity: 0.15, rotation: -45, fontSize: 48, position: "diagonal", }, metadata: { title: "Service Agreement", author: "Acme Corp", custom: { recipientId: user.id, requestId: ctx.requestId, }, }, });

If you're rolling your own with pdf-lib, the watermark goes on every page after rendering:

ts
import { PDFDocument, rgb, degrees, StandardFonts } from "pdf-lib"; const doc = await PDFDocument.load(existingPdfBytes); 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.1, 0.1), opacity: 0.15, rotate: degrees(-45), }); } const watermarked = await doc.save();

For high-value documents, consider also embedding an invisible steganographic marker — tiny perturbations to character positions or whitespace that encode the recipient ID. This is overkill for most use cases but valuable for legal/financial workflows.

3. Access control: don't just hand out the file

The biggest mistake teams make is generating a secure PDF and then serving it from a public URL. Always gate the delivery.

Signed URLs with short expiry are the minimum bar:

ts
const { signedUrl, expiresAt } = await kamy.documents.render({ template: "payslip", data: payslipData, delivery: { mode: "signedUrl", ttlSeconds: 300, // 5 minutes maxDownloads: 1, ipBinding: req.ip, }, }); return res.json({ url: signedUrl, expiresAt });

Key properties to enforce:

  • TTL measured in minutes, not days. If a user wants the file later, regenerate it.
  • Single-use tokens where possible. A download counter on the server eliminates link-hoarding.
  • Audit every access. Log who generated the file, who accessed it, from which IP, and when.

For sensitive documents, prefer proxied downloads over direct object-storage URLs. Your API authenticates the request, checks authorization, streams the file, then logs the access:

ts