Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

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:

  1. BT ... ET tells the PDF viewer to place text glyphs on the canvas at coordinate (72, 712).
  2. re and f simply 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 the Delete key, 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:
  3. Render the affected page to an offscreen bitmap at high DPI.
  4. Paint opaque pixels directly over the target areas, permanently overwriting the raw RGBA buffer.
  5. 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:
  6. Zero Server Footprint: Confidential legal documents should never leave the user's device.
  7. 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!

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.