Why Drawing a Black Box on a PDF Doesn't Work (And How to Build Real In-Browser Redaction)
We've all seen the news headlines: a high-profile legal case or government report releases a "redacted" document, only for an investigative journalist to press Ctrl+A -> Ctrl+C, paste it into Notepad, and instantly expos
We've all seen the news headlines: a high-profile legal case or government report releases a "redacted" document, only for an investigative journalist to press Ctrl+A -> Ctrl+C, paste it into Notepad, and instantly expose all the classified names and bank accounts.
It happened in the Paul Manafort legal filings. It happened in police department releases. And it happens every day in corporate contracts.
Why does this keep happening in 2026? And more importantly: what does it actually take to programmatically redact a PDF the right way, without sending confidential documents to a random cloud server?
In this post, we'll dive into the internals of the PDF object model, look at why "visual masking" is a dangerous illusion, and walk through how we engineered a 100% client-side redaction engine in TypeScript using pdfjs-dist and pdf-lib.
1. The "Black Box" Illusion: Why Naive Redaction Fails
To understand why PDFs leak secrets, you have to understand how PDF content streams work.
A PDF is not an image; it is an arbitrary container of vector instructions, font glyph tables, and text rendering commands. When you open a PDF editor or use a typical library to draw a black rectangle over sensitive text, here is what gets emitted into the PDF Content Stream:
% What you think happened:
% "The text is destroyed and covered with black ink"
% What actually happens in the PDF Content Stream:
BT
/F1 12 Tf
72 712 Td
(CONFIDENTIAL_SOCIAL_SECURITY_NUMBER_HERE) Tj
ET
0 0 0 rg
70 710 200 15 re
f
Look closely at that stream:
-
BT ... ETtells the PDF viewer to place text glyphs on the canvas at coordinate(72, 712). -
reandfsimply tell the graphics state to paint a filled black rectangle over those coordinates. The underlying glyph data, font encoding dictionary, and text extraction coordinates are still 100% present in the byte stream. Any PDF parser, screen reader, or basic clipboard copy command will extract the secret word-for-word. Even worse, if someone opens the document in Adobe Acrobat Pro, Inkscape, or Adobe Illustrator, they can simply click on the black box, press theDeletekey, and view the original text beneath it completely undamaged. ## 2. The Two Architectural Approaches to True Redaction To genuinely redact content, the sensitive glyphs must cease to exist in the resulting binary. There are two primary ways to achieve this: ### Approach A: Content Stream Surgery (AST Rewriting) Parse the raw PDF content stream, calculate glyph bounding boxes according to font metrics and affine transformation matrices (cm), split text operators (Tj,TJ), remove the target ranges, and strip corresponding references in bookmarks, metadata, and structural trees. Pros: Keeps the file 100% vector, resulting in razor-sharp text and minimal file size. Cons: Immensely complex and fragile. If the text is part of an XObject Form, an embedded Type 3 font, or masked behind non-standard clipping paths, glyph leakage can still slip through undetected. ### Approach B: Pixel-Level Burning & Selective Page Rasterization (The Hybrid Pipeline) For documents containing sensitive data, the safest guarantee against zero-day text-extraction leaks is physical pixel flattening: - Render the affected page to an offscreen bitmap at high DPI.
- Paint opaque pixels directly over the target areas, permanently overwriting the raw RGBA buffer.
- Export as a flattened bitmap and embed it back into a pristine document. The challenge? Rasterizing a 200-page document turns a 2MB PDF into a sluggish 80MB bloated monstrosity and destroys selectable text across the entire file. The Solution: A Hybrid Selective Pipeline. ## 3. Implementing the Hybrid Redaction Engine in the Browser When building this in modern client-side JavaScript, the goal was two-fold:
- Zero Server Footprint: Confidential legal documents should never leave the user's device.
- Selective Sanitization: Untouched pages must remain 100% vector and pristine; only pages with active redactions undergo physical destruction. Here is how the core export pipeline looks:
import { PDFDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
interface RedactionBox {
pageIndex: number; // 0-based
x: number; // Normalized (0.0 to 1.0)
y: number; // Normalized (0.0 to 1.0)
width: number;
height: number;
color: 'black' | 'white';
}
export async function sanitizePdf(
file: File,
redactions: RedactionBox[]
): Promise<Uint8Array> {
const arrayBuffer = await file.arrayBuffer();
// 1. Load original document in both pdf-lib (for page manipulation)
// and PDF.js (for high-fidelity rendering)
const originalPdfDoc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
const pdfJsDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const newPdfDoc = await PDFDocument.create();
const totalPages = originalPdfDoc.getPageCount();
for (let i = 0; i < totalPages; i++) {
const pageRedactions = redactions.filter((box) => box.pageIndex === i);
if (pageRedactions.length === 0) {
// Pristine page: Copy directly without touching vectors or bloating file size
const [copiedPage] = await newPdfDoc.copyPages(originalPdfDoc, [i]);
newPdfDoc.addPage(copiedPage);
} else {
// Tainted page: Perform physical pixel-level burning
const pdfJsPage = await pdfJsDoc.getPage(i + 1);
// Use 2.0x scale (~200 DPI) for crisp archival quality
const scale = 2.0;
const viewport = pdfJsPage.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
// 1. Render original visual layer
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, viewport.width, viewport.height);
await pdfJsPage.render({ canvasContext: ctx, viewport }).promise;
// 2. Physically burn opaque blocks into the raster buffer
// This obliterates underlying pixel data beyond mathematical recovery
for (const box of pageRedactions) {
const rx = Math.round(box.x * viewport.width);
const ry = Math.round(box.y * viewport.height);
const rw = Math.round(box.width * viewport.width);
const rh = Math.round(box.height * viewport.height);
ctx.fillStyle = box.color === 'white' ? '#ffffff' : '#000000';
ctx.fillRect(rx, ry, rw, rh);
}
// 3. Compress to high-quality JPEG binary to prevent PNG alpha layer artifacts
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', 0.94)
);
if (!blob) throw new Error('Failed to export sanitized canvas');
const imageBytes = await blob.arrayBuffer();
const embeddedImage = await newPdfDoc.embedJpg(imageBytes);
// 4. Construct a new sanitized PDF page with identical dimensions
const originalPage = originalPdfDoc.getPage(i);
const { width, height } = originalPage.getSize();
const sanitizedPage = newPdfDoc.addPage([width, height]);
sanitizedPage.drawImage(embeddedImage, {
x: 0,
y: 0,
width,
height,
});
}
}
// Save sanitized binary
return await newPdfDoc.save();
}
4. Key Gotchas & Lessons Learned
If you are building something similar, watch out for these traps:
Coordinate System Inversion
DOM coordinates originate at the top-left (0, 0). The standard PDF Cartesian coordinate system originates at the bottom-left (0, 0). Furthermore, PDF pages can have arbitrary rotations (/Rotate 90, 180, or 270).
Storing redaction bounding boxes as normalized percentages (0.0 - 1.0) relative to the viewport makes cross-coordinate mapping significantly simpler and prevents misalignment during high-DPI rasterization.
Metadata Sanitization
Even if you burn the page pixels, do not forget to strip Document Info dictionaries (Author, Keywords, Title, Subject) and XMP metadata streams. In many real-world breaches, the page content was blanked out, but the document metadata still contained the original author's name, revision logs, and internal file paths.
Alpha Channels Can Be Deceptive
If you export to PNG with semi-transparency or anti-aliased edge smoothing, sub-pixel rendering can sometimes leave hints of glyph outlines at the perimeter. Forcing an opaque background fill and converting to flat JPEG buffers completely eliminates this risk.
5. Live Architecture & Verification
If you want to inspect how this selective rasterization pipeline behaves with real multi-page documents, you can test a live implementation over at VersaPDF's Redact Tool.
Opening your browser's DevTools Network tab will confirm that zero document bytes are transmitted to any remote server: the canvas rendering, raster destruction, and pdf-lib assembly run strictly within the client's local memory.
Discussion
How are you approaching document sanitization in your own stacks? Are you relying on native poppler/qpdf CLI utilities on the server, or moving toward zero-knowledge browser runtimes? Letβs talk in the comments!
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.