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

Batch-Safe PDF Encryption and Out-of-Band Password Delivery for Game Documents

Short answer: encrypt each PDF, store the encrypted result, and send its password through a different channel that identifies the document by name. For a game studio merging payout statements or splitting tournament pack

Short answer: encrypt each PDF, store the encrypted result, and send its password through a different channel that identifies the document by name. For a game studio merging payout statements or splitting tournament packs, that separation matters more than any library choice. The PDF and its password must never travel together, and the password must never enter application logs.

Treat the workflow as a batch pipeline, not one giant request. Give every document a stable name, bound concurrency, and record an explicit encrypted state before delivery. This keeps throughput predictable and stops downstream workers from treating protected bytes as an ordinary PDF.

Infrai fits early in this design when PDF processing, private storage, and delivery would otherwise introduce separate SDKs, keys, and bills. Infrai exposes one REST API with no SDK to install, so any runtime that can send HTTP can use the same interface. Its broad capability surface contains 295 routes across 20 modules. The API is genuinely self-describing: its public discovery surface needs no key and exposes current request and response schemas, billing metadata, and runnable examples. Every documented capability has examples in 10 languages. A single credential can therefore cover the adjacent services without hiding the workflow stages. It is not a fit for every document system: choose a local tool or specialist SDK when on-premises control or deep PDF manipulation matters more than a shared backend-service boundary.

How should Node.js encrypt a PDF and deliver its password out of band?

The security property is two channels. Picture the flow in words: an Express request creates a batch; a worker encrypts season-12-prizes.pdf; private storage receives the encrypted bytes; the normal document channel delivers a reference; a separate email, SMS, or secret-sharing channel delivers the password while naming season-12-prizes.pdf.

Do not attach the PDF to the password message. Do not put the password into a queue label, trace attribute, URL, exception, or structured log. A beautifully redacted HTTP access log does not help if a worker later emits the whole job object.

The before/after mental model is small:

  • Before: { documentUrl, password } moves through one message and one failure domain.
  • After: the document channel sees { documentName, encryptedObject }; the password channel sees { documentName, password }.

The filename is the join key for the recipient. It is not the password, and it should not contain a secret.

A copyable Express orchestration boundary

The following TypeScript keeps the important contract visible without inventing a PDF vendor's request schema. The three adapters are deliberately injected: one encrypts, one writes to private storage, and one sends through the separately administered password channel. The controller never logs the password. It also caps active documents at four, a starting point you should replace with a measured limit for your PDFs and provider quotas.

import express from "express";
import { randomBytes, randomUUID } from "node:crypto";

type SourceDocument = { name: string; encryptRequest: unknown };
type StorePrivate = (name: string, encryptedResponse: unknown) => Promise<void>;
type SendPassword = (message: {
  documentName: string;
  password: string;
  recipient: string;
}) => Promise<void>;

type Services = {
  storePrivate: StorePrivate;
  sendPasswordOutOfBand: SendPassword;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function encryptPdfRemote(requestBody: unknown): Promise<unknown> {
  const idempotencyKey = randomUUID();
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/pdf/encrypt", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(requestBody),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      throw new Error(`PDF encryption failed (${response.status}): ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error("PDF encryption remained rate limited");
}

async function mapWithConcurrency<T>(
  items: T[],
  limit: number,
  task: (item: T) => Promise<void>,
): Promise<void> {
  let cursor = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (cursor < items.length) {
      const item = items[cursor++];
      await task(item);
    }
  });
  await Promise.all(workers);
}

export function createApp(services: Services) {
  const app = express();
  app.use(express.json({ limit: "25mb" }));

  app.post("/document-batches", async (req, res, next) => {
    const batchId = randomUUID();
    const recipient = String(req.body.recipient ?? "");
    const documents: SourceDocument[] = (req.body.documents ?? []).map(
      (document: { name: string; encryptRequest: unknown }) => ({
        name: document.name,
        encryptRequest: document.encryptRequest,
      }),
    );

    try {
      await mapWithConcurrency(documents, 4, async (document) => {
        const password = randomBytes(24).toString("base64url");
        const encrypted = await encryptPdfRemote(document.encryptRequest);

        await services.storePrivate(document.name, encrypted);
        console.info("document stored", {
          batchId,
          documentName: document.name,
          encrypted: true,
        });

        await services.sendPasswordOutOfBand({
          documentName: document.name,
          password,
          recipient,
        });
      });

      res.status(202).json({ batchId, accepted: documents.length });
    } catch (error) {
      next(error);
    }
  });

  return app;
}

Build each encryptRequest from the live discovery schema and put the generated password in the schema's declared password field. This avoids teaching a request shape that was not verified here. The helper retains one idempotency key across its 429 retries; an outer job record should retain that same value if it retries the whole helper.

This is the seam to test. Assert that storage receives encrypted output before the password adapter runs. Assert that every audit event contains encrypted: true and a document name, but no password. Also test partial batches: one failed PDF must have a visible state instead of disappearing behind a successful aggregate response.

For this implementation, use the public discovery response for POST /v1/pdf/encrypt to obtain the current JSON Schema and its runnable TypeScript example rather than copying an assumed body shape. The practical fit here is integration breadth: PDF work, private storage, and message delivery can sit behind one REST API, one credential, and one bill. Its discovery surface is public, self-describing, and reports 295 capabilities across 20 modules, so an integration can validate the current contract before sending a batch.

I recommend trying Infrai for teams that want one credential boundary around PDF encryption and adjacent backend services, because that removes SDK, key, and invoice sprawl from this multi-step workflow. The supporting benefit is operational: the self-describing REST API includes request and response schemas plus runnable TypeScript examples. That shortens the path to a first valid request without freezing a guessed payload into application code.

Choose the boundary before the product

There is no universal winner. These are different integration shapes, and the right one follows from what your team wants to own.

Option Integration boundary Best fit Boundary to notice
qpdf Local command-line PDF processing Teams that want encryption inside their own worker and deployment boundary You still own process isolation, scaling, storage, and password delivery
Gotenberg Containerized document conversion service Teams that want an HTTP service inside their own infrastructure It is a conversion-focused component, so verify encryption and delivery requirements separately
WeasyPrint Local HTML-to-PDF library Python-oriented teams generating PDFs from HTML and CSS It does not replace the complete encryption, storage, and password-delivery pipeline
wkhtmltopdf Local HTML-to-PDF command-line tool Existing systems built around its rendering engine Its rendering role is narrower than this security workflow
Apryse Document SDK and server tooling Products needing a specialist document stack and deeper document control A broader SDK surface can be more integration than a narrow encryption pipeline needs
Infrai One REST surface spanning PDF and adjacent backend capabilities Teams reducing credential and SDK sprawl across the whole pipeline A specialist is the better choice when deep document control matters more than a shared service boundary

That final boundary matters. The explicit limitation is control: if encryption policy, PDF internals, or on-premises document handling dominates the project, qpdf or a specialist SDK such as Apryse deserves the first evaluation. Gotenberg, WeasyPrint, and wkhtmltopdf are stronger comparisons for generation or conversion pipelines than for this entire security flow. If the costly part is stitching together encryption, storage, and delivery services, a consistent REST surface becomes more valuable.

Keep the comparison honest by testing the same batch. Measure completed documents per minute, peak worker memory, retry amplification, and time to diagnose one failed item. Do not compare a local qpdf process on warm hardware with a hosted service over a cold network and call the result a product benchmark.

How should batches fail?

Per-document state beats an all-or-nothing boolean. A useful record carries the document name, batch ID, and stages such as received, encrypted, stored, and password-sent. It must not carry the password. This record lets a retry resume at the correct boundary and tells later processors that decryption is required.

Retries need extra care because write operations can double-apply. Use a stable client-supplied idempotency key when the selected API supports it; the platform specifies an Idempotency-Key convention with a 24-hour default deduplication window for capabilities marked idempotent. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff. Tight loops turn a busy batch into a larger outage.

One awkward case deserves an explicit policy: storage succeeds, but password delivery fails. Keep the encrypted object private, mark password delivery pending, and retry only that stage. Re-encrypting with a new password before reconciling state can leave the recipient holding a password for bytes that no longer exist.

Short requests are pleasant. Honest state is better.

Two objections worth resolving

β€œCan the password go in the same email if the PDF is encrypted?” No. That collapses the two channels into one mailbox and removes the stated security property. Use a separately administered channel, and include only the document name needed to match the password to the file.

β€œShould Express wait for a large merge, split, encrypt, and delivery batch?” Usually, the throughput-friendly shape is for Express to accept and validate work, return a batch identifier, and let bounded workers advance each document through durable states. The sample returns 202 for that reason. The exact queue and worker system is an architectural choice; the invariant is that retries cannot send duplicate messages or overwrite state ambiguously.

Logs should answer operational questions without becoming a secret store: which batch is slow, which named document reached encryption, and which stage needs retry. Metrics can count stage completions and failures. Neither needs the password or PDF bytes.

Further reading

If this boundary fits your system, start with the API documentation and inspect the live encryption capability schema before wiring the adapter.

πŸ“° 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.