Securing Your Generated PDFs: Encryption, Watermarks, and Access Control
PDFs generated by your backend often contain things you don't want leaking: invoices with customer data, signed contracts, payroll statements, medical records. "Generate and email" is fine until a link gets forwarded, an S3 bucket goes public, or a laptop gets stolen. This post walks through three layers of defense you should actually implement — PDF-level encryption, watermarking, and access control at the delivery layer — with concrete code.
Layer 1: PDF-level encryption
PDF encryption is defined by the PDF spec itself. When applied, the file bytes on disk are encrypted and a password (or certificate) is required to open or modify them. There are two password types:
- User password: required to open the document.
- Owner password: required to change permissions (printing, copying, editing).
If you're generating PDFs with a library like pdf-lib, you'll notice it doesn't support encryption directly. For that you'll want hummus-recipe, qpdf, or a service that handles it. Here's an example using qpdf as a shell step after generation:
bashqpdf --encrypt "user-pass" "owner-pass" 256 \ --print=low --modify=none --extract=n -- \ input.pdf output.pdf
If you'd rather stay in Node, shelling out works fine for most workloads:
tsimport { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export async function encryptPdf( inputPath: string, outputPath: string, userPass: string, ownerPass: string, ) { await run("qpdf", [ "--encrypt", userPass, ownerPass, "256", "--print=low", "--modify=none", "--extract=n", "--", inputPath, outputPath, ]); }
With Kamy, you can skip the shell call and request encryption as part of the render:
tsimport { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-10293" }, security: { encryption: { userPassword: process.env.PDF_USER_PASSWORD, ownerPassword: process.env.PDF_OWNER_PASSWORD, permissions: ["print-low"], }, }, });
A few practical notes:
- Use AES-256, not RC4 or AES-128. Older algorithms are trivially crackable now.
- Don't reuse one password across all customers. Derive per-document passwords from a secret and a document ID.
- The password still has to reach the user. SMS, a second email, or a password inside your authenticated web app all work. Don't send it in the same email as the attachment.
Layer 2: Watermarks
Watermarks don't prevent leaks, but they change the economics of leaking. A document stamped with "Issued to [email protected] on 2025-03-14 — Request ID 8f3a..." is traceable. People think twice.
There are two useful kinds:
Visible watermarks — rendered into the content: a diagonal "CONFIDENTIAL", a footer with the recipient's email, or a per-page QR code linking to an audit record.
Invisible/forensic watermarks — subtle encoded data (metadata fields, zero-width characters, barely-perceptible font kerning) that identifies the recipient.
Here's a simple visible watermark with pdf-lib:
tsimport { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib"; import { readFile, writeFile } from "node:fs/promises"; export async function stampWatermark( input: string, output: string, text: string, ) { const pdf = await PDFDocument.load(await readFile(input)); const font = await pdf.embedFont(StandardFonts.HelveticaBold); for (const page of pdf.getPages()) { const { width, height } = page.getSize(); page.drawText(text, { x: width / 2 - 150, y: height / 2, size: 42, font, color: rgb(0.85, 0.85, 0.85), rotate: degrees(-30), opacity: 0.4, }); } await writeFile(output, await pdf.save()); }
If you're using Kamy templates, watermarking is a render option so you don't maintain a second pipeline:
tsawait kamy.documents.render({ template: "contract", data: contractData, security: { watermark: { text: `Issued to ${user.email} • ${new Date().toISOString()}`, opacity: 0.25, rotation: -30, }, }, });
Always include something unique per recipient. "CONFIDENTIAL" alone tells you nothing when it leaks.
Layer 3: Access control at delivery
Encryption and watermarks protect the file itself. Access control protects the pipe through which it travels. The two biggest mistakes:
- Storing PDFs in a public bucket because "the URL is hard to guess."
- Issuing download URLs that never expire.
Use short-lived, signed URLs. With S3:
tsimport { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const s3 = new S3Client({ region: "us-east-1" }); export async function getDownloadUrl(key: string) { const cmd = new GetObjectCommand({ Bucket: "docs-private", Key: key, }); return getSignedUrl(