PDF Security Best Practices: Protect Your Documents and Your Users š
May 9, 2025
Your users trust you with their data. Invoices. Financial reports. Medical records. Personal information.
Then you generate a PDF and accidentally:
- Embed API keys in the PDF metadata
- Expose customer data through public S3 URLs
- Leak PII in debug logs
- Store unencrypted PDFs that contain credit card numbers
One security slip-up, and you're not just dealing with angry users. You're dealing with GDPR fines, legal liability, and a PR nightmare.
This guide shows you how to secure your PDF generation pipeline from end to end.
The PDF Security Threat Model
What Can Go Wrong?
1. Data Leakage
- Sensitive data embedded in PDFs
- PDFs stored in publicly accessible locations
- Metadata containing system information
2. Unauthorized Access
- Anyone with the URL can download the PDF
- No authentication or authorization checks
- Permanent, guessable URLs
3. API Security
- API keys hardcoded in source code
- Keys exposed in version control
- Unrestricted API access
4. Compliance Violations
- GDPR violations (data retention, right to deletion)
- HIPAA violations (PHI in unencrypted PDFs)
- PCI DSS violations (payment card data)
Let's fix all of them.
Layer 1: API Key Security
ā Never Do This
// NEVER hardcode API keys
const API_KEY = 'rg_live_abc123xyz789';
// NEVER commit keys to git
const config = {
reportgen_key: 'rg_live_abc123xyz789'
};
// NEVER expose keys in client-side code
fetch('https://reportgen.io/api/v1/generate-pdf-sync', {
headers: { 'X-API-Key': 'rg_live_abc123xyz789' } // Visible in browser!
});ā Do This Instead
Use Environment Variables
// .env file (NEVER commit this to git)
REPORTGEN_API_KEY=rg_live_abc123xyz789
// .gitignore
.env
.env.local
.env.*.local// In your code
const apiKey = process.env.REPORTGEN_API_KEY;
if (!apiKey) {
throw new Error('REPORTGEN_API_KEY environment variable is not set');
}Use Secret Management Services
// AWS Secrets Manager
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
async function getApiKey() {
const client = new SecretsManager({ region: 'us-east-1' });
const response = await client.getSecretValue({
SecretId: 'reportgen-api-key'
});
return JSON.parse(response.SecretString).api_key;
}
const apiKey = await getApiKey();Rotate Keys Regularly
// Keep track of active and old keys for rotation
const keys = {
current: process.env.REPORTGEN_API_KEY_CURRENT,
previous: process.env.REPORTGEN_API_KEY_PREVIOUS
};
async function generatePDF(data) {
try {
return await callAPI(keys.current, data);
} catch (err) {
if (err.status === 401) {
// Try previous key during rotation
return await callAPI(keys.previous, data);
}
throw err;
}
}Layer 2: PDF Storage Security
The Problem: Public URLs
// ā Anyone with this URL can access the PDF
const pdfUrl = 'https://mybucket.s3.amazonaws.com/invoices/invoice-123.pdf';
// If someone guesses invoice-124.pdf, invoice-125.pdf...
// they can access other users' documentsSolution 1: Signed URLs with Expiration
import AWS from 'aws-sdk';
const s3 = new AWS.S3();
async function generateSignedUrl(fileKey, expiresIn = 3600) {
return s3.getSignedUrlPromise('getObject', {
Bucket: 'your-pdf-bucket',
Key: fileKey,
Expires: expiresIn // URL expires in 1 hour
});
}
// Generate PDF
const pdf = await generatePDF(data);
// Upload to S3
const fileKey = `invoices/${uuid()}.pdf`;
await s3.putObject({
Bucket: 'your-pdf-bucket',
Key: fileKey,
Body: pdf,
ContentType: 'application/pdf',
ServerSideEncryption: 'AES256' // ā
Encrypt at rest
}).promise();
// Create signed URL
const signedUrl = await generateSignedUrl(fileKey, 3600);
// Return to user
res.json({
pdf_url: signedUrl,
expires_at: new Date(Date.now() + 3600 * 1000)
});Solution 2: Authentication-Required Downloads
// ā Public download endpoint
app.get('/pdfs/:filename', (req, res) => {
const file = s3.getObject({
Bucket: 'pdfs',
Key: req.params.filename
}).createReadStream();
file.pipe(res);
});
// ā
Authenticated download with authorization check
app.get('/pdfs/:pdfId', authenticateUser, async (req, res) => {
const { pdfId } = req.params;
const userId = req.user.id;
// Check if user owns this PDF
const pdf = await db.pdfs.findOne({ id: pdfId });
if (!pdf) {
return res.status(404).json({ error: 'PDF not found' });
}
if (pdf.user_id !== userId && !req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
// Stream from S3
const file = s3.getObject({
Bucket: process.env.S3_BUCKET,
Key: pdf.s3_key
}).createReadStream();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${pdf.filename}"`);
file.pipe(res);
});Solution 3: Use Non-Guessable Identifiers
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
// ā Sequential, guessable IDs
const filename = `invoice-${invoiceId}.pdf`; // invoice-1.pdf, invoice-2.pdf...
// ā
Non-guessable UUIDs
const filename = `invoice-${uuidv4()}.pdf`; // invoice-a7b3c4d5-e6f7-8901-2345-6789abcdef01.pdf
// ā
Cryptographically random tokens
const token = crypto.randomBytes(32).toString('hex');
const filename = `invoice-${token}.pdf`;Layer 3: Data Sanitization
Remove Sensitive Data from PDFs
// ā Exposing full credit card numbers
const html = `
<p>Card: {{credit_card_number}}</p>
`;
const data = {
credit_card_number: '4532 1234 5678 9010'
};
// ā
Mask sensitive data
function maskCreditCard(cardNumber) {
return cardNumber.replace(/\d(?=\d{4})/g, '*');
}
const data = {
credit_card_number: maskCreditCard('4532123456789010') // '************9010'
};// ā Including full SSN
const html = `<p>SSN: {{ssn}}</p>`;
const data = { ssn: '123-45-6789' };
// ā
Redact or mask
function maskSSN(ssn) {
return ssn.replace(/\d(?=\d{4})/g, '*'); // '***-**-6789'
}
const data = { ssn: maskSSN('123-45-6789') };Sanitize User Input
import DOMPurify from 'isomorphic-dompurify';
// ā Unsanitized user input (XSS risk)
const html = `<h1>{{user_provided_title}}</h1>`;
const data = {
user_provided_title: '<script>alert("XSS")</script>'
};
// ā
Sanitize user input
function sanitizeInput(input) {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: [], // Strip all HTML tags
KEEP_CONTENT: true
});
}
const data = {
user_provided_title: sanitizeInput(userInput)
};Layer 4: Metadata Security
The Hidden Data Problem
PDFs contain metadata that can leak information:
// ā PDF metadata might include:
// - Creator: "John's Macbook Pro"
// - Producer: "reportgen.io v1.2.3"
// - Creation date, modification date
// - File paths from your systemStrip Sensitive Metadata
// When generating PDFs, control metadata
const payload = {
html_template: html,
data: data,
engine: 'handlebars',
pdf_options: {
metadata: {
title: 'Invoice',
author: 'Your Company Name',
subject: 'Invoice Document',
// Don't include system info or user details
}
}
};Layer 5: Encryption
Encrypt PDFs at Rest
// S3 Server-Side Encryption
await s3.putObject({
Bucket: 'pdfs',
Key: fileKey,
Body: pdfBuffer,
ServerSideEncryption: 'AES256' // ā
Encrypt at rest
}).promise();
// Or use KMS for more control
await s3.putObject({
Bucket: 'pdfs',
Key: fileKey,
Body: pdfBuffer,
ServerSideEncryption: 'aws:kms',
SSEKMSKeyId: 'your-kms-key-id'
}).promise();Encrypt PDFs in Transit
// ā
Always use HTTPS
const response = await fetch('https://reportgen.io/api/v1/generate-pdf-sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify(payload)
});
// ā Never use HTTP for PDF generation or download
// http://reportgen.io/... // INSECURE!Password-Protected PDFs
// Generate password-protected PDF
const payload = {
html_template: html,
data: data,
engine: 'handlebars',
pdf_options: {
password: generateSecurePassword(), // User-specific password
permissions: {
printing: 'highResolution',
modifying: false,
copying: false,
annotating: false
}
}
};
function generateSecurePassword() {
return crypto.randomBytes(16).toString('hex');
}
// Send password to user via separate secure channel
await sendPasswordEmail(user.email, password);Layer 6: Compliance and Data Retention
GDPR Compliance
// Implement data retention policy
async function enforceRetentionPolicy() {
const retentionDays = 365; // 1 year
const expiredPDFs = await db.pdfs.find({
created_at: {
$lt: new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
}
});
for (const pdf of expiredPDFs) {
// Delete from S3
await s3.deleteObject({
Bucket: process.env.S3_BUCKET,
Key: pdf.s3_key
}).promise();
// Delete from database
await db.pdfs.delete({ id: pdf.id });
console.log(`Deleted expired PDF: ${pdf.id}`);
}
}
// Run daily
setInterval(enforceRetentionPolicy, 24 * 60 * 60 * 1000);Right to Deletion (GDPR Article 17)
// User requests data deletion
app.delete('/api/users/:userId/data', authenticateUser, async (req, res) => {
const { userId } = req.params;
if (req.user.id !== userId && !req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
// Find all PDFs for this user
const userPDFs = await db.pdfs.find({ user_id: userId });
// Delete from S3
for (const pdf of userPDFs) {
await s3.deleteObject({
Bucket: process.env.S3_BUCKET,
Key: pdf.s3_key
}).promise();
}
// Delete from database
await db.pdfs.deleteMany({ user_id: userId });
// Delete user account
await db.users.delete({ id: userId });
res.json({ message: 'All user data deleted' });
});HIPAA Compliance (Healthcare)
// For HIPAA-compliant PDF generation
const hipaaSecureConfig = {
// Encrypt at rest
storage: {
encryption: 'AES256',
kmsKeyId: process.env.HIPAA_KMS_KEY
},
// Access logging
logging: {
enabled: true,
destination: 'hipaa-audit-logs',
includeMetadata: true
},
// Access control
accessControl: {
requireAuthentication: true,
requireAuthorization: true,
mfaRequired: true
},
// Retention
retention: {
years: 6, // HIPAA requires 6 years minimum
autoDeleteAfter: false // Manual review required
}
};
async function generateHIPAACompliantPDF(patientData) {
// Audit log
await auditLog.record({
action: 'GENERATE_PDF',
user_id: req.user.id,
patient_id: patientData.id,
timestamp: new Date(),
ip_address: req.ip
});
// Generate PDF
const pdf = await generatePDF(patientData);
// Upload with encryption
await s3.putObject({
Bucket: process.env.HIPAA_BUCKET,
Key: `patient-records/${uuidv4()}.pdf`,
Body: pdf,
ServerSideEncryption: 'aws:kms',
SSEKMSKeyId: hipaaSecureConfig.storage.kmsKeyId,
Metadata: {
'patient-id': patientData.id,
'generated-by': req.user.id,
'generated-at': new Date().toISOString()
}
}).promise();
}Layer 7: Logging and Monitoring (Without Leaking Data)
ā Dangerous Logging
// NEVER log sensitive data
console.log('Generating PDF for user:', userData);
// Logs might include: SSN, credit card, passwords, etc.
console.log('API Key:', process.env.REPORTGEN_API_KEY);
// Exposed in log files!
console.log('PDF data:', pdfData);
// Might contain PII, PHI, or financial dataā Safe Logging
// Redact sensitive fields
function redactSensitive(obj) {
const redacted = { ...obj };
const sensitiveFields = [
'ssn', 'credit_card', 'password', 'api_key',
'secret', 'token', 'auth', 'cvv'
];
for (const field of sensitiveFields) {
if (redacted[field]) {
redacted[field] = '[REDACTED]';
}
}
return redacted;
}
// Log safely
console.log('Generating PDF:', redactSensitive({
user_id: user.id,
report_type: 'invoice',
ssn: '123-45-6789' // Will be redacted
}));
// Output: { user_id: '123', report_type: 'invoice', ssn: '[REDACTED]' }Audit Trail
// Maintain secure audit logs
async function auditPDFGeneration(userId, pdfId, action) {
await db.auditLogs.insert({
timestamp: new Date(),
user_id: userId,
pdf_id: pdfId,
action: action,
ip_address: req.ip,
user_agent: req.headers['user-agent']
});
}
// Track all PDF operations
await auditPDFGeneration(user.id, pdf.id, 'GENERATE');
await auditPDFGeneration(user.id, pdf.id, 'DOWNLOAD');
await auditPDFGeneration(user.id, pdf.id, 'DELETE');Security Checklist
Before you go to production, verify:
API Security
- API keys stored in environment variables or secret manager
- Keys never committed to version control
- Key rotation strategy in place
- Rate limiting on API endpoints
Storage Security
- PDFs stored with encryption at rest (AES-256 or KMS)
- Signed URLs with expiration
- Non-guessable file identifiers (UUIDs)
- Private S3 buckets (not public)
Access Control
- Authentication required for PDF downloads
- Authorization checks (user can only access their own PDFs)
- Role-based access control if needed
Data Protection
- Sensitive data masked/redacted in PDFs
- User input sanitized
- No PII/PHI in logs
- HTTPS for all connections
Compliance
- Data retention policy implemented
- User data deletion workflow
- Audit logging enabled
- GDPR/HIPAA requirements met (if applicable)
Final Thoughts
Security isn't optional. One breach can destroy your business.
The good news? Most security best practices are simple to implement if you build them in from day one.
Don't wait for a security incident to take this seriously. Secure your PDF generation pipeline now.
Need secure, compliant PDF generation? Try reportgen.io - built with security best practices from the ground up.