The 'Just Put a CDN In Front of It' Advice That Will Burn You
Everyone says 'just add a CDN' like it's free performance. It is, until your content is behind auth. Then it's a liability.
I've watched this play out enough times that it feels like a rite of passage. Someone adds a CDN to speed up their app, everything looks great in staging, and then in production they discover that User A is seeing User B's data. Or worse, they don't discover it for weeks.
The advice "just put a CDN in front of it" is correct exactly often enough to be dangerous.
What a CDN Actually Does (and Assumes)
A CDN caches HTTP responses at edge nodes close to your users. The fundamental assumption baked into every CDN is that a URL maps to a single canonical response that is safe to serve to anyone who requests that URL.
That assumption holds for a CSS file. It does not hold for /api/patient-records?id=1042.
The CDN doesn't know who the user is. It sees a request come in, checks its cache for that URL (and sometimes the query string, depending on config), finds a hit, and serves it. Fast. No origin call. No auth check. Just pure, unauthenticated cache hit delivery.
When your content is public, this is exactly what you want. When your content is behind auth, this is a data breach waiting to happen.
The Scenario That Actually Bites People
I built a document portal for a healthcare client a few years back. PDF reports, lab results, that kind of thing. The previous vendor had "optimized" the app by putting CloudFront in front of everything. Their performance numbers looked great.
The documents were served from signed S3 URLs, which expire, so that part was fine. The problem was the JSON API responses listing which documents a user could access. Those were cached at the edge with no Vary header, no cache partitioning, nothing. The CDN was happily serving cached user-specific API responses to whoever asked first.
In practice they got lucky - the user base was small enough that cache collisions were rare. But the architecture was a loaded gun.
Where the Config Goes Wrong
Here's a simplified version of what a misconfigured CloudFront behavior looks like via the AWS SDK. I'm using PHP/Laravel here because that's what I ship.
// This is the WRONG way - caching an authenticated API response
$cloudfront->createDistribution([
'DistributionConfig' => [
'DefaultCacheBehavior' => [
'ViewerProtocolPolicy' => 'redirect-to-https',
'CachePolicyId' => 'MANAGED_CACHING_OPTIMIZED', // caches everything
'TargetOriginId' => 'api-origin',
// No ForwardedValues config for cookies or auth headers
// CDN will cache /api/me, /api/documents, all of it
],
],
]);
And here's the corrected approach - separate behaviors for public vs. authenticated paths:
// Separate cache behaviors by path pattern
$behaviors = [
[
// Public assets - cache aggressively
'PathPattern' => '/assets/*',
'CachePolicyId' => 'MANAGED_CACHING_OPTIMIZED',
'TargetOriginId' => 'static-origin',
'ViewerProtocolPolicy' => 'redirect-to-https',
],
[
// Auth-protected API routes - DO NOT cache at edge
'PathPattern' => '/api/*',
'CachePolicyId' => 'MANAGED_CACHING_DISABLED',
'OriginRequestPolicyId' => 'MANAGED_ALL_VIEWER', // forward all headers
'TargetOriginId' => 'api-origin',
'ViewerProtocolPolicy' => 'redirect-to-https',
],
[
// Authenticated file downloads via signed URLs
'PathPattern' => '/downloads/*',
'CachePolicyId' => 'MANAGED_CACHING_DISABLED',
'TargetOriginId' => 'api-origin',
'ViewerProtocolPolicy' => 'redirect-to-https',
],
];
Disabling caching on authenticated routes means you're using the CDN purely as a reverse proxy for those paths - which is fine for TLS termination and DDoS absorption, but you're not getting cache performance there. Know what you're buying.
The Vary Header Trap
Some people think they can fix this with Vary: Cookie or Vary: Authorization headers. In theory, the CDN creates separate cache entries per unique header value. In practice, most CDNs either ignore Vary: Authorization entirely or handle it inconsistently.
CloudFront historically stripped Authorization headers and ignored Vary: Authorization by default. Cloudflare has similar gotchas depending on your plan tier. Fastly is more configurable but requires VCL you have to write yourself.
In Laravel, it's tempting to do this:
// In a middleware - DON'T rely on this for auth-protected responses
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// This looks right but many CDNs ignore Vary: Authorization
$response->headers->set('Vary', 'Authorization');
return $response;
}
Don't rely on Vary for security. Read your CDN's documentation on which Vary values it actually respects and test it. If you can't verify the behavior with certainty, disable caching for that route.
What I Actually Do
For any client where content is user-specific, I treat the CDN as two separate systems mentally:
Layer 1 - Static assets. CSS, JS, images, fonts. Cache everything aggressively. Long TTLs, immutable headers where appropriate, cache-busting via content hashes in filenames (Vite/Mix handles this). Zero auth concerns here.
Layer 2 - Dynamic authenticated responses. CDN sits in front as a reverse proxy only. No caching. The value here is TLS termination, geographic routing, DDoS mitigation, and not having to expose your origin server directly. That's real value - just not cache performance.
For file downloads behind auth, I use pre-signed S3 URLs with short expiration (15-60 minutes) and I never put the signed URL through a CDN cache. The signing happens at request time:
use Aws\S3\S3Client;
use Aws\S3\PreSignedUrlGenerator;
public function getDownloadUrl(string $documentKey, int $userId): string
{
// First verify the user actually owns this document
$document = Document::where('s3_key', $documentKey)
->where('user_id', $userId)
->firstOrFail();
$s3 = new S3Client([
'region' => config('filesystems.disks.s3.region'),
'version' => 'latest',
]);
$cmd = $s3->getCommand('GetObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $documentKey,
'ResponseContentDisposition' => 'attachment; filename="' . $document->filename . '"',
]);
// 30 minute window - short enough to limit exposure
$presigned = $s3->createPresignedRequest($cmd, '+30 minutes');
return (string) $presigned->getUri();
}
The pre-signed URL goes directly to S3, not through any CDN layer. The user gets fast downloads from S3's infrastructure without you needing to cache anything.
When CDN Caching Makes Sense for "Authenticated" Apps
There's one pattern where aggressive CDN caching works fine even with auth - and that's when you've fully separated your static shell from your dynamic data.
If you're building a React or Vue SPA, the HTML shell, the JS bundles, and the CSS are all public. Cache them hard. The auth happens inside the app via API calls that never get cached. This is the architecture that lets you have both: fast asset delivery via CDN and secure authenticated API responses that bypass the cache entirely.
Most Laravel apps I build these days follow this pattern. Vite builds the frontend assets with hashed filenames, those go to S3 + CloudFront with long TTLs, and the API layer is behind its own subdomain (api.client.com) where caching is explicitly disabled for all routes.
When I'd Skip the CDN Entirely
For internal tools, I skip CDN setup almost entirely. If your user base is 50 people in one city, CDN edge caching buys you nothing. A properly sized server with HTTP/2 and gzip compression will serve your app faster than a misconfigured CDN.
I've also seen CDN layers add debugging complexity that costs more in engineering time than the performance gain justifies. When a client calls me because their app is showing stale data, the CDN cache is always the first thing I have to rule out. That diagnostic overhead is real.
If your content is predominantly user-specific, authenticated, and not publicly cacheable - think EHR portals, internal dashboards, e-commerce account pages - the ROI on CDN caching specifically (vs. CDN as reverse proxy) is often negative.
The Real Question Is What You're Caching
A CDN is not a performance silver bullet you bolt on at the end. It's a contract: "this URL returns the same response for all callers." If that's true for your content, CDNs are great. If it's not true, you need to either make it true (separate public from private paths) or accept that caching is off the table for that content.
The "just add a CDN" advice comes from people who built content sites or SPAs with clean public/private separation. That separation is the real work. The CDN is just a consequence of doing it right.
Get the architecture right first. The CDN is easy. The data breach cleanup is not.
Need help shipping something like this? Get in touch.