CDN Data Classification and Handling: Policies for Sensitive Content at the Edge

A few years ago I got paged at 2 a.m. because a SaaS customer's private invoices were showing up in another tenant's dashboard. It wasn't a database breach — it was a single missing cache header at the edge that told our CDN to store a sensitive PDF as if it were a public logo. That incident reshaped how my team thinks about data classification and it’s why you can’t treat the edge as just a dumb cache.

Why Classification Has to Happen Before Traffic Hits the Edge

In a traditional origin-centric model, you've got time to inspect a response and decide what it is. At the CDN edge, that decision window shrinks to milliseconds. If you don't know whether a response contains public marketing assets or sensitive content like PII, tokens, or tenant-specific reports, your caching, logging, and even TLS handling will default to the wrong behavior. I've seen teams try to bolt on classification after they've already gone live, and it always turns into a frantic game of adding exceptions to VCL or edge workers while traffic is still flowing.

For SaaS and enterprise platforms, the challenge is compounded by multi-tenancy. One edge PoP might be serving ten different customers at once, each with their own compliance requirements. You can't apply a one-size-fits-all cache rule when Tenant A considers user emails as public and Tenant B treats them as regulated data under GDPR or HIPAA. That's where a clear tie between your data classification framework and your CDN configuration becomes critical. We now map every content type to a sensitivity tier at the application layer and pass it explicitly to the edge, so the CDN never has to guess. If you're sharing infrastructure, this approach is the foundation for proper CDN Multi-Tenant Architecture: Isolating Customer Traffic While Sharing Infrastructure — without it, isolation is just wishful thinking.

Our current standard is to have the origin emit a definitive classification header, and have the edge enforce it strictly. We don't rely on URL patterns alone because an endpoint like /api/v2/documents/12345 could return a public template or a confidential contract depending on the user. The HTTP Working Group Specifications give us the mechanisms like Cache-Control, Vary, and CDN-Cache-Control, but they're only useful if the values reflect the actual sensitivity of the payload. I tell teams: if your origin can't label it, your edge shouldn't cache it.

// Edge worker (Cloudflare / Fastly Compute@Edge style) - enforce classification
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(req) {
  const res = await fetch(req);
  
  // Origin must classify every response
  const sensitivity = res.headers.get('X-Data-Classification');
  const validTiers = ['public', 'internal', 'confidential', 'restricted'];
  
  if (!validTiers.includes(sensitivity)) {
    // Fail closed: unknown classification = don't cache, don't log body
    res.headers.set('Cache-Control', 'private, no-store, max-age=0');
    res.headers.set('CDN-Cache-Control', 'no-store');
    res.headers.set('X-Cache-Policy', 'BYPASS-UNCLASSIFIED');
    return res;
  }

  if (sensitivity === 'restricted' || sensitivity === 'confidential') {
    res.headers.set('Cache-Control', 'private, no-store');
    res.headers.set('CDN-Cache-Control', 'no-store');
    // Strip from edge logs and disable tracing for PII
    res.headers.set('X-Log-Body', 'disabled');
  }
  
  return res;
}

From Labels to Real Handling Policies at the Edge

A label without a policy is just metadata. What matters is what the CDN *does* when it sees restricted versus public. In my experience, the best handling policies cover four behaviors explicitly: caching, logging, transformation, and purge. If you leave any of those implicit, someone will eventually make an assumption that exposes you. For example, we had a product team that assumed image optimization wouldn't touch confidential avatars — until they realized the optimization service was copying those images to a secondary bucket for processing without encryption.

So we codify everything. Public assets get aggressive caching, full access logging, and can be run through any optimization pipeline. Confidential data gets private, no-store, gets excluded from detailed access logs (we keep only anonymized request counts), and bypasses any third-party optimization. It also enforces stricter TLS and requires token authentication at the edge. This isn't just paranoia; it's how you meet CDN data security commitments when auditors ask you to prove that an edge node in Singapore isn't retaining EU customer data longer than it should. Good handling policies also need to account for CDN Tenant Isolation Patterns: Cache Separation, Rate Limits, and Access Controls, because cache keys, rate limit counters, and even error pages must be scoped per tenant when sensitivity differs.

Don't forget your delivery branding either. When we configure White-Label CDN Delivery: Custom Branding for SaaS Customer Domains, each custom domain inherits the handling policies of the underlying tenant, but we validate that inheritance at deploy time. A misconfigured CNAME that points a restricted tenant's domain at a public tenant's edge behavior is exactly how data ends up where it shouldn't. For cache semantics, we strictly follow the guidance in the HTTP Working Group Specifications and caching best practices documented on web.dev Articles — especially around private vs. no-store — because browsers and intermediate caches will interpret your mistakes literally.

# Handling policy map enforced at edge (YAML deployed via CDN config API)
policies:
  public:
    cdn_cache_control: "public, max-age=31536000, immutable"
    browser_cache_control: "public, max-age=86400"
    cache_key_includes: ["url", "accept-encoding"]
    log_detail: "full_url_and_headers"
    allow_optimization: true
    require_auth: false

  confidential:
    cdn_cache_control: "private, no-store"
    browser_cache_control: "private, no-store, max-age=0"
    cache_key_includes: ["url", "tenant_id", "auth_token_hash"]
    log_detail: "anonymized_counts_only"
    allow_optimization: false
    require_auth: true
    tls_min_version: "1.2"
    purge_on_logout: true

  restricted: # e.g., PII, PHI, financial data
    cdn_cache_control: "private, no-store, no-cache"
    browser_cache_control: "private, no-store, no-cache, must-revalidate"
    cache_key_includes: ["url", "tenant_id", "user_id", "auth_token_hash"]
    log_detail: "no_logging"
    allow_optimization: false
    require_auth: true
    tls_min_version: "1.3"
    edge_persistence: "memory_only_no_disk"

Translating Sensitivity Tiers Into Edge Controls That Auditors Understand

When you sit down with a security or compliance team, they don't want to hear about VCL snippets — they want to see a matrix that shows what happens to each class of data at the edge. We learned to present it as a simple table that ties business language (what the data is) to technical controls (what the CDN will and won't do). That table becomes the contract between product, security, and platform engineering, and it's what we use to generate our automated tests before any edge config ships.

The numbers below reflect what we've standardized for our enterprise SaaS customers over the last three years. They aren't theoretical; they're pulled from production configs that have survived SOC 2 Type II and customer pen tests. The key insight is that as sensitivity increases, you trade hit ratio and cost savings for safety. A public tier might give you a 95% cache hit ratio and sub-30ms delivery, while a restricted tier is intentionally 0% cached — every request goes back to origin for auth and audit, and we accept the latency hit. We've found that being explicit about that trade-off upfront prevents product managers from later asking why their sensitive dashboards aren't "fast enough."

Sensitivity Tier Edge Caching Behavior TTL / Persistence Logging & Security Controls Typical Content Example
Public Shared cache, globally distributed 1 year (immutable), disk + memory Full access logs, TLS 1.2+, cache hit ratio ~94-97% Marketing images, public JS/CSS, docs
Internal Shared cache, keyed by tenant 1 hour - 24 hours, memory + disk Anonymized IP logs, TLS 1.2+, hit ratio ~70-80% Help center articles, tenant logos, feature flags
Confidential Private cache only (no shared), bypass for auth'd users 0 (no-store) at edge, private browser cache 5 min max Minimal logs (status + bytes only), mTLS optional, WAF enabled Account dashboards, invoices, non-PII reports
Restricted No edge cache (pass-through) no-store, memory-only, no disk write No body/header logging, TLS 1.3 required, token binding PII/PHI, auth tokens, financial records

From Config to Code: Implementing Classification at the Edge

Here's where most teams stall — they've got a beautiful classification matrix in Notion, but nothing enforces it when a request actually hits PoP. You can't rely on manual tagging. We've seen it fail every time. What works is making classification deterministic and automatic at ingress. The origin should emit a signal, and the edge should enforce without thinking. Typically that's a combination of a response header like X-Content-Class: restricted | confidential | public plus path and content-type heuristics as a fallback. If the header's missing, you don't default to public — you default to confidential and you alert. That one inversion has saved us from more incidents than any WAF rule ever has.

Once that signal's trusted, your edge config becomes a simple policy router. Public assets get aggressive caching (think max-age=86400, stale-while-revalidate=604800), shared cache keys, and full image optimization. Restricted content gets private, no-store or at most a 60-120 second short TTL in private cache only, with Vary: Authorization, Cookie and cache key salted by user ID or JWT sub. Confidential — think PII, pre-release financials, health data — doesn't touch cache at all. It gets tunneled, mTLS back to origin, and full request/response logging is suppressed at the edge. You’ll also want to tie this to your Custom Domain CDN for SaaS: Automated SSL and DNS for Customer Vanity Domains setup, because vanity domains often carry different sensitivity tiers per customer and you can't apply a one-size-fits-all rule.

In practice, we manage this as code — Terraform for the CDN provider plus a lightweight Worker/VCL snippet that runs on every request in under 5ms. Don't hardcode the tiers in the Worker; pull them from a KV or Edge Dictionary you can update in < 10 seconds globally without a purge or deploy. We version the policy bundle and can roll back in about 15 seconds. And yes, you need to log the classification decision itself — not the content — so you can audit that a confidential label actually resulted in bypass_cache. Without that, your auditor's just trusting your config.

// Cloudflare Worker / Fastly Compute@Edge pattern (p95 < 8ms)
export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);
    const tier = req.headers.get("x-content-class") 
              || classifyByPath(url.pathname); // fallback

    // Enforce: never cache confidential, short-TTL restricted
    if (tier === "confidential") {
      // No cache, strip logging, require mTLS
      return fetch(req, { cf: { cacheEverything: false, cacheTtl: 0 } });
    }
    if (tier === "restricted") {
      const res = await fetch(req);
      res.headers.set("Cache-Control", "private, max-age=60");
      res.headers.set("Vary", "Authorization, Cookie");
      res.headers.set("CDN-Cache-Control", "no-store"); // don't store on shared TiB
      return res;
    }
    // public: aggressive edge cache + Tiered Cache
    return fetch(req, { cf: { cacheTtl: 86400, cacheEverything: true } });
  }
}
function classifyByPath(p) {
  if (p.startsWith("/api/private/") || p.includes("/account/")) return "confidential";
  if (p.match(/\.(m3u8|mpd)$/)) return "restricted";
  return "public";
}

When Edge Policies Collide: Advanced Patterns and Troubleshooting

So you've shipped the tiers and everything looks green — until policies overlap. The classic collision is a public image that’s requested with an auth cookie, or a confidential API response that some marketer wants cached for 5 minutes to survive a launch spike. If your logic is just if tier == public then cache, you’ll cache poison private data or leak it across tenants. We solve this with explicit precedence: authentication always wins over path. If Authorization or a session cookie is present, we automatically promote the classification one level up (public -> restricted, restricted -> confidential) unless there's an explicit allowlist with a signed token. It drops our cache hit ratio by maybe 3-4% on authenticated routes, but it’s eliminated the entire class of cross-user cache leaks that used to take 2-3 hours to debug.

The other nightmare is observability. You redacted logs for confidential tier, which is correct, but now you can't tell if that 403 spike is an attack or a broken policy. What we do is emit two log streams at the edge: a full-fidelity stream sampled at 0.1% for public/restricted (with IP, UA, cache status) and a minimal, tokenized stream for confidential that only logs request_id, tier, policy_action, edge_colo, latency_ms — no URL params, no headers, no body. Then we reconstruct usage via edge metering instead of raw logs. That’s where API Metering at CDN Edge: Real-Time Usage Tracking for SaaS Billing becomes critical — you can track per-customer request counts and error rates without ever storing the sensitive payload. If you see a confidential request getting a HIT in your analytics, you know immediately your no-store rule isn't firing.

When something does break — and it will — check these three things first. One, verify the origin is actually sending the classification header consistently; we’ve seen 12% of origins drop headers on 304s and 5xx responses, which causes the edge to misclassify. Two, purge your edge dictionary/KV version after any policy change and confirm propagation; we've measured 8-14 seconds globally, but stale nodes can hold old rules for up to 90 seconds. Three, audit your cache keys. Run curl -I with two different JWTs against the same restricted URL; if you get X-Cache: HIT on the second, your key isn't salted. Pair this with CDN Usage Tracking for SaaS: Per-Customer Bandwidth and Request Analytics to catch silent over-caching — if one tenant's confidential bandwidth suddenly shows up as cached egress, your tiering logic has a hole and you’ll spot it in minutes, not months.

Frequently Asked Questions

Should we classify at the origin or at the edge?

Both, but authority lives at the origin. The origin knows the data's sensitivity and should send an explicit header like X-Content-Class on every response, including errors. The edge enforces and applies the fallback heuristic if the header is missing — and we strongly recommend defaulting missing labels to confidential (no-cache, no-log) rather than public.

How do cache settings differ by sensitivity tier?

Public: cache for 1-7 days with Tiered Cache and stale-while-revalidate. Restricted: private cache only, 60-120s TTL, keyed by user/token, with Vary: Authorization. Confidential: no-store, private — bypass edge cache entirely, enforce mTLS to origin, and suppress body/param logging. Don't try to shave latency by caching confidential; the breach risk isn't worth the 30-45ms you save.

How do we audit edge handling without logging sensitive content?

Log the decision, not the data. Emit tier, policy_action (HIT/MISS/BYPASS), cache_key_hash, colo, latency for every request, even when you redact the URL and headers. Sample full logs at 0.1% for public/restricted only. Then reconcile daily against your edge metering — if a confidential request was ever served from cache, that’s an automatic alert.

What’s the biggest mistake teams make with multi-tenant SaaS content?

Using a shared cache key across tenants. For SaaS with vanity domains, you must include tenant ID and content tier in the cache key. We've seen hit-ratio-optimized configs serve Tenant A's restricted dashboard API to Tenant B in under 2 seconds. Salt your keys by host + tier + user_sub and test with two authenticated identities after every policy deploy.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker