🔗 files.link
tutorials

How to Serve User-Uploaded Content Through a CDN

CDN architecture for user-generated content: public vs private buckets, signed URLs, cache invalidation, security, and code examples.

Why User-Uploaded Content Needs a CDN

When users upload files to your application — profile pictures, documents, media attachments — those files need to be served back to other users. Serving them directly from your application server or origin storage creates three problems that get worse as you grow.

Latency. A file stored in us-east-1 takes 200-400ms to reach a user in Tokyo. Through a CDN, the same file is served from a nearby edge location in under 50ms after the first request.

Bandwidth costs. Origin egress is expensive. S3 charges $0.09/GB for data transfer. CloudFront charges $0.085/GB for the first 10 TB but serves cached content from edge locations, reducing origin egress dramatically. If a popular profile picture is viewed 10,000 times, the CDN serves it from cache 9,999 times — your origin serves it once.

Origin load. Every file download that hits your origin consumes server resources — network bandwidth, connection slots, and I/O. A viral piece of content can saturate your origin server. A CDN absorbs the traffic at the edge, keeping your origin healthy.

The architecture is not complex, but there are decisions that matter: public vs private content, cache invalidation strategies, and security controls that prevent your CDN from becoming a malware distribution network.

Architecture: Origin + CDN Pattern

The standard architecture for serving user-uploaded content has three layers:

1. Object storage (origin)

Files live in object storage — typically S3, GCS, or a managed storage service. This is the source of truth. The CDN fetches files from here on cache miss.

2. CDN (edge layer)

The CDN caches files at edge locations worldwide. After the first request, subsequent requests for the same file are served from the nearest edge — no origin fetch required.

3. Application server (control plane)

Your application controls who can upload files, who can access them, and what URLs to generate. It never serves file bytes directly (that is the CDN's job).

User Request
    |
    v
[CDN Edge Location]
    |
    | (cache miss only)
    v
[Origin: S3 / Object Storage]
    ^
    |
[App Server: generates URLs, enforces access control]

Public content (profile pictures, blog images, public media) gets a direct CDN URL. The CDN fetches from the origin on the first request and caches it at the edge.

Private content (user documents, internal files, paid content) uses signed URLs. The application server generates a time-limited signed URL that grants temporary access through the CDN.

This separation is critical. Public content should be cached aggressively and served without authentication. Private content should require a valid signature on every request. Mixing the two models — or worse, making everything public because "it is easier" — is how data leaks happen.

Public Content: Direct CDN URLs

For public user-generated content, the architecture is straightforward: upload to a public bucket (or public folder), and serve via a CDN URL.

Upload flow:

# Upload a profile picture (public)
curl -X POST https://api.files.link/v1/files \
  -H "Authorization: YOUR_API_KEY" \
  -F "[email protected]" \
  -F "folderId=PUBLIC_AVATARS_FOLDER"

# Response includes the CDN URL:
# {
#   "id": "file_abc123",
#   "cdnUrl": "https://cdn.files.link/p/abc123/avatar.jpg",
#   "size": 45320,
#   "contentType": "image/jpeg"
# }

The CDN URL is permanent and publicly accessible. Anyone with the URL can access the file. This is correct for content that is intentionally public — avatars, product images, blog media.

Cache headers for public content:

Public assets should be cached aggressively. The CDN should serve them with long cache lifetimes, and the origin should set appropriate headers:

Cache-Control: public, max-age=31536000, immutable

If your CDN file hosting service manages cache headers automatically, verify the defaults. For user-uploaded content that might be updated (e.g., a user changes their avatar), you have two options:

  • New URL per upload: Each upload generates a unique URL. The old URL continues to serve the old file from cache until it expires. This is the simplest and most reliable approach.
  • Cache invalidation: Keep the same URL and invalidate the CDN cache when the file changes. This is more complex and has propagation delays (CDN edge nodes do not invalidate simultaneously).

The new-URL approach is strongly preferred. Cache invalidation across a global CDN is inherently eventually consistent — there will always be a window where some users see the old content and others see the new content.

Private Content: Signed URLs Through the CDN

Private content — documents, invoices, medical records, paid downloads — should never be accessible via a public URL. Instead, your application generates signed URLs that grant temporary access.

How signed URLs work with a CDN:

  • Your application verifies the user is authorized to access the file
  • It generates a signed URL with an expiration time (typically 5-15 minutes)
  • The user's browser fetches the file using the signed URL
  • The CDN validates the signature and serves the file (from cache if available, from origin if not)
  • After expiration, the signed URL stops working
// Server-side: generate a signed URL after verifying access
app.get("/api/documents/:id/download", authenticate, async (req, res) => {
  const doc = await db.findDocument(req.params.id);

  // Verify the requesting user has access
  if (doc.ownerId !== req.user.id) {
    return res.status(403).json({ error: "Access denied" });
  }

  // Generate a signed URL (10-minute expiration)
  const signedUrl = await generateSignedUrl(doc.fileKey, {
    expiresIn: 600,
  });

  res.json({ url: signedUrl, expiresIn: 600 });
});

CDN caching with signed URLs:

This is a subtlety that trips up many implementations. If the CDN caches a response served via a signed URL, and a different user requests the same file with a different signed URL, the CDN might serve the cached response — bypassing the signature check.

The solution: configure the CDN to include the query string (which contains the signature) in the cache key. This means each unique signed URL is cached separately. Files accessed with different signatures are treated as different cache entries.

If you use a managed service like files.link, signed URL caching is handled correctly by default — private folders return signed URLs with proper cache key configuration, and you do not need to configure cache behaviors manually.

Cache Invalidation for Mutable Content

User-uploaded content is often mutable. Users update their profile pictures, replace document versions, or edit uploaded images. When content changes, stale CDN caches become a problem.

Strategy 1: Immutable URLs (recommended)

Treat every upload as a new, unique file. When a user updates their avatar, upload the new file with a new key and update the URL in your database. The old file continues to be served from CDN cache until it expires, and the new URL immediately serves the new content.

// When user uploads a new avatar:
// 1. Upload new file (gets a new unique URL)
const newFile = await uploadFile(newAvatarFile, "avatars/");

// 2. Update the user's avatar URL in the database
await db.updateUser(userId, { avatarUrl: newFile.cdnUrl });

// 3. Optionally delete the old file after a grace period
setTimeout(() => deleteFile(oldAvatarKey), 24 * 60 * 60 * 1000);

This approach is simple, reliable, and cache-friendly. The only downside is that old files consume storage until cleaned up.

Strategy 2: Cache invalidation

If you must keep the same URL for updated content, you need to invalidate the CDN cache when the content changes. CloudFront invalidation takes 5-15 minutes to propagate to all edge locations.

# Invalidate a specific file
aws cloudfront create-invalidation \
  --distribution-id E1A2B3C4D5 \
  --paths "/avatars/user-123.jpg"

Invalidation has costs: AWS charges for invalidation requests beyond the first 1,000 per month. More importantly, invalidation is not instantaneous — users in different regions will see the update at different times.

Strategy 3: Versioned URLs

Append a version parameter to the URL: /avatar.jpg?v=2. The CDN treats this as a different cache key. This gives you instant updates without invalidation, but requires your application to track and update the version parameter.

For serving images at scale, the immutable URL approach is almost always the right choice. It is the simplest to implement, the most cache-friendly, and eliminates the consistency problems of invalidation-based approaches.

Security Considerations

A CDN for user-uploaded content is a potential attack vector. Users upload content, and your CDN serves it globally. Without proper controls, you risk hosting malware, serving XSS payloads, or enabling content abuse.

Content-type validation

Validate the content type on upload — do not trust the Content-Type header sent by the client. Inspect the file's magic bytes to determine the actual type.

import { fileTypeFromBuffer } from "file-type";

async function validateUpload(fileBuffer, declaredType) {
  const detected = await fileTypeFromBuffer(fileBuffer);

  // Reject if actual type does not match declared type
  if (!detected || detected.mime !== declaredType) {
    throw new Error("Content type mismatch");
  }

  // Reject dangerous types
  const blocked = ["application/x-executable", "text/html", "application/javascript"];
  if (blocked.includes(detected.mime)) {
    throw new Error("File type not allowed");
  }

  return detected;
}

Why block HTML and JavaScript? If a user uploads an HTML file and your CDN serves it from your domain, the HTML can execute JavaScript in the context of your origin — stealing cookies, making authenticated API requests, or redirecting users. Always serve user-uploaded HTML with Content-Disposition: attachment or from a separate domain.

Size limits

Enforce upload size limits at multiple layers:

  • Application level: Reject uploads exceeding your limit before processing
  • Storage level: Configure max object size on your bucket or storage service
  • CDN level: Set maximum request body size on your CDN origin

Content-Disposition header

For file types that browsers might try to render (HTML, SVG, PDF), set Content-Disposition: attachment to force a download instead of inline rendering. This prevents XSS via uploaded HTML/SVG files.

Malware scanning

For applications handling sensitive uploads (healthcare, finance, legal), scan uploaded files for malware before making them available. Services like ClamAV can scan files asynchronously after upload:

  • Upload file to a quarantine bucket/folder
  • Scan asynchronously
  • Move to the public/private serving bucket only after the scan passes
  • Notify the user if the scan fails

Rate limiting uploads

Without rate limits, a single user can upload thousands of large files, consuming your storage quota and bandwidth. Implement per-user upload rate limits at the API level.

CDN Approach Comparison

Different CDN approaches suit different use cases. Here is how the common options compare.

ApproachSetup ComplexityCostBest For
S3 + CloudFront (manual)High — configure origin, cache behaviors, SSL, OACPay-per-use (S3 + CloudFront separately)Large-scale apps in the AWS ecosystem
DigitalOcean Spaces CDNLow — toggle CDN on a SpaceFlat $5/month + overageSmall-medium projects on DigitalOcean
files.link managed CDNNone — CDN is automatic on uploadPrepaid creditsApps that want zero CDN configuration
Cloudflare R2 + CDNLow — R2 with Workers for custom logicNo egress fees, pay for storage + operationsHigh-bandwidth apps optimizing for egress cost
Self-hosted (nginx + origin)High — manage servers, caching, SSL, scalingServer costsOn-premise requirements or full control

Putting It All Together

A well-architected CDN setup for user-uploaded content follows these principles:

  • Separate public and private content. Public files get direct CDN URLs with long cache times. Private files use signed URLs with short expiration. Never mix the two models in the same bucket or folder.
  • Use immutable URLs. When content changes, generate a new URL instead of invalidating CDN caches. This is simpler, faster, and more reliable than cache invalidation.
  • Validate uploads aggressively. Check content types by inspecting file bytes, not trusting client headers. Block dangerous types (HTML, JavaScript, executables). Enforce size limits.
  • Set cache headers deliberately. Public content: Cache-Control: public, max-age=31536000, immutable. Private signed URLs: CDN should cache by full URL including query string.
  • Serve user content from a separate domain. If possible, serve user-uploaded files from a different origin than your application (e.g., cdn.yourapp.com instead of yourapp.com). This prevents cookie theft and XSS via uploaded content.

If you want to skip the CDN configuration entirely, files.link handles the full stack — user-generated content storage with automatic CDN delivery, signed URLs for private files, and content-type handling. But whether you build the infrastructure yourself or use a managed service, the architecture patterns in this guide apply. CDNs are infrastructure — the security and caching decisions are yours regardless of who operates the edge servers.