DEV Community

monkeymore studio
monkeymore studio

Posted on

Securing PDFs with Password Protection: A WebAssembly-Powered Implementation

Introduction

PDF documents often contain sensitive information that requires protection. Whether it's financial reports, legal contracts, or personal records, password protection ensures that only authorized users can access your documents. In this article, we'll explore how to build a pure browser-side PDF password protection tool using the powerful QPDF library compiled to WebAssembly, providing military-grade 256-bit encryption without any server uploads.

Why Browser-Side PDF Protection?

Traditional PDF protection services require uploading your documents to external servers:

  1. Security Risks: Sensitive documents exposed to third-party servers
  2. Privacy Concerns: Unknown data retention policies
  3. Network Vulnerabilities: Documents transmitted over the internet
  4. Compliance Issues: May violate data protection regulations (GDPR, HIPAA, etc.)

Browser-side encryption eliminates these risks:

  • Documents never leave your device
  • No network transmission of sensitive files
  • You control the encryption process entirely
  • Works offline for maximum security

Architecture Overview

Our implementation uses QPDF compiled to WebAssembly for native-grade encryption performance:

Core Technologies

1. QPDF - Industrial PDF Security

QPDF is a mature C++ library that provides:

  • 256-bit AES encryption: Military-grade security
  • Password protection: User and owner password support
  • Granular permissions: Control printing, copying, editing
  • Standard compliance: PDF specification compliant

2. WebAssembly (WASM)

By compiling QPDF to WASM:

  • Native Performance: C++ execution speed in the browser
  • Strong Encryption: No JavaScript-based crypto limitations
  • Cross-Platform: Works on any device with a modern browser
  • Sandboxed Security: Isolated execution environment

Data Structures

1. Protection Options

// hooks/useqpdf.ts
type ProtectOptons = {
  userPassword: string;        // Password required to open PDF
  ownerPassword: string;       // Password for full access
  accessibility: boolean;      // Allow screen readers
  extract: boolean;            // Allow copying text/images
  print: "none" | "low" | "full";  // Print permission level
  assemble: boolean;           // Allow document assembly
  annotate: boolean;           // Allow comments
  form: boolean;               // Allow form filling
  modify: boolean;             // Allow modifications
  clearTextMetadata: boolean;  // Keep metadata unencrypted
};
Enter fullscreen mode Exit fullscreen mode

2. Worker Functions Interface

// hooks/useqpdf.ts
interface WorkerFunctions {
  init: () => Promise<void>;
  protect: (file: File, options: ProtectOptons) => Promise<ArrayBuffer>;
  unlock: (file: File) => Promise<ArrayBuffer | null>;
  // ... other functions
}
Enter fullscreen mode Exit fullscreen mode

Implementation

1. Entry Point - Page Component

// protect/page.tsx
import { type Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { seoConfig } from "../_components/seo-config";
import { Protect } from "@/app/[locale]/_components/qpdf/protect";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const seo =
    seoConfig[locale as keyof typeof seoConfig]?.protect ||
    seoConfig["en-us"].protect;

  return {
    title: seo.title,
    description: seo.description,
  };
}

export default async function Page() {
  return <Protect />;
}
Enter fullscreen mode Exit fullscreen mode

2. Main Component - Password Protection UI

// _components/qpdf/protect.tsx
"use client";

import { useState } from "react";
import { useTranslations } from "next-intl";
import { PdfPage } from "../pdfpage";
import { useInputValue } from "@/hooks/useInputValue";
import { useQpdf } from "@/hooks/useqpdf";
import { autoDownloadBlob } from "@/utils/pdf";

export const Protect = () => {
  const [files, setFiles] = useState<File[]>([]);

  // Password state
  const { value: userPassword, onChange: onChangeUserPassword } =
    useInputValue<string>("");

  // Permission states
  const { value: accessibility, onChange: onChangeAccessibility } =
    useInputValue<boolean>(true);
  const { value: extract, onChange: onChangeExtract } =
    useInputValue<boolean>(true);
  const { value: print, onChange: onChangePrint } =
    useInputValue<boolean>(true);
  const { value: assemble, onChange: onChangeAssemble } =
    useInputValue<boolean>(true);
  const { value: form, onChange: onChangeForm } = useInputValue<boolean>(true);
  const { value: modify, onChange: onChangeModify } =
    useInputValue<boolean>(true);

  const { protect, isLoading } = useQpdf();
  const t = useTranslations("Protect");

  const mergeInMain = async () => {
    if (files.length === 0) return;

    // Apply password protection with permissions
    const outputFile = await protect(files[0]!, {
      userPassword: userPassword,           // Optional: password to open
      ownerPassword: "monkeymore",          // Owner password (hardcoded)
      accessibility: accessibility,         // Screen reader access
      extract: extract,                     // Copy/paste permission
      print: print ? "full" : "none",       // Print permission
      assemble: assemble,                   // Page manipulation
      annotate: false,                      // Comments (disabled)
      form: form,                           // Form filling
      modify: modify,                       // General modifications
      clearTextMetadata: false,             // Encrypt metadata
    });

    if (outputFile) {
      autoDownloadBlob(new Blob([outputFile]), "protected.pdf");
    }
  };

  const onPdfFiles = (files: File[]) => {
    console.log("Files selected");
    files.forEach((e) => console.log(e.name));
    setFiles(files);
  };

  return (
    <PdfPage
      title={t("title")}
      onFiles={onPdfFiles}
      process={mergeInMain}
      processDisabled={isLoading}
    >
      <div className="flex flex-col gap-4">
        {/* User Password */}
        <div className="form-control">
          <label className="label">
            <span className="label-text">{t("userPassword")}</span>
          </label>
          <input
            type="password"
            className="input input-bordered"
            value={userPassword}
            onChange={onChangeUserPassword}
            placeholder={t("passwordPlaceholder")}
          />
          <label className="label">
            <span className="label-text-alt">{t("passwordHelp")}</span>
          </label>
        </div>

        {/* Permissions */}
        <div className="divider">{t("permissions")}</div>

        <div className="grid grid-cols-2 gap-4">
          <label className="label cursor-pointer">
            <span className="label-text">{t("accessibility")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={accessibility}
              onChange={onChangeAccessibility}
            />
          </label>

          <label className="label cursor-pointer">
            <span className="label-text">{t("extract")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={extract}
              onChange={onChangeExtract}
            />
          </label>

          <label className="label cursor-pointer">
            <span className="label-text">{t("print")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={print}
              onChange={onChangePrint}
            />
          </label>

          <label className="label cursor-pointer">
            <span className="label-text">{t("assemble")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={assemble}
              onChange={onChangeAssemble}
            />
          </label>

          <label className="label cursor-pointer">
            <span className="label-text">{t("form")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={form}
              onChange={onChangeForm}
            />
          </label>

          <label className="label cursor-pointer">
            <span className="label-text">{t("modify")}</span>
            <input
              type="checkbox"
              className="checkbox"
              checked={modify}
              onChange={onChangeModify}
            />
          </label>
        </div>
      </div>
    </PdfPage>
  );
};
Enter fullscreen mode Exit fullscreen mode

3. QPDF Hook - Worker Communication

// hooks/useqpdf.ts
import { useEffect, useRef, useState } from "react";
import * as Comlink from "comlink";
import PdfWorker from "@/lib/pdf.worker.js";

type ProtectOptons = {
  userPassword: string;
  ownerPassword: string;
  accessibility: boolean;
  extract: boolean;
  print: "none" | "low" | "full";
  assemble: boolean;
  annotate: boolean;
  form: boolean;
  modify: boolean;
  clearTextMetadata: boolean;
};

interface WorkerFunctions {
  init: () => Promise<void>;
  protect: (file: File, options: ProtectOptons) => Promise<ArrayBuffer>;
}

export const useQpdf = () => {
  const workerRef = useRef<Comlink.Remote<WorkerFunctions>>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    async function initWorker() {
      const worker = new PdfWorker();
      workerRef.current = Comlink.wrap<WorkerFunctions>(worker);
      await workerRef.current.init();
    }
    initWorker();
  }, []);

  const protect = async (
    file: File, 
    options: ProtectOptons
  ): Promise<ArrayBuffer | null> => {
    if (!workerRef.current) return null;
    setIsLoading(true);
    try {
      const result = await workerRef.current.protect(file, options);
      return result;
    } finally {
      setIsLoading(false);
    }
  };

  return { protect, isLoading };
};
Enter fullscreen mode Exit fullscreen mode

4. Web Worker - QPDF Encryption

// lib/pdf.worker.js
import * as Comlink from "comlink";
import qpdfwasm from "@/lib/qpdfwasm";

let qpdf = null;

const obj = {
  async init() {
    qpdf = await qpdfwasm();
  },

  async protect(file, options) {
    const {
      userPassword,
      ownerPassword,
      accessibility,
      extract,
      print,
      assemble,
      annotate,
      form,
      modify,
      clearTextMetadata,
    } = options;

    const arrayBuffer = await file.arrayBuffer();
    const uint8Array = new Uint8Array(arrayBuffer);

    // Write input file to virtual filesystem
    qpdf.FS.writeFile(`/input.pdf`, uint8Array);

    // Build QPDF encryption command
    const params = [
      "/input.pdf",
      "--encrypt",
      userPassword || "",           // User password (empty = no open password)
      ownerPassword,                // Owner password
      "256",                        // 256-bit AES encryption
    ];

    // Add permission flags
    params.push(accessibility ? "--accessibility=y" : "--accessibility=n");
    params.push(extract ? "--extract=y" : "--extract=n");
    params.push("--print=" + print);
    params.push(assemble ? "--assemble=y" : "--assemble=n");
    params.push(annotate ? "--annotate=y" : "--annotate=n");
    params.push(form ? "--form=y" : "--form=n");
    params.push(modify ? "--modify-other=y" : "--modify-other=n");

    if (clearTextMetadata) {
      params.push("--cleartext-metadata");
    }

    params.push("--");
    params.push("/output.pdf");

    console.log("QPDF encryption params:", params);

    // Execute encryption
    qpdf.callMain(params);

    // Read encrypted output
    const outputFile = qpdf.FS.readFile("/output.pdf");

    // Cleanup
    qpdf.FS.unlink("/input.pdf");
    qpdf.FS.unlink("/output.pdf");

    return outputFile.buffer;
  },
};

Comlink.expose(obj);
Enter fullscreen mode Exit fullscreen mode

Understanding PDF Passwords

Two Types of Passwords

User Password:

  • Required to open the PDF
  • If set, users must enter it to view the document
  • Users with this password have all permissions

Owner Password:

  • Grants full control over the PDF
  • Can modify permissions and passwords
  • Should be kept secure

Permission Matrix

Permission Flag Description
Accessibility --accessibility=y/n Allow screen readers for visually impaired
Extract --extract=y/n Allow copying text and images
Print --print=full/low/none Control printing quality
Assemble --assemble=y/n Allow inserting/rotating pages
Annotate --annotate=y/n Allow comments and markup
Form --form=y/n Allow filling form fields
Modify --modify-other=y/n Allow other modifications

Processing Flow

Complete User Flow

Security Best Practices

1. Strong Passwords

// Enforce strong passwords
const isStrongPassword = (password: string): boolean => {
  return password.length >= 8 &&
         /[A-Z]/.test(password) &&      // Uppercase
         /[a-z]/.test(password) &&      // Lowercase
         /[0-9]/.test(password) &&      // Numbers
         /[^A-Za-z0-9]/.test(password); // Special chars
};
Enter fullscreen mode Exit fullscreen mode

2. Permission Granularity

High Security (Confidential):

  • User password: Required
  • Print: None
  • Extract: No
  • Modify: No

Medium Security (Internal Use):

  • User password: Optional
  • Print: Low quality
  • Extract: Yes
  • Modify: No

Low Security (Distribution):

  • User password: None
  • Print: Full
  • Extract: Yes
  • Modify: No

3. Owner Password Security

āš ļø Important: The current implementation hardcodes the owner password:

ownerPassword: "monkeymore",  // Should be configurable!
Enter fullscreen mode Exit fullscreen mode

Recommendation: Allow users to set their own owner password for maximum security.

Browser Compatibility

Requirements:

  • WebAssembly - For QPDF execution
  • Web Workers - For background processing
  • ES6+ - Modern JavaScript features
  • File API - For reading PDFs

Supported in all modern browsers.

Technical Highlights

1. 256-bit AES Encryption

QPDF uses industry-standard AES-256 encryption:

params.push("256");  // 256-bit encryption strength
Enter fullscreen mode Exit fullscreen mode

Security Level:

  • AES-256 is approved for TOP SECRET classification by NSA
  • Computationally infeasible to brute force
  • Industry standard for document encryption

2. Command-Line Interface

QPDF WASM exposes the same CLI as desktop QPDF:

# Desktop
qpdf input.pdf --encrypt userpass ownerpass 256 --print=full --extract=y -- output.pdf

# WASM (via callMain)
qpdf.callMain([
  "/input.pdf",
  "--encrypt", userPassword, ownerPassword, "256",
  "--print=full",
  "--extract=y",
  "--",
  "/output.pdf"
])
Enter fullscreen mode Exit fullscreen mode

3. Virtual Filesystem Security

Files are processed in-memory only:

// Write to virtual filesystem (RAM only)
qpdf.FS.writeFile(`/input.pdf`, uint8Array);

// Process...

// Read result
const output = qpdf.FS.readFile("/output.pdf");

// Cleanup (immediate deletion)
qpdf.FS.unlink("/input.pdf");
qpdf.FS.unlink("/output.pdf");
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a browser-side PDF password protection tool with QPDF WASM demonstrates how modern web technologies can provide enterprise-grade security. By combining:

  • QPDF for military-grade 256-bit AES encryption
  • WebAssembly for native performance
  • Granular permissions for access control
  • Local processing for maximum privacy

We've created a tool that offers:

  • Complete privacy - Documents never leave your device
  • Strong encryption - 256-bit AES standard
  • Flexible permissions - Control every aspect of document access
  • Cross-platform - Works on any modern browser
  • No server dependency - Works offline

The ability to apply sophisticated password protection and permission controls entirely in the browser makes document security accessible to everyone.


Need to protect your sensitive PDFs? Try our free online tool at Free Online PDF Tools - apply 256-bit AES encryption with customizable permissions, all processed locally in your browser for maximum security and privacy!

Top comments (0)