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:
- Security Risks: Sensitive documents exposed to third-party servers
- Privacy Concerns: Unknown data retention policies
- Network Vulnerabilities: Documents transmitted over the internet
- 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
};
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
}
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 />;
}
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>
);
};
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 };
};
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);
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=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
};
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!
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
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"
])
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");
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)