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

A practical guide to protecting generated PDFs with encryption, visible and invisible watermarks, and short-lived access controls — with code you can use today.

pdfsecurityencryptionwatermarksaccess-controlnode.js

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:

bash
qpdf --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:

ts
import { 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:

ts
import { 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:

ts
import { 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:

ts
await 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:

  1. Storing PDFs in a public bucket because "the URL is hard to guess."
  2. Issuing download URLs that never expire.

Use short-lived, signed URLs. With S3:

ts
import { 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(