secure-pdf
v1.0.1
Published
Production-grade secure PDF generation with encryption, watermarking, tamper detection, expiration, forensic tracking, and QR verification.
Maintainers
Readme
secure-pdf
Node.js package for generating highly secure PDFs. Combines AES-256 encryption, granular permissions, visible and forensic watermarking, self-destruct expiration, view tracking, QR-based authenticity verification, and cryptographic tamper detection into a single fluent API.
Features
- AES-256 Encryption — password-protect PDFs with user and owner passwords
- Granular Permissions — control printing, copying, modifying, annotating per-document
- Watermarks — visible diagonal watermarks with full style control
- Self-Destruct — documents expire after a specified date
- View Tracking — embed unique IDs to trace document distribution
- Forensic Watermarks — invisible steganographic patterns for leak detection
- QR Verification — embedded QR codes linking to verification endpoints
- Tamper Detection — SHA-256 content hashing with integrity verification
- Metadata — title, author, classification, keywords, custom fields
- Tables & Images — built-in table renderer, image embedding, custom fonts
- Fluent API — chain all operations in a clean, readable style
- TypeScript Ready — complete
.d.tstype definitions included - Minimal Dependencies — only
pdfkitandqrcode
Installation
npm install secure-pdfRequires Node.js 18 or later.
Quick Start
import { SecurePDF } from 'secure-pdf';
const pdf = new SecurePDF();
pdf
.addText('Hello, Secure World!', { fontSize: 24, bold: true })
.watermark('DRAFT')
.encrypt('my-password');
await pdf.save('document.pdf');Usage Examples
Basic PDF with Encryption
import { SecurePDF } from 'secure-pdf';
const pdf = new SecurePDF();
pdf
.addText('Quarterly Report', { fontSize: 28, bold: true })
.moveDown(1)
.addText('Revenue exceeded targets across all business units.')
.addTable(
[
['Region', 'Revenue', 'Growth'],
['North America', '$42M', '+18%'],
['Europe', '$28M', '+12%'],
['APAC', '$15M', '+31%'],
],
{ headerBackground: '#1a1a2e', headerColor: '#ffffff' }
)
.watermark('CONFIDENTIAL')
.encrypt('report-2026')
.permissions({ copying: false, modifying: false })
.metadata({
title: 'Q4 Report',
author: 'Finance Team',
classification: 'CONFIDENTIAL',
});
await pdf.save('quarterly-report.pdf');All Security Features
import { SecurePDF } from 'secure-pdf';
const pdf = new SecurePDF();
await pdf
.addPage()
.addText('Confidential Report', { fontSize: 24, bold: true })
.addTable([
['Name', 'Department', 'Clearance'],
['John Doe', 'Engineering', 'Level 3'],
])
.watermark('CONFIDENTIAL', {
opacity: 0.3,
angle: 45,
fontSize: 72,
})
.permissions({
printing: 'lowResolution',
modifying: false,
copying: false,
annotating: false,
})
.encrypt('SecurePass2024!')
.metadata({
title: 'Q4 Financial Report',
author: 'Finance Team',
subject: 'Confidential',
keywords: ['finance', 'Q4', 'confidential'],
classification: 'INTERNAL USE ONLY',
})
.expire('2026-12-31')
.track('USER-12345-DOC-789')
.forensicWatermark({ userId: 'jdoe', timestamp: true })
.qrVerify('https://verify.company.com/check/')
.tamperDetect(true)
.save('confidential-report.pdf');Enterprise Document
import { SecurePDF } from 'secure-pdf';
const recipientId = 'EXEC-CFO-2026';
const pdf = new SecurePDF({
pageSize: 'Letter',
margins: { top: 60, bottom: 60, left: 55, right: 55 },
});
pdf
.addText('Annual Financial Summary', { fontSize: 32, bold: true, align: 'center' })
.moveDown(2)
.addText('CONFIDENTIAL — EXECUTIVE DISTRIBUTION ONLY', {
fontSize: 12, bold: true, color: '#c0392b', align: 'center',
})
.addPage()
.addText('Financial Summary', { fontSize: 22, bold: true })
.addTable([
['Category', 'FY2024', 'FY2025', 'Change'],
['Revenue', '$142.3M', '$168.7M', '+18.6%'],
['Net Income', '$34.8M', '$49.4M', '+41.9%'],
])
.watermark('CONFIDENTIAL', { fontSize: 60, opacity: 0.1 })
.encrypt('AcmeFY2025!')
.expire('2027-03-31')
.track(recipientId)
.forensicWatermark({ userId: recipientId, timestamp: true })
.qrVerify('https://docs.acmecorp.internal/verify/')
.tamperDetect('acme-fy2025-integrity');
const result = await pdf.save('enterprise-report.pdf');
console.log(`Hash: ${result.contentHash}`);
console.log(`Tracking: ${result.trackingId}`);
console.log(`Document ID: ${result.documentId}`);Get Buffer (No File)
const { buffer, contentHash, trackingId, documentId } = await pdf.toBuffer();
// Use for HTTP responses, S3 uploads, email attachments, etc.Database Integration
secure-pdf does not connect to databases itself — you query your database first, then pass the data into the PDF builder. The core pattern is always the same regardless of which database you use:
Query DB → Map results to string[][] → Pass to .addTable()The secure-pdf code is identical across all databases — only the query syntax changes.
PostgreSQL (pg)
import { SecurePDF } from 'secure-pdf';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// 1. Fetch rows from PostgreSQL
const { rows } = await pool.query(
'SELECT name, department, salary FROM employees ORDER BY department'
);
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...rows.map(r => [r.name, r.department, `$${r.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();MySQL (mysql2)
import { SecurePDF } from 'secure-pdf';
import mysql from 'mysql2/promise';
const pool = mysql.createPool({ host: 'localhost', user: 'root', database: 'mydb' });
// 1. Fetch rows from MySQL
const [rows] = await pool.query(
'SELECT name, department, salary FROM employees ORDER BY department'
);
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...rows.map(r => [r.name, r.department, `$${r.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();SQLite (better-sqlite3)
import { SecurePDF } from 'secure-pdf';
import Database from 'better-sqlite3';
const db = new Database('mydb.sqlite');
// 1. Fetch rows from SQLite (synchronous)
const rows = db.prepare(
'SELECT name, department, salary FROM employees ORDER BY department'
).all();
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...rows.map(r => [r.name, r.department, `$${r.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();MongoDB (mongodb)
import { SecurePDF } from 'secure-pdf';
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
const db = client.db('mydb');
// 1. Fetch documents from MongoDB
const employees = await db.collection('employees')
.find({})
.sort({ department: 1 })
.toArray();
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...employees.map(e => [e.name, e.department, `$${e.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();Note: MongoDB returns plain objects (documents), not rows. Access fields directly —
e.name,e.department. For nested fields use dot access:e.address.city. Usee._id.toString()when passing to.track().
Mongoose
import { SecurePDF } from 'secure-pdf';
import mongoose from 'mongoose';
await mongoose.connect(process.env.MONGO_URI);
const Employee = mongoose.model('Employee', new mongoose.Schema({
name: String,
department: String,
salary: Number,
}));
// 1. Fetch documents using Mongoose
const employees = await Employee.find().sort({ department: 1 }).lean();
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...employees.map(e => [e.name, e.department, `$${e.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();Tip: Use
.lean()on Mongoose queries to get plain JavaScript objects instead of Mongoose documents — this makes field access faster and more predictable.
Prisma
import { SecurePDF } from 'secure-pdf';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 1. Fetch records using Prisma
const employees = await prisma.employee.findMany({
orderBy: { department: 'asc' },
select: { name: true, department: true, salary: true },
});
// 2. Build the PDF — same for every database
const pdf = new SecurePDF();
pdf
.addText('Employee Report', { fontSize: 24, bold: true })
.moveDown(1)
.addText(`Generated: ${new Date().toLocaleDateString()}`, { fontSize: 11, color: '#888888' })
.moveDown(1)
.addTable([
['Name', 'Department', 'Salary'],
...employees.map(e => [e.name, e.department, `$${e.salary.toLocaleString()}`]),
])
.watermark('CONFIDENTIAL')
.encrypt('hr-password-2026')
.permissions({ copying: false, modifying: false })
.metadata({ title: 'Employee Report', author: 'HR System', classification: 'INTERNAL' });
const { buffer } = await pdf.toBuffer();Sending a PDF as an HTTP Response
This pattern works with any of the database clients above — just swap the query section:
import express from 'express';
import { SecurePDF } from 'secure-pdf';
const app = express();
app.get('/reports/invoices/:id', async (req, res) => {
// Swap this block for whichever DB client you use
const rows = await yourDb.getInvoiceItems(req.params.id);
if (!rows.length) return res.status(404).send('Invoice not found');
const pdf = new SecurePDF();
pdf
.addText(`Invoice #${req.params.id}`, { fontSize: 26, bold: true })
.moveDown(1)
.addTable([
['Item', 'Qty', 'Unit Price', 'Total'],
...rows.map(r => [r.description, String(r.qty), `$${r.unit_price}`, `$${r.total}`]),
])
.metadata({ title: `Invoice #${req.params.id}`, author: 'Billing System' })
.encrypt('invoice-2026');
const { buffer } = await pdf.toBuffer();
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${req.params.id}.pdf"`,
'Content-Length': buffer.length,
});
res.send(buffer);
});Per-User Document Tracking
Combine .track() and .forensicWatermark() with a DB-stored user ID to trace distributed documents back to their recipient. Works with any database — just fetch the user record first:
// PostgreSQL / MySQL / SQLite: recipient = user.rows[0] or rows[0]
// MongoDB / Mongoose: recipient = await User.findById(userId).lean()
// Prisma: recipient = await prisma.user.findUnique({ where: { id: userId } })
const pdf = new SecurePDF();
pdf
.addText(`Welcome, ${recipient.name}`, { fontSize: 20, bold: true })
.addText('This document is personalized and tracked.', { fontSize: 12 })
.track(`USER-${recipient.id ?? recipient._id.toString()}`)
.forensicWatermark({ userId: recipient.email, timestamp: true })
.encrypt('access-password')
.tamperDetect(true);
const { buffer, trackingId } = await pdf.toBuffer();
// Optionally log the tracking ID back to the database
// e.g. await db.query('INSERT INTO document_log ...') or
// await prisma.documentLog.create({ data: { userId, trackingId } })Supported Database Clients
| Database | Client |
|------------|-------------------------------------|
| PostgreSQL | pg, postgres |
| MySQL | mysql2 |
| SQLite | better-sqlite3 |
| MongoDB | mongodb, mongoose |
| Any ORM | prisma, drizzle, sequelize |
API Reference
Constructor
new SecurePDF(options?)| Option | Type | Default | Description |
|---|---|---|---|
| documentId | string | auto-generated | Custom document identifier |
| pageSize | string | 'A4' | Page size ('A4', 'Letter', etc.) |
| margins | object | { top: 50, bottom: 50, left: 50, right: 50 } | Page margins in points |
Instance Properties
| Property | Type | Description |
|---|---|---|
| documentId | string | The unique identifier for this document instance |
Content Methods
All content methods return this for chaining.
| Method | Parameters | Description |
|---|---|---|
| addText(text, options?) | text: string, options: TextOptions | Add a text block |
| addPage(options?) | options: object | Insert a page break |
| addImage(source, options?) | source: string \| Buffer, options: ImageOptions | Embed an image |
| addTable(rows, options?) | rows: string[][], options: TableOptions | Render a table (first row = header) |
| setFont(name, path) | name: string, path: string | Register and use a custom font |
| moveDown(lines?) | lines: number | Add vertical spacing |
TextOptions
| Property | Type | Description |
|---|---|---|
| fontSize | number | Font size in points |
| bold | boolean | Use bold weight |
| italic | boolean | Use italic style |
| underline | boolean | Underline text |
| color | string | Fill color (hex) |
| align | 'left' \| 'center' \| 'right' \| 'justify' | Text alignment |
| font | string | Font name override |
| link | string | Hyperlink URL |
| width | number | Maximum width for text wrapping (points) |
| lineBreak | boolean | Enable or disable automatic line breaks |
| indent | number | Left indent in points |
ImageOptions
| Property | Type | Description |
|---|---|---|
| x | number | Absolute x position |
| y | number | Absolute y position |
| width | number | Image width in points |
| height | number | Image height in points |
| fit | [number, number] | Fit image within [width, height] bounds |
| align | 'left' \| 'center' \| 'right' | Horizontal alignment |
| valign | 'top' \| 'center' \| 'bottom' | Vertical alignment |
TableOptions
| Property | Type | Default | Description |
|---|---|---|---|
| fontSize | number | 10 | Font size for all cells |
| columnWidth | number | auto (equal split) | Fixed width per column in points |
| headerBackground | string | '#eeeeee' | Header row background color (hex) |
| headerColor | string | '#333333' | Header row text color (hex) |
| cellColor | string | '#000000' | Body cell text color (hex) |
| borderColor | string | '#cccccc' | Cell border color (hex) |
Security Methods
| Method | Parameters | Description |
|---|---|---|
| encrypt(password) | password: string | AES-256 encryption with user password |
| permissions(config) | config: PermissionsConfig | Set document permissions |
| metadata(config) | config: MetadataConfig | Set document metadata |
| watermark(text, options?) | text: string, options: WatermarkOptions | Visible watermark on every page |
| expire(date) | date: string \| Date | Self-destruct after date |
| track(id) | id: string | Embed tracking identifier |
| forensicWatermark(config?) | config: ForensicConfig | Hidden forensic watermark |
| qrVerify(url, options?) | url: string, options: QROptions | Embed verification QR code |
| tamperDetect(enableOrSalt?) | boolean \| string | Enable content integrity hashing |
PermissionsConfig
| Property | Type | Values |
|---|---|---|
| printing | string \| boolean | 'highResolution', 'lowResolution', true, false |
| modifying | boolean | Allow content modification |
| copying | boolean | Allow content copying |
| annotating | boolean | Allow annotations |
| fillingForms | boolean | Allow form filling |
| contentAccessibility | boolean | Allow accessibility extraction |
| documentAssembly | boolean | Allow page assembly |
MetadataConfig
| Property | Type | Description |
|---|---|---|
| title | string | Document title |
| author | string | Document author |
| subject | string | Document subject |
| keywords | string \| string[] | Search keywords |
| creator | string | Creating application name |
| producer | string | PDF producer name |
| company | string | Organization name |
| classification | string | Security classification label |
WatermarkOptions
| Property | Type | Default | Description |
|---|---|---|---|
| fontSize | number | 60 | Watermark font size |
| color | string | '#cccccc' | Watermark color |
| opacity | number | 0.15 | Opacity (0–1) |
| angle | number | -45 | Rotation in degrees |
| font | string | 'Helvetica' | Font name |
QROptions
| Property | Type | Description |
|---|---|---|
| x | number | Absolute x position of the QR code |
| y | number | Absolute y position of the QR code |
| size | number | QR code size in points |
ForensicConfig
| Property | Type | Description |
|---|---|---|
| userId | string | User identifier to embed in forensic pattern |
| timestamp | boolean | Include generation timestamp in pattern |
Output Methods
| Method | Returns | Description |
|---|---|---|
| save(filePath) | Promise<SaveResult> | Write PDF to disk |
| toBuffer() | Promise<BufferResult> | Get PDF as in-memory Buffer |
SaveResult
{
path: string; // Absolute file path
size: number; // File size in bytes
documentId: string; // Document identifier
contentHash: string; // SHA-256 hash (if tamperDetect enabled)
trackingId: string; // Tracking ID (if track enabled)
}BufferResult
{
buffer: Buffer; // Raw PDF bytes
contentHash: string; // SHA-256 hash (if tamperDetect enabled)
trackingId: string; // Tracking ID (if track enabled)
documentId: string; // Document identifier
}Security Features
Encryption
Uses pdfkit's built-in AES-256 encryption. A user password is required to open the document. An owner password is auto-generated with cryptographically secure random bytes and controls permission overrides.
Self-Destruct / Expiration
Two-layer approach:
- PDF JavaScript — executes on open in Adobe Acrobat and compatible readers; closes the document if expired
- Visible notice — red warning at the bottom of the last page for readers that don't execute JavaScript
PDF JavaScript is not supported in all viewers. The visible notice provides fallback coverage.
Tamper Detection
A SHA-256 hash is computed over all text content and embedded in the PDF metadata (ContentHash key). Verify integrity programmatically:
import { verifyContentHash } from 'secure-pdf';
const result = verifyContentHash(
savedResult.contentHash,
['your', 'content', 'parts'],
'your-salt'
);
console.log(result.valid); // true or false
console.log(result.message); // "Document integrity verified." or "Document has been tampered with."View Tracking
Each tracked document gets a unique ID embedded in:
- PDF metadata (
TrackingIDinfo key) - Hidden near-invisible watermark grid across every page
- Invisible selectable text at the page bottom
This enables forensic identification of the source copy if a document is leaked.
Forensic Watermarks
When .forensicWatermark() is called, a grid of nearly invisible text containing the user ID and timestamp is scattered across every page using a staggered pattern. Corner markers provide additional forensic anchors. The pattern is designed to survive most image processing operations while remaining invisible to the naked eye.
QR Verification
A QR code is embedded on the last page that encodes the verification URL with the document's content hash as a query parameter. Recipients can scan to verify authenticity against a server-side verification endpoint.
Error Handling
secure-pdf exports structured error classes for precise error handling:
import {
SecurePDFError,
ValidationError,
EncryptionError,
BuildError,
FeatureError,
} from 'secure-pdf';
try {
await pdf.save('output.pdf');
} catch (err) {
if (err instanceof ValidationError) {
console.error(`Invalid input on field "${err.field}": ${err.message}`);
} else if (err instanceof BuildError) {
console.error(`PDF generation failed: ${err.message}`);
} else if (err instanceof EncryptionError) {
console.error(`Encryption error: ${err.message}`);
} else {
throw err;
}
}Error Classes
| Class | Code | Description |
|---|---|---|
| SecurePDFError | base | Base class for all package errors |
| ValidationError | — | Invalid input; includes field property |
| EncryptionError | — | Encryption configuration failure |
| BuildError | — | PDF generation or rendering failure |
| FeatureError | — | Feature-specific failure; includes feature property |
Exported Utilities
In addition to the main class, the package exports low-level utilities for advanced use:
Crypto Utilities
import { sha256, sha256Multi, hmacSha256, generateDocumentId } from 'secure-pdf';
sha256('some data'); // → hex string
sha256Multi(['part1', 'part2'], 'salt'); // → hex string
hmacSha256('secret-key', 'data'); // → hex string
generateDocumentId(); // → unique document ID stringFeature Functions
import {
verifyContentHash,
applyEncryption,
buildPermissionFlags,
renderWatermark,
applyMetadata,
applyExpiration,
embedTracking,
renderForensicWatermark,
renderQRVerification,
computeContentHash,
embedContentHash,
} from 'secure-pdf';These are exported for advanced integrations or custom PDF builders using PDFBuilder directly.
TypeScript
The package includes complete type definitions. No additional @types package is needed.
import { SecurePDF, type SaveResult, type PermissionsConfig, type BufferResult } from 'secure-pdf';
const perms: PermissionsConfig = {
printing: 'lowResolution',
copying: false,
};
const pdf = new SecurePDF();
pdf.addText('Typed document').permissions(perms);
const result: SaveResult = await pdf.save('typed.pdf');
const { buffer }: BufferResult = await pdf.toBuffer();Testing
npm test # Run all tests
npm run test:coverage # Run with coverage reportTests are structured as:
tests/unit/— isolated tests for each feature moduletests/integration/— end-to-end workflow tests
Examples
npm run example:basic # Simple encrypted PDF
npm run example:advanced # All security features
npm run example:enterprise # Corporate document workflowOutput is written to examples/output/.
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Write tests for new functionality
- Ensure
npm testpasses - Commit with clear messages using conventional commits
- Open a Pull Request
Development Setup
git clone https://github.com/CodeByMoriarty/secure-pdf.git
cd secure-pdf
npm install
npm testLicense
Support
Open an issue at the GitHub repository for bug reports, feature requests, or questions.
You can also reach the maintainer by email: [email protected]
For security vulnerabilities, please see SECURITY.md.
