reportgen
Back to all posts

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' documents

Solution 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 system

Strip 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.