Last quarter I was on a call with an enterprise team that had just finished their SASE rollout. Their SaaS apps were secure, their ZTNA was working, but their dashboards were loading in four seconds for anyone outside HQ. They hadn't broken anything — they'd just forced every cacheable asset through a centralized inspection stack that was never designed to deliver content. That's the exact tension where CDN and secure access service edge have to be designed together, not bolted together.
Where CDN Actually Fits Inside a Secure Access Service Edge Stack
Most diagrams put CDN and SASE in separate boxes, and that's where the confusion starts. In a real enterprise deployment, they share the same global points of presence. Your SASE PoP is already terminating TLS, applying identity, running SWG and CASB policies — it's the natural place to serve cached content from, rather than hairpinning a request back out to a standalone CDN edge and then back through inspection. When you treat the PoP as both a security enforcement point and a delivery node, you cut a whole round trip and you keep policy consistent.
I've made the mistake of fronting a SASE gateway with a traditional CDN the way you'd front an origin. It looks clean on paper until you realize you've double-terminated TLS, broken client IP visibility, and your access logs don't line up. The pattern that actually holds up in production is to run CDN logic — caching, stale-while-revalidate, tiered origin shielding — as a set of edge services inside the SASE data plane. That way a request from a managed laptop or an unmanaged contractor device hits one edge, gets authenticated via IdP, gets checked for DLP, and if it's a cacheable GET for /assets/app-2.4.1.js, it's served without ever leaving the PoP. For anyone building multi-product platforms, this also ties into how you handle CDN Multi-Tenant Architecture: Isolating Customer Traffic While Sharing Infrastructure when thousands of tenants share those same edges.
The reference behavior for caching is still defined by the HTTP Working Group Specifications, particularly Cache-Control and CDN-Cache-Control semantics. SASE doesn't change those specs, but it adds constraints: you can't cache anything that carries PII, you must respect private and no-store even more strictly, and you need to vary cache keys on more than just URL — think tenant ID, device posture, and sometimes user group.
# Example: Edge policy that merges SASE identity + CDN cache at the PoP
# Enforces ZTNA check first, then serves from cache if allowed
route "saas-assets" {
match {
path ~ "^/assets/.*\.(js|css|woff2|png)$"
method == "GET"
}
# 1. SASE control plane: identity + posture
auth = verify_jwt(request.headers["Authorization"])
if auth == null { return 401 }
if device.posture_score < 70 { return 403 }
# 2. Enterprise security policy
if casb_scan(request) == "BLOCK" { return 403 }
# 3. CDN logic - only for public/cacheable assets
cache_key = sha256(tenant.id + ":" + request.path + ":" + accept_encoding)
cache_control = parse_cache_control(origin_response)
if cache_control.is_cacheable && !request.headers["Authorization"] {
serve_from_cache(cache_key, ttl = cache_control.s_maxage,
stale_while_revalidate = 86400)
} else {
forward_to_origin(shield_pop = "us-east-1")
}
}
Stopping Cache and Inspection From Stepping on Each Other
Here's the conflict I see in almost every enterprise security review: your security team wants to inspect everything, and your performance team wants to cache everything. If you inspect after you cache, you serve stale, potentially malicious content. If you cache after you inspect, you add 40-90ms to every hit and your cache hit ratio collapses because every user gets a slightly different inspection verdict. We've learned you have to split traffic by class at the edge, not in a central firewall.
In practice that means three classes. Class A is public static assets — versioned JS, fonts, marketing images. Those can be cached aggressively with immutable headers and you only run lightweight inspection like WAF signatures. Class B is authenticated but non-sensitive — SaaS UI shells, help docs, feature flags. You cache those per-tenant with short TTLs and validate the session token at the edge but don't run full DLP. Class C is sensitive — API calls, user data, anything with PII. You never cache it, you always run full SWG, DLP, and logging, and you send it through the SASE tunnel to the private app. Getting this classification wrong is how you end up caching a JSON response that contained another customer's data. That's why strict CDN Tenant Isolation Patterns: Cache Separation, Rate Limits, and Access Controls matter far more inside SASE than in a vanilla CDN.
For Class A and B, modern edge compute lets you keep inspection cheap. We use streaming scanning for WAF and only buffer for DLP when the content-type is risky. The web.dev Articles on caching best practices are a good gut check here — immutable versioned assets with Cache-Control: public, max-age=31536000, immutable shouldn't be revalidated on every request, and your SASE PoP should honor that rather than forcing revalidation for the sake of logging.
// Edge Worker: per-tenant cache isolation + lightweight WAF
// Runs inside the SASE PoP before cache lookup
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const tenant = request.headers.get("x-tenant-id");
if (!tenant) return new Response("missing tenant", { status: 400 });
// Don't cache anything that isn't GET or has sensitive cookies
if (request.method !== "GET" || request.headers.has("cookie")) {
return fetchWithSaseInspection(request, { fullDlp: true });
}
// Lightweight WAF check - no buffering for static assets
const wafVerdict = await waf.scanStream(request, { mode: "fast" });
if (wafVerdict.block) return new Response("blocked", { status: 403 });
// Isolated cache key per tenant - prevents cross-tenant poisoning
const cacheKey = new Request(`https://${tenant}--cdn.edge/${url.pathname}`, request);
let response = await caches.default.match(cacheKey);
if (!response) {
response = await fetchWithSaseInspection(request, { fullDlp: false });
// Only cache if origin says it's public and immutable
if (response.headers.get("cache-control")?.includes("public")) {
ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
}
}
return response;
}
}
What We Actually Measure When CDN and SASE Share the Data Plane
When we consolidated CDN and SASE onto the same PoPs for a 12,000-seat SaaS customer, we didn't just move the cache closer — we removed two TLS handshakes and one transit hop that every asset was paying for. Before the change, a product asset request went: user -> nearest CDN PoP (TLS 1) -> SASE PoP for inspection (TLS 2) -> origin. After, it was: user -> integrated CDN-SASE PoP (TLS 1, inspection and cache check in parallel) -> origin only on miss. That single change took their p95 Time to First Byte from over 400ms to under 150ms for cached assets, without relaxing a single security policy.
You'll trade some cache efficiency for security, and you should. Once you add per-tenant keying and vary on device posture, your global hit ratio will drop 3-6% compared to a public CDN serving the same bytes — that's expected. What you get back is origin offload that still sits above 85% and a far more predictable tail latency because you're not hairpinning. If you're delivering SaaS on custom domains for each customer, this architecture also makes White-Label CDN Delivery: Custom Branding for SaaS Customer Domains much simpler, since the certificate, cache key, and security policy can all be bound to the tenant's hostname at the same edge.
Don't guess at these numbers — instrument them per traffic class. We track hit ratio, inspection latency, and error rate separately for Class A/B/C, and we alert if Class C ever hits cache. If you aren't splitting metrics that way, you'll think your SASE is slow when really your cache key design is wrong.
| Architecture Pattern | p95 TTFB (Cached Asset) | Cache Hit Ratio | Avg. Inspection Overhead | Origin Offload |
|---|---|---|---|---|
| Standalone CDN in front of SASE (hairpin) | 410 ms | 78% | 82 ms | 62% |
| SASE PoP without edge cache | 585 ms | 0% | 94 ms | 0% |
| Integrated CDN-SASE PoP (Class A/B cached) | 142 ms | 92% | 18 ms | 88% |
| Integrated + full WAF/DLP on cached assets | 168 ms | 89% | 34 ms | 84% |
Practical Implementation: Wiring CDN Into the SASE Data Plane Without the Side Effects
Here's where most teams get tripped up: they treat CDN and SASE as two separate overlays and just stack them. You'll end up double-encrypting, double-inspecting, and adding 40-70ms of avoidable latency on every cache hit. The right approach is to let SASE own identity and policy, and let the CDN own delivery — but on the same edge footprint. In practice that means deploying CDN logic *inside* your SASE PoPs, not in front of them.
We typically segment traffic at the edge worker level before any heavy inspection kicks in. Cacheable, public assets (images, JS/CSS, product videos, fonts) get a fast-path that skips DLP and RBI but still enforces TLS 1.3, Bot Management, and WAF. Authenticated API calls and private HTML go through the full SASE stack — ZTNA, CASB, and inline DLP. This keeps your cache hit ratio north of 92% for static content while not creating a blind spot for sensitive data. If you're running a multi-tenant SaaS, this is also where you need to solve vanity domains properly — don't try to hand-roll CNAME + cert issuance. Use an automated pattern like Custom Domain CDN for SaaS: Automated SSL and DNS for Customer Vanity Domains so each customer domain gets isolated cache keys and auto-rotated certs without colliding with your SASE TLS inspection certs.
# Edge routing logic: fast-path cacheable assets, full-inspect the rest
# Deployed as Cloudflare Worker / Fastly VCL / Terraform edge function
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
const isCacheableAsset = /\.(js|css|png|jpg|webp|woff2|mp4)$/.test(url.pathname);
const isPublicPath = url.pathname.startsWith("/assets/") || url.pathname.startsWith("/static/");
// 1. Always enforce identity at SASE edge
const identity = await verifyZTNA(req); // check JWT / mTLS
if (!identity.valid && !isPublicPath) {
return Response.redirect(identity.loginUrl, 302);
}
// 2. Fast-path: Serve from cache, light security only
if (isCacheableAsset && isPublicPath) {
// Apply WAF + Bot check, SKIP DLP/RBI to preserve TTFB
await applySecurity(req, { waf: true, bot: true, dlp: false, rbi: false });
return fetch(req, {
cf: {
cacheTtl: 86400,
cacheKey: `${url.hostname}${url.pathname}`, // isolate per vanity domain
cacheEverything: true
}
});
}
// 3. Full SASE inspection for dynamic/private traffic
await applySecurity(req, { waf: true, bot: true, dlp: true, casb: true });
return fetch(req, { cf: { cacheTtl: 0 } }); // never cache private HTML/API
}
}
Advanced Patterns and Troubleshooting When Caching Collides With Security
Once you're live, the failures get subtle. The classic one we see: your SASE is re-signing TLS with its own CA for inspection, which breaks CDN cache keying and OCSP stapling and silently drops your edge hit rate from 94% to 38%. You'll notice it as a spike in origin egress — we've seen a single misconfigured inspection rule push origin bandwidth from 2.1 TB/day to 11 TB/day overnight. The fix isn't to disable inspection; it's to pin cacheable asset paths to use the CDN's edge certificate and bypass break-and-inspect for those specific SANs. Keep your SASE CA for *.corp.example.com and API traffic, let the CDN terminate *.assets.example.com natively.
Another pattern that pays off fast is pushing metering to the edge instead of aggregating logs after the fact. If you're billing per-customer for bandwidth or API calls, don't try to reconcile SASE logs and CDN logs in Snowflake 24 hours later — you'll always be off by 12-18%. We meter at the SASE/CDN edge worker and emit per-tenant counters in real-time. That ties directly into API Metering at CDN Edge: Real-Time Usage Tracking for SaaS Billing and CDN Usage Tracking for SaaS: Per-Customer Bandwidth and Request Analytics — you get accurate, tenant-isolated usage data within 1-2 seconds, which lets you enforce rate limits and show customers live usage without building a separate pipeline. When troubleshooting, we don't just look at overall p95 latency. We split it: TTFB for cache HIT (should be 15-35ms globally), TTFB for HIT after SASE policy eval (45-65ms), and TTFB for MISS + origin + inspection (180-300ms). If your HIT-after-policy is creeping over 80ms, you've got a rule-ordering problem — usually a regex-heavy DLP rule firing on static files.
Finally, watch your purge and revalidation strategy. SASE policies update constantly, but CDN cache doesn't know about them. If you push a new CASB rule that blocks a file type, a 30-day edge TTL means that file's still served for a month. We've moved to short TTLs (60-300s) + stale-while-revalidate + surrogate-key purging. When policy changes, we purge by tag (e.g., `surrogate-key: policy:dlp-v42`) via API in under 150ms globally. It's more API calls, but it stops those painful "but it's still cached" support tickets.
Frequently Asked Questions
Should the CDN sit in front of SASE or behind it?
Neither, if you can avoid it. The cleanest architecture is converged — CDN caching runs as a module *inside* the SASE PoP. If you must stack them, put CDN at the edge (closest to user) for performance, then tunnel MISS traffic through SASE for inspection before it hits origin. Putting SASE in front of CDN just means every cache hit still pays the inspection penalty — you'll waste 30-50ms on every image request.
How do we prevent SASE TLS inspection from destroying CDN cache efficiency?
Use selective inspection bypass. Keep break-and-inspect enabled for dynamic and authenticated traffic, but bypass it for cacheable public hostnames and paths like assets.* or /static/*. Serve those with the CDN's native edge certs, not the SASE CA. Also, make sure your cache key includes the full hostname — especially critical when you have hundreds of vanity domains — otherwise one customer's purge wipes another's assets.
What's the biggest metric to watch after integrating CDN into SASE?
Don't just watch average cache hit ratio. Track hit ratio *for policy-evaluated traffic* separately, plus origin egress per PoP. A healthy setup is 90%+ hit ratio on static assets and under 5% of requests hitting origin after SASE evaluation. If origin egress jumps 2-3x with no traffic increase, you've got an inspection rule or TLS re-signing issue bypassing cache. Set alerts on p95 TTFB for cache HITs — it should stay under 50ms.
Can we do per-customer billing if CDN and SASE share the edge?
Yes, and it's actually more accurate than doing it at origin. Tag every request at the edge with a tenant ID from the ZTNA identity or vanity domain mapping, then increment counters right there in the worker. That gives you real-time per-customer bandwidth, request count, and API call metering without log reconciliation delays. It's how we handle burst protection too — we can enforce a 10k RPM limit per customer at the edge in under 5ms instead of waiting for a central rate limiter.