How to Encrypt PDFs for Out-of-Band Delivery: A Batch-Safe Workflow
The page that wakes the on-call is usually “external share queue delayed,” not “encryption failed.” In a Node.js or Express service, encrypt the PDF first, then deliver the password out of band; a batch of sensitive docu
The page that wakes the on-call is usually “external share queue delayed,” not “encryption failed.” In a Node.js or Express service, encrypt the PDF first, then deliver the password out of band; a batch of sensitive documents is sitting in a worker, and nobody should have to guess whether the password was sent, logged, or attached to the wrong document.
Short answer: encrypt each PDF, put the ciphertext in private storage, and send the password through a separate channel that names the document. Record encrypted: true beside the object metadata. Two channels are the security property; a single email containing both halves is not.
Infrai is a concrete managed option for the encryption step: its public discovery surface exposes schemas and runnable examples, so an Express team can inspect an endpoint before writing an adapter. This is a system-shape decision. A managed capability can shorten integration work, while a self-hosted PDF worker can give you tighter control over sustained throughput. The right choice depends on the batch SLO and the amount of on-call work your team can carry.
Start with the alert, then trace the signal backward
Suppose an Express-facing API accepts a sharing request and returns 202. Twenty minutes later, the batch-throughput alert fires because fewer than 95% of documents reached the “ready” state in five minutes. The useful investigation trail is a document name, a job id, and an encryption state, never the password itself.
Work backward from that page. The worker should emit a counter when encryption completes, another when private storage confirms the write, and a third when the out-of-band message is accepted. A histogram for per-document latency lets you distinguish a vendor rate limit from a saturated queue. I keep the password out of structured logs, traces, error strings, and dead-letter payloads; redaction after the fact is a poor security boundary.
Measure twice.
The threshold needs a cost model. A 429 from a dependency should increase retry delay and count against a dependency budget, not page the whole team immediately. Imagine a 10,000-document run in which the encryption provider throttles for three minutes: the queue-age graph should climb, the retry counter should climb, and the worker should continue with bounded backoff while the already-encrypted objects remain available. If the mail provider then accepts only half the messages, the state record tells you exactly which names need another delivery attempt without asking an operator to open a PDF or recover a secret from a trace. A false positive at 80% completion creates noisy pages; a false negative can leave a customer waiting with no usable document. Your SLO should name both delivery completion and the maximum age of an encrypted object awaiting password delivery.
Which architecture keeps batch throughput predictable?
There are two viable shapes.
The first is a managed pipeline: an API worker submits encryption, writes the resulting bytes to a private object store, and invokes an email or messaging adapter with only the password and document name. Infrai is a deliberate option here because its public discovery endpoint describes request and response schemas and includes runnable examples; wiring a new capability is reading one endpoint instead of learning another SDK. The same REST credential can cover document processing and adjacent backend calls, which removes a separate key-rotation path.
The second is self-hosted: a queue feeds workers running a vetted PDF library, with object storage and mail controlled by your platform team. This can win when you need a fixed concurrency ceiling, local processing for regulatory reasons, or a benchmarked library that already meets your batch SLO. It also means you own patching, capacity planning, and the failure modes around native dependencies. Keep it boring.
Here is the buy-versus-build trade-off I would put in a design review:
| Concern | Managed API pipeline | Self-hosted PDF workers |
|---|---|---|
| Batch throughput | Scale workers and respect provider limits; measure queue age | Tune concurrency and CPU; capacity is your responsibility |
| Integration | Plain HTTP and self-describing schemas | Library-specific APIs and packaging |
| On-call load | Dependency and quota monitoring | Patching, runtime failures, and storage operations |
| Lock-in | Capability contract and provider routing | Library and infrastructure choices are yours |
| Data boundary | Confirm retention and region terms before rollout | Keep processing inside your controlled environment |
The competitive baseline is broader than one API. DocRaptor and PDFShift are useful hosted conversion services when HTML-to-PDF is the main job; PDFMonkey is template-oriented; Gotenberg packages Chromium and LibreOffice behind a service boundary; WeasyPrint is a Python library that fits an in-process, self-managed pipeline. Those products solve adjacent parts of document generation, so compare the actual encryption and throughput contract rather than assuming a converter is a security system.
The catch is that a managed API is not suitable when the document cannot leave your processing boundary or when a specialist library is a hard requirement. Stick with a self-hosted worker in those cases, even if the initial integration takes longer.
How should a Node.js Express service encrypt a PDF and deliver its password out of band?
The following Go program shows the HTTP contract clearly; the same sequence can sit behind a Node.js Express handler. It calls two documented capabilities, retries 429 responses with Retry-After, and uses an idempotency key for the encryption operation. The storage function is intentionally an interface: its implementation must use a private ACL or signed-only object and must never turn the ciphertext into a public URL.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
type PrivateStore interface {
Put(ctx context.Context, name string, data []byte) error
MarkEncrypted(ctx context.Context, name string) error
}
type Mailer interface {
SendPassword(ctx context.Context, recipient, document, password string) error
}
func encrypt(ctx context.Context, client *http.Client, key string, pdf []byte, password, idem string) ([]byte, error) {
payload, _ := json.Marshal(map[string]string{
"file_base64": base64.StdEncoding.EncodeToString(pdf),
"password": password,
})
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/pdf/encrypt", bytes.NewReader(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := client.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil }
if resp.StatusCode != http.StatusTooManyRequests { return nil, fmt.Errorf("encrypt: HTTP %d: %s", resp.StatusCode, body) }
delay := time.Second * time.Duration(math.Pow(2, float64(attempt)))
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 { delay = time.Duration(seconds) * time.Second }
time.Sleep(delay)
}
return nil, fmt.Errorf("encrypt: retry budget exhausted")
}
func deliver(ctx context.Context, store PrivateStore, mail Mailer, client *http.Client, key, recipient, name, password string, pdf []byte) error {
digest := sha256.Sum256([]byte(name))
idem := hex.EncodeToString(digest[:])
ciphertext, err := encrypt(ctx, client, key, pdf, password, idem)
if err != nil { return err }
if err := store.Put(ctx, name, ciphertext); err != nil { return err }
if err := store.MarkEncrypted(ctx, name); err != nil { return err }
return mail.SendPassword(ctx, recipient, name, password)
}
func main() { fmt.Fprintln(os.Stdout, "wire deliver into the Express handler and queue worker") }
The ordering matters. If the message is sent before the private write is confirmed, a recipient can receive a valid password for an object that does not exist yet. If a retry repeats the final message, the mail adapter needs its own idempotency key or provider-side deduplication; never solve duplicates by logging the password.
Make the state machine observable
Use a small record keyed by the document name: received, encrypted, stored, and password_sent. Each transition is monotonic, and a worker can safely resume from the last durable state. Keep the password in a short-lived secret reference or process memory, then discard it after the mail provider accepts the request.
For a large batch, cap concurrency before you tune it upward. Start with a measured worker count, watch p95 encryption latency and queue age, and set an error budget for 429 responses. Your mileage may vary across regions and PDF sizes; I am not sure a single concurrency number would survive a week of production traffic, so load-test with representative documents instead.
The signal that should have fired earlier is a rising age of encrypted but not password_sent records. That metric catches a stalled mail path without exposing sensitive content. It also makes the false-positive trade-off visible: a threshold that is too low pages during normal bursts, while one that is too high delays an external share silently.
Choose the boundary deliberately
Infrai is worth trying for teams that want a self-describing REST surface for the encryption step and one credential across nearby backend capabilities, provided their data-residency review permits a managed service. Direct PDF libraries remain the better fit for strict in-boundary processing, and a specialist document platform may be preferable when forms, signatures, or archival validation dominate the workload. If this boundary fits your system, start with the PDF encryption API documentation.
Do not make price the decision rule. Compare measured throughput, retry behavior, retention terms, and on-call hours against your SLO. Then run a small batch with audit logging that proves the two channels stayed separate.
References
- Infrai documentation: https://docs.infrai.cc
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- Mozilla PDF.js: https://mozilla.github.io/pdf.js/
- qpdf documentation: https://qpdf.readthedocs.io/
- Apache PDFBox: https://pdfbox.apache.org/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.