Overview
On March 30th, 2026, Railway experienced an incident where a CDN configuration update accidentally enabled caching for domains that had not enabled CDN. For 52 minutes, HTTP GET responses were incorrectly cached across ~0.05% of Railway domains with CDN disabled. Railway says cached responses may have been served to users other than the original requester, including authenticated responses without Set-Cookie.
TLDR: A configuration change to Railway’s CDN provider accidentally enabled caching on domains that had not enabled CDN. Authenticated responses (without Set-Cookie headers) were cached at the edge and may have been served to other users. Cache-Control directives were respected where set, but most GET responses without explicit cache headers were cached by default.
Timeline of Events
Mar 30, 10:42 UTC - CDN configuration update deployed
Accidentally enables caching for CDN-disabled domains
↓
Mar 30, 10:42-11:34 UTC - 52-minute window
Authenticated GET responses cached and served
to wrong users across ~0.05% of domains
↓
Mar 30, 11:34 UTC - Issue identified and change reverted
All cached assets purged globally
How Railway’s CDN Works
Railway offers CDN caching as an opt-in feature. When enabled, your application’s responses are cached at edge servers around the world for faster delivery. When disabled, requests route directly to your application with no caching layer in between.
┌─────────────────────────────────────────────────────────────┐
│ CDN Enabled (Opt-In) │
│ │
│ User Request │
│ │ │
│ ▼ │
│ ┌──────────┐ Cache ┌──────────────┐ │
│ │ CDN │────HIT──────▶│ Serve from │ │
│ │ Edge │ │ edge cache │ │
│ │ Server │ └──────────────┘ │
│ │ │ │
│ │ │ Cache ┌──────────────┐ │
│ │ │────MISS─────▶│ Forward to │ │
│ └──────────┘ │ origin app │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CDN Disabled (Default) │
│ │
│ User Request │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Direct passthrough to origin app │ │
│ │ No caching, no edge servers involved │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
The distinction matters. There are plenty of reasons to keep CDN off:
- Your application returns user-specific or authenticated data that should never be shared.
- Your origin is fast enough and you have no need for edge caching.
- Your responses are highly dynamic and caching would add complexity without meaningful performance gains.
- You want full control over response handling at the application level.
What Went Wrong
The configuration change flipped the caching behavior for domains that should have been in passthrough mode. Here is what the CDN did during the 52-minute window:
┌─────────────────────────────────────────────────────────────────┐
│ During the Incident (10:42 - 11:34 UTC) │
│ │
│ User A: GET /api/dashboard │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ CDN │ 1. Cache MISS (first request) │
│ │ Edge │ 2. Forwards to origin app │
│ │ Server │ 3. Receives response with User A's data │
│ │ │ 4. CACHES the response <-- Should not happen! │
│ └──────────┘ │
│ │
│ User B: GET /api/dashboard (same URL, seconds later) │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ CDN │ 1. Cache HIT │
│ │ Edge │ 2. Returns User A's cached response │
│ │ Server │ 3. User B sees User A's data <-- Cross-user │
│ │ │ data exposure! │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
The above is an illustrative hypothetical. Railway did not disclose specific endpoints or data types involved, but this pattern shows how cross-user data exposure could occur.
There are two important details from Railway’s report:
Set-Cookie headers were not cached. Railway says Set-Cookie response headers were not cached during the incident. This means session cookies in response headers were not replayed from cache, but response bodies (which could contain user-specific data) may have been.
Cache-Control directives were respected. If your application explicitly set Cache-Control: no-store or Cache-Control: private, the CDN honoured that. The problem was that most GET responses without explicit cache headers were cached by default.
The Real Problem: Cache-Control Defaults
This is the core of the issue. Most web frameworks do not set Cache-Control headers on API responses by default. Developers rarely think about caching headers on authenticated endpoints because they assume the response will go directly to the requesting client.
// A typical Express API endpoint
// No Cache-Control header set, because why would you?
// The CDN is supposed to be OFF.
app.get("/api/dashboard", authMiddleware, async (req, res) => {
const user = await getUser(req.userId);
res.json({
name: user.name,
email: user.email,
billing: user.billingInfo,
});
});
When the CDN is disabled, this is perfectly fine. The response goes straight from your app to the user. But the moment a CDN is inserted into the path, even accidentally, the lack of Cache-Control headers becomes a vulnerability.
// What Railway's CDN saw for a GET request without Cache-Control:
//
// Response headers:
// Content-Type: application/json
// (no Cache-Control header)
//
// CDN behavior during this incident: "No cache directive? I'll cache it."
During this incident, Railway’s CDN treated GET responses without explicit cache directives as cacheable. The exact default caching behaviour varies by CDN provider and configuration, but the broader point holds: if you are not setting Cache-Control headers, you are leaving the caching decision up to whatever intermediary sits in the request path. Railway’s users never opted into the CDN in the first place, so they had no reason to think about this.
How a Managed CDN Decides What to Cache
Understanding CDN caching decisions helps explain why this went wrong. Below is a simplified model of the decision points that were relevant during this incident. Note that exact behaviour varies by CDN provider and configuration.
Incoming Response from Origin
│
▼
┌─────────────────────────┐
│ Is caching enabled for │──── NO ─────▶ Passthrough (no caching)
│ this domain/route? │
└─────────────────────────┘
│ YES
▼
┌─────────────────────────┐
│ Has Cache-Control: │
│ no-store or private? │──── YES ────▶ Do NOT cache
└─────────────────────────┘
│ NO
▼
┌─────────────────────────┐
│ Has Cache-Control: │
│ max-age or s-maxage? │──── YES ────▶ Cache for specified duration
└─────────────────────────┘
│ NO
▼
┌─────────────────────────┐
│ Has Expires header? │──── YES ────▶ Cache until expiry
└─────────────────────────┘
│ NO
▼
┌─────────────────────────┐
│ Is it a GET request? │──── YES ────▶ Apply default caching <-- HERE
└─────────────────────────┘
│ NO
▼
Do NOT cache
Note: Per RFC 9111 §3.5, a spec-compliant shared cache must not reuse a response to a request containing an
Authorizationheader unless the response explicitly allows it (e.g. viapublic,s-maxage, ormust-revalidate). However, managed CDN products do not always follow this strictly, and Railway’s CDN cached authenticated responses during this incident.
That bottom path, “apply default caching”, is what caught Railway’s users. Their applications were not setting cache headers because they were never meant to go through a CDN. When the CDN was accidentally enabled, the responses fell through to the default caching behaviour.
What Could Have Prevented This
1. Defence in Depth: Always Set Cache-Control Headers
Even if you think your responses will never be cached, set Cache-Control headers on authenticated endpoints. This is defence in depth. You are not just relying on infrastructure configuration being correct, you are telling any intermediary in the request path how to handle your response.
// Middleware that sets Cache-Control on all authenticated routes
function noCacheMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
res.set("Cache-Control", "no-store");
next();
}
// Apply to all authenticated routes
app.use("/api", authMiddleware, noCacheMiddleware);
// Or per-route
app.get("/api/dashboard", authMiddleware, noCacheMiddleware, async (req, res) => {
const user = await getUser(req.userId);
res.json({
name: user.name,
email: user.email,
});
});
The key directives to know:
no-store: Do not store this response in any cache, ever. This is the strongest directive and is sufficient on its own for sensitive authenticated responses.private: Only the end user’s browser may cache this. No shared caches (CDNs, proxies). Useful if you want browser caching but not CDN caching.no-cache: You may store it, but you must revalidate with the origin before serving.
For sensitive authenticated API responses, Cache-Control: no-store is the simplest and most effective choice. If you want to allow browser caching but prevent shared/CDN caching, use Cache-Control: private, no-cache.
2. Understanding Cache Keys
A cache key is how a CDN decides whether it has already seen a request before. By default, most CDNs construct the cache key from the HTTP method and the URL, nothing else.
Default Cache Key Construction
──────────────────────────────
Request from User A: GET /api/dashboard
Cache key: GET:/api/dashboard
Request from User B: GET /api/dashboard
Cache key: GET:/api/dashboard <-- Same key, same cached response
The CDN does not look at:
- Authorization header
- Cookie header
- Any other request header
(unless explicitly told to via Vary or CDN-specific config)
This is exactly why Railway’s incident leaked data. The CDN saw GET /api/dashboard from User A, cached the response, and then served that same cached response to User B because the cache key was identical. The CDN had no way to know these were different users, because nothing in the cache key distinguished them.
You can influence cache keys in two ways: through the Vary response header (standard HTTP), or through CDN-specific configuration (e.g. Cloudflare’s Cache Key settings, CloudFront’s cache policies).
3. Vary Header for Cacheable Variants
The Vary header tells caches to include additional request headers in the cache key. This is useful when you intentionally want to cache responses that differ per some request attribute (e.g. Accept-Language, Accept-Encoding).
// Example: intentionally caching public content that varies by language
app.get("/api/articles", async (req, res) => {
res.set("Vary", "Accept-Language");
res.set("Cache-Control", "public, max-age=3600");
const articles = await getArticles(req.headers["accept-language"]);
res.json(articles);
});
With Vary: Accept-Language, the cache key changes from just the URL to the URL plus the language header. Users requesting different languages get separate cache entries.
Important: Vary is not the right tool for protecting personalized authenticated responses. For those, Cache-Control: no-store is the correct approach. Do not rely on Vary: Authorization or Vary: Cookie as a security mechanism. These headers can explode cache cardinality, behave inconsistently across CDN providers, and do not prevent caching, they just partition it. If a response should never be shared between users, prevent caching entirely.
4. CDN-Level Safeguards
From a platform perspective (which is Railway’s responsibility), there are architectural safeguards that can prevent this class of issue:
┌─────────────────────────────────────────────────────────────────┐
│ CDN Configuration Safeguards │
│ │
│ 1. Separate CDN Configs │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ CDN-Enabled │ │ CDN-Disabled │ │
│ │ Config Pool │ │ Config Pool │ │
│ │ │ │ │ │
│ │ cache: true │ │ cache: false │ │
│ │ edge: active │ │ passthrough │ │
│ └──────────────┘ └──────────────┘ │
│ Changes to one pool cannot affect the other │
│ │
│ 2. Canary Rollouts │
│ Deploy to 1% ──▶ Monitor ──▶ 10% ──▶ Monitor ──▶ 100% │
│ (hours, not minutes) │
│ │
│ 3. Automated Cache Behaviour Tests │
│ Before every config change: │
│ - Verify CDN-disabled domains return no cache headers │
│ - Verify CDN-enabled domains cache correctly │
│ - Verify Set-Cookie responses are never cached │
│ │
└─────────────────────────────────────────────────────────────────┘
Railway’s post-mortem mentions they have already rolled out additional tests for correct/incorrect caching behaviours, and moved to aggressive sharding of CDN rollouts over hours instead of minutes. Both of these directly address the problem. Railway also said users with affected domains would be notified by email.
Note: Railway’s report does not disclose the CDN provider, exact cache key construction, TTLs, authentication mechanisms involved, or the number of incorrect responses served. The analysis sections above are general HTTP caching guidance rather than Railway-confirmed implementation details.
5. Framework-Level Defaults
If you are building a web framework or a platform, consider making safe cache defaults the norm rather than the exception:
// A framework that defaults to no-cache for authenticated responses
class SecureRouter {
authenticatedRoute(
path: string,
handler: RequestHandler
) {
return this.router.get(path, (req, res, next) => {
// Always set safe defaults for authenticated routes
if (!res.getHeader("Cache-Control")) {
res.set("Cache-Control", "no-store, private");
}
handler(req, res, next);
});
}
}
This is the principle of secure defaults. Developers should have to opt in to caching on authenticated routes, not opt out.
Lessons Learned
CDN configuration is a trust boundary. Railway called this out explicitly in their report. Domains with CDN disabled should never have content cached. When a single configuration change can cross that boundary, you need both infrastructure-level and application-level defences.
Defaults matter more than you think. Railway’s CDN cached GET responses by default when no Cache-Control header was present. Most web frameworks do not set Cache-Control on API responses by default. This gap between CDN behaviour and framework defaults is where incidents like this live.
Defence in depth is not optional. Setting Cache-Control: no-store on authenticated endpoints costs nothing and protects against an entire class of infrastructure misconfiguration. It is the WHERE 1=1 of caching, a safety net you hope you never need.
Slow rollouts save incidents. Railway has since moved to sharding CDN rollouts over hours instead of minutes. If this change had rolled out to 1% of domains first with monitoring, the 52-minute exposure window would have been much shorter.
This pattern will keep repeating. Any platform that sits behind a CDN is one misconfiguration away from the same issue. The fix is not just better infrastructure processes, it is application-level awareness that your response might be cached by something you did not put there.