Published on

CloudFront Signed URLs: Securing Private S3 Assets for Multi-Tenant Applications

Authors
  • avatar
    Name
    Xiaoyi Zhu
    Twitter

Your users upload images to your app. Those images live in an S3 bucket. If that bucket is public, anyone with the URL can access any user's files—forever. One leaked link and you've got a privacy incident. CloudFront signed URLs solve this by making every asset access temporary, authenticated, and traceable.

Sound familiar? Many applications start with public S3 buckets for simplicity. It works until it doesn't. By adding CloudFront with signed URLs, you get CDN performance with cryptographic access control. This post walks through why, how, and what I learned implementing this pattern in production.


The Problem: Public Buckets Are a Liability

Without access control, S3 has one mode: anyone with the URL can download the file. This creates real issues:

  • No ownership enforcement: User A can access User B's files if they guess or obtain the URL. S3 URLs follow predictable patterns (bucket.s3.region.amazonaws.com/path/file.jpg).
  • Links never expire: Once shared, a URL works forever. There's no way to revoke access to a specific file without deleting it entirely.
  • No audit trail: You can't tell who accessed what or when. S3 access logs exist but don't tie back to your application's users.
  • Compliance exposure: HIPAA and similar regulations require demonstrable access controls. "Anyone with the link" doesn't satisfy auditors.
  • CDN caching conflicts: If you put CloudFront in front of a public bucket for performance, the cached content is equally public.

Bottom line: Public S3 buckets trade security for convenience. For user-uploaded content, that trade-off is unacceptable.


The Solution: CloudFront + Signed URLs + Private S3

The architecture is straightforward: make S3 completely private, put CloudFront in front, and generate short-lived signed URLs that only your backend can create.

+=============================================================================+
|                          SECURE IMAGE FLOW                                  |
+=============================================================================+
|                                                                             |
|  +----------+     +--------------+     +------------+     +--------------+  |
|  |   User   |---->|   Backend    |---->| CloudFront |---->|     S3       |  |
|  |  Browser |     |              |     |   (CDN)    |     |  (Private)   |  |
|  +----------+     +--------------+     +------------+     +--------------+  |
|       |                  |                   |                    |         |
|       |   1. Request     |                   |                    |         |
|       |   image URL      |                   |                    |         |
|       |----------------->|                   |                    |         |
|       |                  |                   |                    |         |
|       |   2. Verify auth |                   |                    |         |
|       |   + ownership    |                   |                    |         |
|       |                  |                   |                    |         |
|       |   3. Generate    |                   |                    |         |
|       |   signed URL     |                   |                    |         |
|       |<-----------------|                   |                    |         |
|       |                  |                   |                    |         |
|       |   4. Load image via signed URL       |                    |         |
|       |------------------------------------->|                    |         |
|       |                  |                   |                    |         |
|       |                  |                   |   5. Fetch with    |         |
|       |                  |                   |   OAC credentials  |         |
|       |                  |                   |------------------->|         |
|       |                  |                   |                    |         |
|       |   6. Image data  |                   |<-------------------|         |
|       |<-------------------------------------|                    |         |
|                                                                             |
+=============================================================================+

Security Layers

  1. S3 Bucket: Fully private. Block ALL public access. No exceptions.
  2. CloudFront OAC: Origin Access Control means only CloudFront can read from S3. Direct S3 URLs return 403.
  3. Signed URLs: CloudFront validates cryptographic signatures on every request. URLs expire after a configurable duration.
  4. Backend Authorization: Your app verifies the user owns the file before generating any signature.

Each layer is independent. Even if one fails, the others still protect your data.


Technical Deep Dive

1) AWS Infrastructure Setup

Make S3 Private

In S3 Console → Your Bucket → Permissions:

  • Block Public Access: Enable ALL four settings
  • Bucket Policy: Remove any "Principal": "*" statements
  • ACLs: Disable if possible (newer buckets don't need them)

Create CloudFront Distribution

Key settings when creating the distribution:

"Origin":       your-bucket.s3.us-west-2.amazonaws.com
"Origin Type":  Amazon S3
"Settings":     Allow private S3 bucket access to CloudFront - Recommended

AWS automatically updates your S3 bucket policy to allow CloudFront access via OAC. The policy looks like:

{
  "Version": "2008-10-17",
  "Id": "PolicyForCloudFrontPrivateContent",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket/*",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/BNMKJDS6EXAMPLE"
        }
      }
    }
  ]
}

Create Signing Keys

CloudFront uses RSA key pairs for signing. Generate locally:

# Generate 2048-bit RSA private key
openssl genrsa -out cloudfront-private-key.pem 2048

# Extract public key
openssl rsa -pubout -in cloudfront-private-key.pem -out cloudfront-public-key.pem

In CloudFront Console → Key Management:

  1. Create Public Key: Paste contents of cloudfront-public-key.pem
  2. Create Key Group: Add the public key you just created
  3. Update Distribution Behavior: Select your key group for "Trusted key groups"

Store the private key securely and make sure it is only accessible to your backend.

2) Backend Implementation

CloudFront Signing Module

const { getSignedUrl } = require('@aws-sdk/cloudfront-signer')

let cloudFrontConfig = null

const initializeCloudFront = () => {
  if (cloudFrontConfig) return cloudFrontConfig

  const distributionDomain = process.env.CLOUDFRONT_DISTRIBUTION_DOMAIN
  const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID
  let privateKey = process.env.CLOUDFRONT_PRIVATE_KEY

  // Handle escaped newlines in environment variables
  if (privateKey) {
    privateKey = privateKey.replace(/\\n/g, '\n')
  }

  // Fallback to file-based key
  if (!privateKey && process.env.CLOUDFRONT_PRIVATE_KEY_PATH) {
    privateKey = fs.readFileSync(process.env.CLOUDFRONT_PRIVATE_KEY_PATH, 'utf8')
  }

  if (!distributionDomain || !keyPairId || !privateKey) {
    return null // CloudFront disabled, graceful degradation
  }

  cloudFrontConfig = {
    distributionDomain,
    keyPairId,
    privateKey,
    urlExpiry: parseInt(process.env.CLOUDFRONT_URL_EXPIRY, 10) || 900,
  }

  return cloudFrontConfig
}

const generateSignedUrl = (s3Key, expirySeconds) => {
  const config = initializeCloudFront()
  if (!config) throw new Error('CloudFront not configured')

  const url = `https://${config.distributionDomain}/${s3Key}`
  const expiry = expirySeconds || config.urlExpiry
  const expiresAt = new Date(Date.now() + expiry * 1000)

  return getSignedUrl({
    url,
    keyPairId: config.keyPairId,
    privateKey: config.privateKey,
    dateLessThan: expiresAt.toISOString(),
  })
}

Key Pattern: Store Keys, Return Signed URLs

// Upload: store S3 key in database
const s3Key = `images/${userId}/${fileName}`
await s3Client.send(
  new PutObjectCommand({
    Bucket: bucketName,
    Key: s3Key,
    Body: buffer,
    ContentType: 'image/webp',
  })
)

// Database stores: "images/user123/photo.webp"
await saveToDatabase({ filepath: s3Key, userId })

// Response: return signed URL for immediate display
const signedUrl = generateSignedUrl(s3Key)
return { filepath: s3Key, signedUrl }

This separation is important: the database stores a permanent reference (S3 key), while the frontend receives a temporary access token (signed URL).

Signed URL Endpoint with Ownership Check

router.get('/signed-url/:file_id', async (req, res) => {
  const { file_id } = req.params
  const userId = req.user.id

  // Find file in database
  const file = await findFileById(file_id)
  if (!file) return res.status(404).json({ error: 'File not found' })

  // Verify ownership
  if (file.user.toString() !== userId) {
    logger.warn(`Unauthorized access attempt: ${userId} -> ${file_id}`)
    return res.status(403).json({ error: 'Unauthorized' })
  }

  // Generate signed URL
  const signedUrl = generateSignedUrl(file.filepath)
  res.json({ signedUrl })
})

3) Frontend: Handling URL Expiry

Signed URLs expire. The frontend needs to handle this gracefully:

const [imageSrc, setImageSrc] = useState(initialSignedUrl);
const [retryCount, setRetryCount] = useState(0);

const handleImageError = async () => {
  if (retryCount >= 1) return; // Only retry once

  try {
    const response = await fetch(`/api/files/signed-url/${fileId}`);
    if (response.ok) {
      const { signedUrl } = await response.json();
      setImageSrc(signedUrl);
      setRetryCount(prev => prev + 1);
    }
  } catch (error) {
    console.error('Failed to refresh signed URL:', error);
  }
};

return <img src={imageSrc} onError={handleImageError} />;

This pattern handles the case where a user leaves a tab open longer than the URL expiry.


Data Flow Comparison

Before: Public S3 Bucket

Upload:   BrowserBackendS3 (public)
          Database stores: full S3 URL

Retrieve: BrowserS3 directly
          No authentication, no expiry, no audit

Anyone with the URL has permanent access. No way to revoke.

After: Private S3 + CloudFront Signed URLs

Upload:   BrowserBackendS3 (private)
          Database stores: S3 key only

Retrieve: BrowserBackend (verify auth + ownership)
Generate signed URL
          BrowserCloudFront (validate signature)
S3 (via OAC)

Every access is authenticated, time-limited, and logged.


Gotchas and Lessons Learned

1) Private Key Formatting in Environment Variables

The private key contains newlines. Most .env parsers don't handle multi-line values well. Two solutions:

Option A: Escape newlines

CLOUDFRONT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n...\n-----END RSA PRIVATE KEY-----"

Then in code:

privateKey = privateKey.replace(/\\n/g, '\n')

Option B: File path reference

CLOUDFRONT_PRIVATE_KEY_PATH=/secrets/cloudfront-private-key.pem

Option B is more secure for production (key never in env vars).

2) Database Migration: URLs vs. Keys

If migrating from public S3, your database likely stores full URLs:

https://bucket.s3.region.amazonaws.com/images/user123/photo.webp

You need to extract and store just the key:

images/user123/photo.webp

Write a migration script that:

  1. Extracts S3 key from existing URLs
  2. Updates database records
  3. Verifies files still accessible via CloudFront

Potential Improvements

1) Shorter Expiry with Proactive Refresh

Current: 15-minute expiry, refresh on error.

Better: 5-minute expiry, proactively refresh URLs before they expire:

// Include expiry timestamp in response
res.json({ signedUrl, expiresAt: Date.now() + 300000 })

// Frontend refreshes when approaching expiry
useEffect(() => {
  const refreshInterval = setInterval(() => {
    if (Date.now() > expiresAt - 60000) {
      // 1 minute before expiry
      refreshSignedUrl()
    }
  }, 30000)
  return () => clearInterval(refreshInterval)
}, [expiresAt])

2) Batch Signed URL Generation

Loading a page with 50 images? Don't make 50 API calls:

// POST /api/files/signed-urls
router.post('/signed-urls', async (req, res) => {
  const { file_ids } = req.body
  const userId = req.user.id

  const results = await Promise.all(
    file_ids.map(async (file_id) => {
      const file = await findFileById(file_id)
      if (!file || file.user.toString() !== userId) {
        return { file_id, error: 'Unauthorized' }
      }
      return { file_id, signedUrl: generateSignedUrl(file.filepath) }
    })
  )

  res.json({ signedUrls: results })
})

3) Access Logging and Analytics (Require Pro plan)

CloudFront provides detailed access logs. Enable them and analyze:

  • Which files are accessed most frequently
  • Geographic distribution of users
  • Failed signature validations (potential attacks)

Security Checklist

Before going to production:

  • S3 bucket: Block ALL public access enabled
  • S3 bucket policy: Only allows CloudFront OAC (no "Principal": "*")
  • CloudFront: Requires signed URLs (Restrict Viewer Access = Yes)
  • CloudFront: Key group configured and selected
  • Private key: Stored securely, not in version control
  • Backend: Verifies user authentication before signing
  • Backend: Verifies ownership before signing
  • Signed URLs: Short expiry
  • Frontend: Handles URL refresh on expiry
  • Logging: Unauthorized access attempts logged
  • Testing: Verified direct S3 URLs return 403

Lessons Learned / Key Takeaways

  • Public S3 buckets are a security liability for user-uploaded content. The convenience isn't worth the risk.
  • CloudFront signed URLs are simple to implement but provide production-grade security. The cryptographic signing is built into AWS. You generate signatures, CloudFront validates them.
  • Store S3 keys in your database, generate signed URLs at request time. This separation enables URL expiry without database changes.
  • Defense in depth matters. Verify ownership at each level. Assume each layer might fail.
  • Handle URL expiry gracefully on the frontend. Users will leave tabs open. The onError → refresh pattern is simple and effective.
  • Make security features optional for development. Graceful degradation lets you test locally without full AWS setup.
  • Private key handling requires care. Environment variable escaping or file-based keys.

Wrapping Up

Securing user uploads isn't optional. It's table stakes for any application handling private data. CloudFront signed URLs offer a production-ready solution: CDN performance with cryptographic access control, no custom infrastructure required.

The pattern is straightforward: private S3 bucket, CloudFront with OAC, short-lived signed URLs generated by your authenticated backend. Each layer adds security. Together, they ensure only the right users see the right files, for the right amount of time.

Start with the basics (private bucket + signed URLs), measure your access patterns, then layer in optimizations (batch generation, signed cookies, presigned uploads). That's how you build file storage that's fast, secure, and ready for scale.