ScreenshotNeo

BlogEngineering

Permanent Share Links for Generated Documents

Design permanent document links with stable IDs, immutable versions, authorization, revocation, and short-lived signed downloads.

By the ScreenshotNeo team29 September 202610 min read

Permanent Share Links for Generated Documents

Use a stable application URL such as /share/{opaque-id} as the permanent link. Store that ID in your database, resolve it to an immutable document version, check authorization and revocation, then issue a short-lived signed or presigned download URL. The stable URL is the identity; the signed URL is only the temporary delivery mechanism.

This separation lets you replace storage providers, rotate credentials, revoke access, pin a document forever, or deliberately point a share to the latest version without changing the link people saved.

AWS describes a presigned URL as valid for the period selected when it is generated. AWS console URLs can be configured from one minute to twelve hours, while CLI and SDK URLs can be configured for up to seven days; temporary credentials can shorten the effective lifetime. The URL also inherits the permissions of the principal that created it and expires when those credentials expire. See the AWS S3 presigned URL documentation.

Google Cloud signed URLs also require an expiration. Anyone who possesses an active URL can use it for the permitted operation during that window, so your application must apply authorization before creating one. See Google Cloud signed URL documentation.

A signed URL can therefore be a temporary download address, but it is a poor permanent identifier:

  • It contains provider-specific signatures and expiration parameters.
  • It cannot express your product’s tenant, audience, password, or revocation policy by itself.
  • It breaks when the token expires, even if the document still exists.
  • It couples every saved link to one storage provider and credential scheme.

CloudFront signed URLs can add controls such as IP restrictions, but they remain an access-control layer rather than a permanent identity. Use them for delivery when edge caching or network restrictions justify them, not as the canonical share identifier.

The reference architecture

A robust design has four layers:

A stable application URL resolves to an immutable version and issues a temporary download token only after authorization.
A stable application URL resolves to an immutable version and issues a temporary download token only after authorization.
  1. Stable resolver URL: GET /share/{share_id}. The ID is opaque and non-sequential.
  2. Share record: A database row records the owner, document, pinned version or latest policy, creation time, revocation state, and audit metadata.
  3. Immutable document: The generated bytes live under an immutable object key or provider version ID. Never overwrite a version that a pinned share is meant to preserve.
  4. Temporary delivery: After checks pass, return the file directly or redirect to a newly generated short-lived signed URL.

The browser sees a stable address such as https://app.example.com/share/7m4q2v9kz.... It never needs to know whether the bytes are currently in Amazon S3, Google Cloud Storage, or another provider.

Example database model

shares
------
id                  varchar primary key   -- random, opaque value
owner_id            uuid not null
document_id         uuid not null
version_id          varchar null           -- set for a pinned share
resolution_mode     varchar not null       -- 'pinned' or 'latest'
revoked_at          timestamp null
password_hash       varchar null
audience             varchar null
created_at          timestamp not null
last_accessed_at    timestamp null
created_by_ip       inet null

Use at least 128 bits of randomness for the share ID. Base64url, hexadecimal, or another URL-safe encoding works. Do not expose database auto-increment IDs: sequential identifiers make enumeration easier and reveal record volume.

Build the resolver step by step

1. Create an immutable document version

When a generator finishes, write the bytes to a new object key. A key such as documents/{document_id}/versions/{version_id}.pdf makes replacement explicit. Google Cloud Storage documents that objects are immutable during their storage lifetime; replacement creates a new generation number, which can support explicit version pinning. See Google Cloud Storage object metadata and generations.

# Example object metadata
bucket: generated-documents
key: documents/2d4.../versions/01J9...pdf
content_type: application/pdf
sha256: 4f8c...
created_at: 2026-09-29T12:00:00Z

2. Create a share record

Choose the behavior deliberately:

Mode Stored reference What readers receive Best for
Pinned Specific version ID The same bytes forever, until revoked or deleted Invoices, contracts, audit evidence, published reports
Latest Document ID plus resolution_mode=latest The current version at each access Living manuals, status pages, recurring exports

Do not infer “latest” from a missing version field. Store the mode explicitly so migrations and audits cannot silently change semantics.

3. Resolve and authorize

The resolver should perform checks before it generates any storage URL:

  1. Look up the opaque share ID.
  2. Return a generic not-found response when no row exists. Avoid revealing whether a revoked row once existed.
  3. Reject a row with revoked_at set.
  4. Apply tenant, audience, password, and account policy.
  5. Resolve the pinned version or current latest version.
  6. Verify retention and object existence.
  7. Generate a short-lived signed URL with the minimum operation and lifetime.
  8. Redirect, stream, or proxy the response.
  9. Record an audit event without logging the complete signed query string.
GET /share/7m4q2v9kz

HTTP/1.1 302 Found
Location: https://storage.example/...&X-Amz-Expires=300
Cache-Control: no-store

For private documents, use a short lifetime such as a few minutes and set Cache-Control: no-store on the resolver response. If you stream through your application, you avoid exposing a storage URL but pay the bandwidth and connection cost at your application layer.

4. Keep the resolver stable during migrations

If you move from S3 to Google Cloud Storage, update the resolver’s storage adapter. Existing /share/{id} links remain valid because they identify the application record, not the provider URL. During migration, retain old object versions until all pinned shares have been copied and verified.

Reference implementation (Node.js and Express)

The following example shows the control flow. Replace the repository and storage adapter with your database and provider SDK.

import express from "express";
import crypto from "node:crypto";

const app = express();

function newShareId() {
  return crypto.randomBytes(16).toString("base64url");
}

app.get("/share/:id", async (req, res) => {
  const share = await db.shares.findById(req.params.id);
  if (!share || share.revoked_at) return res.sendStatus(404);

  if (share.audience && req.user?.audience !== share.audience) {
    return res.sendStatus(403);
  }

  const version = share.resolution_mode === "pinned"
    ? await db.documentVersions.findById(share.version_id)
    : await db.documentVersions.findLatest(share.document_id);

  if (!version) return res.sendStatus(404);

  const downloadUrl = await storage.createSignedDownloadUrl({
    key: version.object_key,
    expiresInSeconds: 300,
    responseContentType: version.content_type,
    responseContentDisposition: `inline; filename="${version.filename}"`
  });

  await db.shares.touchAccess(req.params.id, new Date());
  res.set("Cache-Control", "no-store");
  res.redirect(302, downloadUrl);
});

app.listen(3000);

When creating a share, validate that the requested version belongs to the caller’s document and write the share and any audit record in one transaction. If a password is supported, store only a slow password hash and rate-limit failed attempts.

Versioning, replacement, and revocation

Replacing a document

Generate a new version object and update the document’s latest pointer. Pinned shares continue to resolve to their original version. Latest shares resolve to the new version on the next request. Never mutate an object in place when its bytes are part of a pinned share’s contract.

Set revoked_at on the share row. Every resolver request checks that field before signing a download. Previously issued signed URLs remain usable until they expire, so choose short lifetimes when rapid revocation matters. If immediate invalidation is required, remove or deny the underlying object, rotate signing credentials, or add a provider policy that blocks access; each option has broader operational effects.

Deleting old versions

Run a retention job that finds versions with no active pinned share and no legal or business retention requirement. A latest share does not protect every historical version, but a pinned share does. Keep deletion and revocation events in an audit log.

Security checklist

  • Generate cryptographically random, non-sequential IDs.
  • Apply authorization before generating a signed URL.
  • Use least-privilege signing credentials and the smallest practical expiration.
  • Do not put provider signatures in emails, database identifiers, or canonical URLs.
  • Do not log complete signed URLs or expose them in analytics referrers.
  • Use generic 404 responses to reduce enumeration and revocation disclosure.
  • Rate-limit share lookups, password attempts, and repeated failures.
  • Set download content type and disposition from trusted, stored metadata.
  • Audit creation, access, failed authorization, revocation, and deletion.
  • Define whether links are bearer credentials. Anyone who obtains an active signed URL can use it during its validity window.

Performance and reliability

A redirecting resolver adds one application request before the object download. Keep the lookup indexed by share ID and use a connection pool. Cache immutable version metadata, but do not cache authorization decisions longer than your policy allows. For high-volume public documents, generate a short-lived CDN URL after authorization and let the CDN serve the bytes.

Pinned shares preserve one version; latest shares deliberately follow the document’s current version.
Pinned shares preserve one version; latest shares deliberately follow the document’s current version.

Handle provider errors separately from missing documents. A transient storage timeout should produce a retryable 503 response, while a missing object should produce 404 and an operational alert if the share record still points to it. Make generation jobs idempotent: a retry should create or reuse one version rather than producing ambiguous duplicates.

For large files, prefer redirects or streaming with range-request support. For small files, proxying can simplify access logging and hide storage details. Measure resolver latency, signed-URL generation failures, object-not-found rates, and revoked-link attempts.

Cost considerations

The main costs are document generation, object storage, storage operations, egress, CDN delivery, and application requests. Immutable versions consume additional storage when documents are replaced, but they make reproducibility and audits possible. Lifecycle rules can delete unreferenced versions after the retention period. CDN delivery can reduce origin egress for frequently downloaded public artifacts, while private content may require a cache policy that limits sharing between users.

Do not generate a new signed URL once per byte or page. Generate one per download request, and choose a lifetime that balances revocation speed against repeated resolver calls.

Testing the design

  1. Create a pinned share and record its version hash.
  2. Replace the document with a new version.
  3. Confirm the pinned share returns the original hash.
  4. Create a latest share and confirm it returns the new version.
  5. Revoke each share and confirm the resolver returns 404 or your documented denial response.
  6. Wait for or force signed-URL expiry and confirm the storage provider denies the old URL.
  7. Move a test object between storage adapters and confirm the stable resolver URL is unchanged.
  8. Try another tenant, an incorrect password, a malformed ID, and a deleted object.

Common errors and fixes

Symptom Likely cause Fix
Saved link returns 403 later The saved URL was a presigned URL and expired Save the application resolver URL instead; issue a new signed URL per request.
Old share shows new bytes The object was overwritten or the share implicitly used latest Store a version ID and use immutable object keys for pinned shares.
Revoked link still downloads An already issued signed URL remains active or a CDN cached it Shorten token lifetimes, use private caching rules, and revoke at the resolver.
Users can access another tenant’s file Authorization was skipped before signing Check tenant ownership and audience on every resolver request.
Intermittent 404 after generation The share was published before the object was durable or replicated Publish the share only after a successful immutable write and verification.
Filename or content type is unsafe Metadata came from untrusted input Normalize filenames and allow-list content types before storing metadata.

Or skip the browser setup

If your workflow includes rendering a generated document’s web page before sharing it, ScreenshotNeo provides a one-request screenshot API. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers.

See the ScreenshotNeo API documentation for all options. cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/generated-document -o shot.webp

Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/generated-document"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/generated-document' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. It supports full-page capture, CSS element selection, custom CSS and JavaScript, waits, headers, cookies, user agents, device presets, PDF options, caching, signed links, asynchronous jobs, bulk capture, and a usage API. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Can I make an S3 presigned URL permanent?

No. Its validity is bounded by the requested expiration and the credentials that created it. Put a stable application URL in front of it.

Should every share pin a version?

Pin regulated, contractual, or reproducibility-sensitive documents. Use an explicit latest policy for living content and tell users that the bytes may change.

Can I use a UUID as the share ID?

Yes, provided it is generated securely and is not predictable. A random opaque value is preferable to a sequential database key.

When is a CDN signed URL useful?

Use one for edge delivery, geographic performance, or network restrictions after your application has authorized the request. Keep the resolver URL as the permanent identifier.

What happens if storage is unavailable?

Return a retryable server error, preserve the share record, and alert on the failed object lookup. Do not silently repoint a pinned share to another version.