Bypass CDN Edge Cache for Authenticated API Routes and User Cookies

by Fahim

A user logs into your app, visits /api/v1/user/me, and sees another customer’s name, email, and billing details. I hit this exact disaster during a launch when our edge CDN cached a JSON response missing explicit private headers. It took thirty seconds of traffic to cross-contaminate sessions across hundreds of users.

Preventing this requires origin headers, Nginx-level cookie detection, and edge bypass rules so private authenticated payloads stay completely uncached while your static assets maintain high cache hit ratios.

Terminal screen showing HTTP response headers with cache bypass configuration
Terminal screen showing HTTP response headers with cache bypass configuration

Why Edge Servers Cache Authenticated Requests (and How Leaks Happen)

By default, intermediate reverse proxies and CDN points of presence (POPs) look at the request URL path and HTTP status codes to decide if a response is cacheable. If your backend returns 200 OK for an API request or user dashboard without explicit cache headers, many CDNs fall back to their default edge TTLs—often 2 hours or more.

Per the RFC 9111 HTTP Caching specification, an edge cache shouldn’t use a stored response for a request with an Authorization header unless the response explicitly sets directives like public or s-maxage. But most web apps authenticate using session cookies (like session_id, connect.sid, or wordpress_logged_in_*) instead of standard Authorization headers. Edge networks treat these requests as plain public traffic unless you write explicit bypass rules or send strict origin headers.

If you configured rules for static assets like in our guide on CDN edge cache rules for dynamic URLs, blindly applying those caching rules to REST or GraphQL endpoints without inspecting cookies will leak private data immediately.

Origin Response Headers That Force Edge Cache Bypasses

Your origin server is your primary line of defense. If your backend instructs the edge proxy never to store or reuse a payload, the edge must fetch a fresh response from origin on every single call.

For any route reading an auth token or session cookie, return these HTTP headers from your origin:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
Vary: Authorization, Cookie, Accept-Encoding

Here is what each directive actually does across proxies:

  • private: Tells shared caches (CDNs and corporate proxies) not to store the response. Only the end-user’s local browser can hold it.
  • no-store: Stops both browsers and edge nodes from saving the payload to disk or memory.
  • no-cache: Forces the client to revalidate with the origin before serving from local storage.
  • Vary: Authorization, Cookie: Tells edge caches that if they attempt to store any non-private variations of this URL, they must key the cache entry against the exact auth token and cookie header. You can read more about edge behavior in the MDN Cache-Control documentation.

Configure Nginx to Detect Auth Cookies Dynamically

Don’t rely on every backend developer remembering to set headers on every single route. If Nginx sits in front of Node.js, PHP-FPM, or Python, intercept authenticated requests directly at the proxy layer with a map block.

Open your Nginx configuration (typically in /etc/nginx/sites-available/app.conf):

sudo nano /etc/nginx/sites-available/app.conf

Add this mapping outside your server block to evaluate cookies and auth headers, then apply the right Cache-Control header inside your location blocks:

# Map to determine if request is authenticated
map $http_cookie $auth_cookie_present { default 0; "~*(session_id|jwt_token|wordpress_logged_in|auth_token)" 1;
} map $http_authorization $auth_header_present { default 0; "~*.+" 1;
} server { listen 80; server_name api.yourdomain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Enforce no-cache if auth cookie or auth header is detected if ($auth_cookie_present) { add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always; add_header Pragma "no-cache" always; add_header X-Edge-Cache-Policy "Bypass-Auth-Cookie" always; } if ($auth_header_present) { add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always; add_header Pragma "no-cache" always; add_header X-Edge-Cache-Policy "Bypass-Auth-Header" always; } }
}

Test the syntax and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

If you’re caching unauthenticated public traffic on the same endpoints, check our walkthrough on how to configure stale-while-revalidate on Nginx to keep your caching layers from fighting each other.

Node.js and Express Middleware for Dynamic API Route Bypassing

In Node.js backends using Express or Fastify, a global middleware ensures protected routes never emit cacheable flags. Here’s an Express middleware that checks for JWT bearer tokens and session cookies:

// middleware/nocache.js
function bypassEdgeCacheForAuth(req, res, next) { const hasAuthHeader = Boolean(req.headers.authorization); const hasSessionCookie = Boolean( req.cookies && (req.cookies.session_id || req.cookies.jwt_token || req.cookies.token) ); // Check if route falls under protected path or has credentials if (hasAuthHeader || hasSessionCookie || req.path.startsWith('/api/v1/auth') || req.path.startsWith('/api/v1/user')) { res.setHeader('Cache-Control', 'private, no-cache, no-store, max-age=0, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.setHeader('Vary', 'Authorization, Cookie, Accept-Encoding'); res.setHeader('X-Cache-Status', 'BYPASS-EXPLICIT-AUTH'); } else { // Public read-only endpoint cache policy res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=60'); } next();
}
module.exports = bypassEdgeCacheForAuth;

Mount this early in your middleware pipeline before defining routes:

const express = require('express');
const cookieParser = require('cookie-parser');
const bypassEdgeCacheForAuth = require('./middleware/nocache'); const app = express(); app.use(cookieParser());
app.use(bypassEdgeCacheForAuth); app.get('/api/v1/user/me', (req, res) => { res.json({ id: req.user?.id || 'anonymous', email: req.user?.email || null, status: 'active' });
}); app.listen(3000, () => { console.log('App server running on port 3000');
});

Edge CDN Rules: Inspecting Cookies and Headers at the Edge

Origin headers tell the CDN what to do after it reaches your server. But you can cut origin load and latency by configuring the CDN to bypass its cache engine before even checking the edge store whenever an auth credential is present.

In your CDN’s dashboard (Cloudflare Cache Rules, Fastly VCL, or StackPath):

  1. Rule Trigger: URI Path matches regex ^/(api|dashboard|account)/.*
  2. Cookie Inspection (OR): Cookie matches regex (session_id|jwt|auth_token|wordpress_logged_in)
  3. Header Inspection (OR): Header "Authorization" is present
  4. Action: Set Cache Level to Bypass or Edge Cache TTL to 0 / Bypass.

If you use Cloudflare Workers or edge compute functions, enforce this at the POP directly:

export default { async fetch(request, env, ctx) { const url = new URL(request.url); const cookieHeader = request.headers.get('Cookie') || ''; const authHeader = request.headers.get('Authorization'); const isAuthenticated = authHeader !== null || cookieHeader.includes('session_id=') || cookieHeader.includes('jwt_token='); // If authenticated or targeting private paths, bypass edge cache completely if (isAuthenticated || url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/')) { const modifiedRequest = new Request(request, { cf: { cacheEverything: false, cacheTtl: 0 } }); return fetch(modifiedRequest); } // Public traffic proceeds with standard edge cache return fetch(request); }
};

If you’re running marketing campaigns alongside authenticated endpoints, make sure to strip marketing query strings at the CDN edge so UTM tracking parameters don’t break your edge bypass logic.

The Vary Header: Prevent Cache Pollution Without Crushing Hit Rates

The Vary header tells downstream proxies that the response depends on specific request headers. Setting Vary: Cookie is safe when paired with Cache-Control: private, no-cache.

However, never put Vary: Cookie on public, cacheable content (like articles or product pages) if you use third-party analytics (like Google Analytics _ga or Meta pixel cookies). Every visitor has unique cookie values. If an edge cache tries to vary public responses on those cookies, your cache hit ratio drops to nearly zero because no two visitors share the same cookie string.

Stick to this setup:

  • Private / Authenticated: Cache-Control: private, no-store and Vary: Authorization, Cookie.
  • Public / Cacheable: Cache-Control: public, max-age=3600 and Vary: Accept-Encoding. Never emit Vary: Cookie here.

Verifying Cache Misses with cURL and Browser DevTools

Don’t verify edge caching through browser refreshes alone—local disk caches will fool you. Use curl to inspect raw response headers directly.

First, test an unauthenticated request to see the public baseline:

curl -I https://api.yourdomain.com/api/v1/status

Next, send a request with a session cookie:

curl -I -H "Cookie: session_id=test_user_session_982341" https://api.yourdomain.com/api/v1/user/me

Then test with a Bearer token in the Authorization header:

curl -I -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." https://api.yourdomain.com/api/v1/user/me

Inspect the returned headers in your terminal:

HTTP/2 200
server: nginx/1.24.0
date: Wed, 26 Feb 2025 14:15:22 GMT
content-type: application/json; charset=utf-8
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
pragma: no-cache
expires: 0
vary: Authorization, Cookie, Accept-Encoding
x-edge-cache-policy: Bypass-Auth-Cookie
cf-cache-status: BYPASS
age: 0

Look for these indicators:

  • CF-Cache-Status (or X-Cache): Shows BYPASS, DYNAMIC, or MISS. You should never see HIT on authenticated requests.
  • Age: Stays at 0 across repeated calls.
  • Cache-Control: Contains private, no-store.

The Accidental Set-Cookie Edge Cache Gotcha

A classic production trap happens when an endpoint sets a session cookie (like during login or cart creation) via Set-Cookie, but returns Cache-Control: public.

If your CDN caches that response, it caches the Set-Cookie header too. Every subsequent user who hits that URL will receive the same session cookie, instantly taking over that initial user’s account.

Prevent this by stripping Set-Cookie headers from cached responses in Nginx or guaranteeing your backend never emits public cache headers alongside Set-Cookie:

# Nginx safety directive inside location block
# Hide Set-Cookie if the upstream server accidentally declared the asset cacheable
location /static/ { proxy_pass http://backend_upstream; proxy_ignore_headers Set-Cookie; proxy_hide_header Set-Cookie; add_header Cache-Control "public, max-age=86400";
}

If you run into redirect loops while testing headers through your edge proxy, take a look at our guide on fixing ERR_TOO_MANY_REDIRECTS on Nginx behind a CDN.

Frequently Asked Questions

Does Cache-Control: private stop browser caching?

No. private tells shared CDNs and proxy servers not to cache the response, but it allows the user’s browser to cache it locally. If you want neither the CDN nor the local browser to store the payload, use Cache-Control: private, no-store, max-age=0.

Why is my CDN still returning HIT on requests with an Authorization header?

Your backend origin is likely sending Cache-Control: public, max-age=... or s-maxage=.... Under HTTP specifications, an explicit public directive overrides the standard rule that disables caching for authenticated requests. Check your origin routes to make sure protected endpoints never send public directives.

Can I safely cache authenticated responses per user at the edge?

You can technically key responses by user (like hash(url + session_token) in an edge worker), but it’s risky in practice. A minor bug in token validation or cache invalidation will leak stale or sensitive data to other users. Dynamic user data should bypass the CDN to origin, while static layouts and assets handle edge caching.

What is the difference between no-cache and no-store?

no-cache means the client or proxy can store the response, but must revalidate with the origin before using it (via ETag or Last-Modified). no-store forbids saving the response anywhere on disk or RAM. For sensitive API endpoints, set both: no-cache, no-store.

Once your dynamic endpoints bypass the cache cleanly, make sure your SSL certs and reverse proxy headers are dialed in by checking our guide on resolving SSL redirect loops behind edge proxies.

all_in_one_marketing_tool