DEV Community

dummyipsum
dummyipsum

Posted on

Password-protecting PDFs in JavaScript: what the encryption hides and what it doesn't

Adding a password to a PDF in JavaScript takes three lines. Knowing what that password actually protects takes a bit longer. I built a browser-only PDF protect tool and ran a few experiments along the way with @cantoo/pdf-lib (a maintained pdf-lib fork that supports encryption) and pdfjs-dist. Here is what I learned.

The basic version

import { PDFDocument } from '@cantoo/pdf-lib';

const doc = await PDFDocument.load(inputBytes);
doc.encrypt({
  userPassword: password,
  ownerPassword: password,
  algorithm: 'AES-256',
});
const protectedBytes = await doc.save({ useObjectStreams: true });
Enter fullscreen mode Exit fullscreen mode

It runs in the browser, so the file never leaves the user's device. That is the main reason to do it client-side: people protect PDFs because the content is sensitive, and uploading it to a server first defeats the purpose.

1. There are two passwords, and only one of them encrypts

A PDF has a user password (needed to open the file) and an owner password (needed to change the permissions: printing, copying, editing).

The user password is the one that matters. If you leave it empty and set only an owner password, the document still opens for everyone. I tested it: a file with userPassword: '' and an owner password opened in pdf.js with no password at all and returned the full page text.

The permission flags are just a number in the file (/P). The viewer decides whether to respect them. Firefox's PDF viewer (pdf.js) ignores them by default: its enablePermissions option is false. And removing them takes two lines:

const doc = await PDFDocument.load(ownerOnlyBytes, { password: '' });
const unrestricted = await doc.save(); // no /Encrypt dictionary any more
Enter fullscreen mode Exit fullscreen mode

So "can't print, can't copy" is a polite request, not security. If the content is confidential, set a user password.

2. Omitting permissions denies everything

In @cantoo/pdf-lib, if you set passwords but no permissions object, every permission is off. My test file ended up with /P -3904, which decodes to: no printing, no copying, no editing, no form filling, not even the copy-for-accessibility flag.

That's a safe default for the library, but probably not what you want when the goal is "only people with the password can open it". Once someone types the password, they usually should be able to print the document. Be explicit:

doc.encrypt({
  userPassword: password,
  ownerPassword: password,
  algorithm: 'AES-256',
  permissions: {
    printing: 'highResolution',
    modifying: true,
    copying: true,
    annotating: true,
    fillingForms: true,
    contentAccessibility: true,
    documentAssembly: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

3. Encryption doesn't hide the document's structure

PDF encryption works on strings and streams: page content, images, fonts, metadata values. The object structure around them stays readable.

I encrypted a three-page test file with the title "Salary review Q3" and the author "Jane Smith", then searched the raw bytes:

Searched for Without object streams With object streams
Jane Smith not found not found
/Author key found not found
/Type /Page found found
/Count 3 found found
/MediaBox found found

The metadata values and the page text are encrypted either way. But anyone with the file can still see that it has three pages and what size they are, without the password. useObjectStreams: true hides more of the structure (the Info dictionary keys moved into an encrypted object stream), but the page tree stayed visible in my test.

Usually that is fine. If the page count itself is sensitive, PDF encryption is the wrong tool, and you should encrypt the whole file (a password-protected 7z, age, GPG).

4. The cipher matters less than the password

@cantoo/pdf-lib supports four algorithms:

  • RC4-40 and RC4-128: broken. The library refuses them unless you pass allowWeakCryptography: true.
  • AES-128: the /V 4 handler from PDF 1.6.
  • AES-256: the /V 5 /R 6 handler, the library's default and the one ISO 32000-2 (PDF 2.0) recommends. In a PDF 1.7 file it is declared as an Adobe extension.

Both AES options are fine. The weak point is the password. The encryption key is derived from it, and neither PDF handler uses a slow, memory-hard function like Argon2 or scrypt. Whoever has the file can try passwords offline as fast as their hardware allows, with no rate limit and no lockout. A six-letter password will not survive that, so ask for a long one, ideally several random words.

And send the password through a different channel than the file. A password in the same email as the attachment protects nothing.

5. Verify the output before you hand it over

A file that "should" be encrypted is not the same as a file that is. Before offering the download, I reopen the result with pdf.js three ways:

import * as pdfjs from 'pdfjs-dist';

async function tryOpen(bytes, password) {
  try {
    const pdf = await pdfjs.getDocument({ data: bytes.slice(), password }).promise;
    return pdf.numPages;
  } catch (error) {
    if (error.name === 'PasswordException') return null;
    throw error;
  }
}

const withoutPassword = await tryOpen(protectedBytes);
const wrongPassword = await tryOpen(protectedBytes, password + 'x');
const rightPassword = await tryOpen(protectedBytes, password);

const ok = withoutPassword === null
  && wrongPassword === null
  && rightPassword === originalPageCount;
Enter fullscreen mode Exit fullscreen mode

Two details: pass a copy (bytes.slice()), because pdf.js may transfer the buffer to its worker and leave you holding an empty one. And check error.name, so a damaged file isn't reported as "password works".

My test results, for reference:

no password -> PasswordException: No password given
wrong pw    -> PasswordException: Incorrect Password
right pw    -> opened, pages=3, text="Confidential salary data page 1"
Enter fullscreen mode Exit fullscreen mode

6. Some PDFs must not be encrypted

PDF/A (the archival format) forbids encryption, so the result would no longer be valid PDF/A. @cantoo/pdf-lib throws an error for these files. Catch it and tell the user why, instead of showing a generic failure.

Also decide what happens with a file that is already encrypted. I refuse it and ask the user to unlock it first, so a file never ends up with two layers of passwords that nobody remembers.

Try it

If you just need to protect a PDF without installing anything, this is the tool I built with the approach above: everything runs in the browser tab, and the download appears only after the result has been reopened: it must refuse to open without the password, open with it, and every page must match the original.

https://www.dummyipsum.com/password-protect-pdf

What do you use for PDF encryption on the server side, qpdf or something else?

Top comments (0)