DEV Community

magnusberg2958
magnusberg2958

Posted on

Node.js Private Object Storage for User Documents: PDF, DOCX, and Expiring Access

Short answer: keep the bucket private, let Node.js authorize the document operation, and give the client a short-lived signed download URL only after checking the document's tenant and user ownership in your application database. Store PDF and DOCX bytes in object storage; store identity, state, and policy in the database. Treat bucket metadata as an operational copy, never as the authorization system.

That decision rule matters because document systems have two very different traffic shapes. A small request issues permission. A large request moves bytes. Sending both through the application fleet makes capacity planning and incident response harder than they need to be. A direct object-storage data path usually preserves application capacity for authorization and metadata work, while a proxy remains the right choice when inspection or a network boundary requires every byte to pass through Node.js.

Keep the bucket private.

What does a private document upload actually need to guarantee?

The incident lesson I use in design reviews is a bounded one: imagine a batch of download-link requests receives HTTP 429 from a dependency, while the retry helper immediately repeats the call and reports only elapsed time. The storage layer may be healthy, yet the link-issuance SLO burns through its error budget as invisible queue growth. I treat that failure mode as a control-plane problem, not evidence that the object bytes should move through the application.

The useful exercise is to follow one request across the whole path instead of staring at the storage dashboard. A user clicks Download; the API authenticates the session, reads a document row, and discovers that the row is available. The signer is throttled with 429. A careless client retries three times without delay, three workers do the same for the next page of documents, and the queue now contains work that has no chance of completing within the five-minute user interaction. If the API records only a final 200 or 500, the incident looks like ordinary latency. If it records the dependency status, attempt number, queue age, and decision outcome, the operator can distinguish throttling from a missing object, an authorization denial, an expired capability, and a browser cancellation. Those distinctions lead to different actions: reduce concurrency for a throttle, repair state reconciliation for a missing object, fix policy for a denial, and inspect client timing for cancellation. This is the level of detail I expect in a platform review because the application fleet's SLO is spent by the control path even though the document data path never failed.

The invariant is straightforward: a signed URL is a temporary bearer capability, not a document identity. An application record should bind an opaque document ID to its tenant, user, object key, state, detected media type, byte count, checksum, and retention policy. A request for a download URL must load that record, perform the current authorization check, and sign the server-owned object key. It must not accept a complete key from the browser, and it must not treat successful signing as proof that the caller was allowed to read the record.

For an upload, create the application record before the bytes move. Generate the object key on the server. Accept application/pdf and the DOCX media type as declared input only; a browser header is useful routing information, not proof of file contents. Enforce a maximum body size at the HTTP boundary, inspect the actual format in a quarantined processing step, and change the record from pending to available only after the required checks complete.

Keep the failure states explicit. An abandoned multipart upload, an object with no database row, and a database row whose object is missing are three different cleanup and alerting cases. A single uploaded = true flag hides too much. I want counters for authorization denials, signature requests, 429 responses, retry exhaustion, orphan cleanup, and pending-document age; I also want upload Mbps, p95 object size, and concurrent transfers beside them.

The user ID belongs in the application record as the authoritative relationship. Copying a non-sensitive identifier into object metadata can help an operator correlate a storage event, but metadata is not a substitute for a queryable authorization model. Do not put email addresses or access policy into a field that may be exposed through operational tooling.

How should Node.js issue signed download URLs for private PDF and DOCX objects?

The handler should have a narrow sequence: authenticate the session, load the document by its opaque ID, compare the authenticated tenant and user to the record, require available, then ask a storage adapter to sign a GET operation for a bounded lifetime. The handler should return the same not-found result for a missing record and a record owned by another user, so the endpoint does not become an ownership oracle.

Here is the boundary I want tested. The application knows about users and document state; the adapter knows how to create a provider-specific signature. The rest of the code does not need to know how a signature is encoded.

package document

import (
    "context"
    "errors"
    "time"
)

var ErrNotFound = errors.New("document not found")

type Record struct {
    ID       string
    TenantID string
    UserID   string
    ObjectKey string
    State    string
}

type Repository interface {
    Find(ctx context.Context, documentID string) (Record, error)
}

type Signer interface {
    SignGet(ctx context.Context, bucket, key string, ttl time.Duration) (string, error)
}

func DownloadURL(ctx context.Context, repo Repository, signer Signer, bucket, tenantID, userID, documentID string) (string, error) {
    record, err := repo.Find(ctx, documentID)
    if err != nil {
        return "", err
    }
    if record.TenantID != tenantID || record.UserID != userID || record.State != "available" {
        return "", ErrNotFound
    }
    return signer.SignGet(ctx, bucket, record.ObjectKey, 5*time.Minute)
}
Enter fullscreen mode Exit fullscreen mode

Five minutes is an example policy, not a magic constant. Choose the lifetime from the user's completion time, the sensitivity of the documents, the session lifetime, and the threat model. A link copied into a log, browser history, analytics event, or support ticket is still a bearer credential until it expires. Never persist the signed query string as the durable document reference, and redact query parameters in request logs and traces.

The retry path deserves its own test. A 429 should produce bounded backoff with jitter, a visible attempt count, and a controlled error after the retry budget is exhausted. It should not spin, swallow the final status, or turn a failed issuance into a successful metric. I'm not sure any generic retry helper can choose the right policy without knowing the link-issuance SLO, so I make that policy an explicit adapter setting and inspect it in review.

Which storage architecture fits the upload and download SLO?

I start the buy-versus-build discussion with ownership of failure modes. All of the following can be made to work, but they put bandwidth, policy, and recovery work in different places.

Architecture What the application fleet carries Operational trade-off Appropriate boundary
Direct upload and signed download Authentication, metadata, and signing requests Requires completion verification, browser policy, and separate data-plane telemetry Clients can reach private object storage and bytes need not be synchronously inspected
Node.js upload/download proxy Every connection and every document byte Easier synchronous inspection, but connection count, memory, network, and egress consume the fleet's SLO budget A compliance control or network boundary requires inline handling
Self-hosted S3-compatible storage Control requests plus storage operations Your team owns durability, patching, capacity, backups, and restore drills Placement or infrastructure control justifies another storage on-call surface
Managed object storage Control requests; the service owns much of the storage capacity layer IAM, account boundaries, egress, and service-specific behavior create contract and portability work The team wants to buy the storage operations it does not want in its roadmap

The catch is that direct transfer is not suitable when every byte must pass through synchronous data-loss prevention, when clients cannot reach the storage endpoint, or when browser policy cannot be made trustworthy. Use a Node.js proxy in those cases and budget for long-lived connections. A proxy that is sized like an ordinary JSON API is a capacity incident waiting for a large upload.

Self-hosting is also a poor fit for a small rotation without storage expertise; the bytes are only one part of the responsibility. A managed service is a poor fit when regulatory placement requirements cannot be expressed in its controls. Your mileage may vary on the exact boundary, but the decision should follow the required control and the team's recovery capability, not a feature checklist.

For capacity planning, estimate peak upload Mbps, peak download Mbps, p95 and maximum object size, concurrent sessions, signing requests per second, abandoned-upload rate, and restore time objective. For a proxy, translate peak bytes and concurrent connections into instance network and memory headroom, then test a rolling deployment while transfers are active. For a direct path, load-test issuance independently from download throughput; otherwise a healthy signing endpoint can hide a failing data path, or the reverse.

There is no universal winner.

How do we deploy, observe, and revoke private object access?

Start behind a per-tenant switch. A deployment test should cover one PDF, one DOCX, an oversized body, a declared-type mismatch, another user's document ID, a pending record, an expired URL, a repeated completion callback, and a simulated 429. The old read path can remain available while new records use the new key scheme, which makes rollback a controlled routing decision instead of a desperate bucket rewrite.

Revocation needs precise language. Disabling the application record prevents new links from being issued, but an already issued bearer capability can remain usable until expiry. If immediate revocation is a requirement, shorten the lifetime, remove the object according to retention policy, or proxy reads through a continuously authorized application path. The last option raises fleet load and enlarges the availability surface. I will not promise immediate revocation from a URL that has already been distributed.

Page on user-visible symptoms: p99 issuance latency, authorization availability, exhausted retries, and oldest pending-document age. Ticket lower-rate anomalies such as checksum mismatches and orphan cleanup lag. Structured logs should carry tenant ID, document ID, an object-key hash, the authorization decision, attempt count, and latency, but never the signed URL. Access logs retain sensitive material longer than many teams expect.

Finally, rehearse exit. Keep object keys and document state portable, avoid making provider-specific metadata the only copy of business data, and restore a sample into an isolated bucket. Measure restore throughput and the time required to issue access through a replacement signer. A migration that copies bytes but cannot reconstruct ownership and state is an archive, not a recovery plan.

The recommendation holds for private user documents, short-lived access, and a team that wants to separate control-plane SLOs from byte-transfer capacity. Choose a proxy when inline inspection or network isolation is the dominant requirement; choose self-hosting only when its operational burden is justified. The architecture is successful when a PDF or DOCX can be uploaded, authorized, downloaded, observed, and revoked according to an explicit policy, with no permanent public URL and no identity relationship hidden inside object metadata.

References

Top comments (0)