API Design · Lesson 18

Preventing Sensitive Data from Being Cached

Protect authenticated and personalized API responses from browser and shared-cache leaks with safe Java and Node.js policies and tests.

Quick answer

Classify every response before optimizing it. Credentials, password-reset pages, authorization codes, one-time links, payment secrets, and similarly sensitive material should use:

Cache-Control: no-store
Pragma: no-cache

no-store tells private and shared HTTP caches not to store the response. Pragma: no-cache is only a legacy compatibility signal; Cache-Control is the modern policy source. Neither header erases copies in logs or application storage.

A personalized response acceptable in one user’s private cache, but checked before every reuse, can use:

Cache-Control: private, no-cache

private forbids storage by shared caches. no-cache permits storage but requires successful validation before reuse. It does not mean “do not store.” Default unclassified routes to no-store, make less restrictive profiles explicit, and test the final response through every proxy and CDN.

RFC 9111 obsoletes RFC 7234. RFC 9110 supplies the surrounding HTTP semantics.

Threat model

Prevent one user’s response from reaching another user or remaining on an exposed device. Relevant stores include browsers, gateways, CDNs, service workers, and native apps. Sensitive values can appear in bodies, headers, filenames, redirects, or queries; TLS protects transit, not retention. Classify each representation: a public product and the same product decorated with contract price or permissions are different responses.

Classify responses before choosing headers

Use three reviewable classes:

ClassExamplesDefault policy
Non-storable sensitivetokens, reset flows, secrets, medical or payment detailsno-store plus legacy Pragma: no-cache
Private and revalidateduser preferences or a dashboard safe on that user’s deviceprivate, no-cache plus a user-specific validator
Publicdocumentation or identical catalog dataa separately reviewed public policy

Classification must fail closed. Missing or misspelled profiles and unexpected response types receive the non-storable policy. Never derive a profile from client input or accept inherited JavaScript keys such as constructor. Treat “public” as a security decision requiring evidence that the representation is identical across identities and tenants.

Authorization and shared caches

RFC 9111 normally restricts shared storage of responses to requests containing Authorization, but directives such as public or s-maxage can authorize it, and edge rules can override the origin. A bearer token is therefore insufficient: sensitive responses need private or no-store, no public or s-maxage, and edge bypass. Never key shared objects by the raw token.

Set-Cookie does not inherently prevent caching, and a request Cookie does not make a response private when an intermediary ignores it. Bypass shared caching for session and personalized routes. Use no-store when authentication state changes; use private, no-cache plus a user-specific validator only when local retention is acceptable. Never put raw cookies in shared keys.

private versus no-store

private limits where a response may be stored; it does not forbid browser retention. no-cache requires validation before reuse. no-store prohibits intentional storage by conforming HTTP caches.

Use private, no-cache only after accepting local retention. Scope its validator to the user and representation; a global ETag can validate the wrong body. Use no-store for credentials and one-time flows. These directives are not encryption or deletion guarantees.

Sensitive redirects, errors, and downloads

Apply policy before handlers so it survives early exits. Redirects can expose codes or signed links; errors can disclose accounts or tenants. Protect reset, login, logout, OAuth callback, 4xx, and 5xx paths. Content-Disposition: attachment does not disable caching, so sensitive exports need authorization, no-store, short-lived URLs, and coverage for HEAD, Range, and 206.

Why Vary is not access control

Vary: Authorization or Vary: Cookie affects representation selection. It does not forbid storage, authenticate a user, or prove that every intermediary keys on the field; it also creates high cardinality. Use Vary for legitimate negotiation only after enforcing authorization, private or no-store, and shared-cache bypass.

Java Spring Boot safe defaults

This filter gives /api one fixed, non-storable policy. Ordered.HIGHEST_PRECEDENCE wraps Spring Security short-circuits. Explicit dispatcher types cover request, asynchronous, and error chains.

final class ApiCacheSafetyFilter implements Filter {
  private static boolean isApi(HttpServletRequest request) {
    Object original = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
    String uri = request.getDispatcherType() == DispatcherType.ERROR
        && original instanceof String value ? value : request.getRequestURI();
    String context = request.getContextPath();
    String path = !context.isEmpty() && uri.startsWith(context)
        ? uri.substring(context.length()) : uri;
    return path.equals("/api") || path.startsWith("/api/");
  }

  @Override
  public void doFilter(ServletRequest rawRequest, ServletResponse rawResponse,
      FilterChain chain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) rawRequest;
    HttpServletResponse response = (HttpServletResponse) rawResponse;
    if (!isApi(request)) {
      chain.doFilter(request, response);
      return;
    }

    NoStoreResponse safe = new NoStoreResponse(response);
    try {
      chain.doFilter(request, safe);
    } finally {
      // Handles a synchronous return that the container commits afterward.
      safe.enforce();
    }
  }
}

final class NoStoreResponse extends OnCommittedResponseWrapper {
  NoStoreResponse(HttpServletResponse response) {
    super(response);
  }

  @Override
  protected void onResponseCommitted() {
    enforce();
  }

  // OnCommittedResponseWrapper covers the legacy one-argument overload.
  // Servlet 6.1 added three overloads that must be intercepted explicitly.
  @Override
  public void sendRedirect(String location, boolean clearBuffer)
      throws IOException {
    enforce();
    super.sendRedirect(location, clearBuffer);
  }

  @Override
  public void sendRedirect(String location, int status) throws IOException {
    enforce();
    super.sendRedirect(location, status);
  }

  @Override
  public void sendRedirect(
      String location, int status, boolean clearBuffer) throws IOException {
    enforce();
    super.sendRedirect(location, status, clearBuffer);
  }

  void enforce() {
    if (isCommitted()) return;
    super.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
    super.setHeader(HttpHeaders.PRAGMA, "no-cache");
    // Neutralize common shared-cache-specific origin headers too.
    super.setHeader("CDN-Cache-Control", "no-store");
    super.setHeader("Cloudflare-CDN-Cache-Control", "no-store");
    super.setHeader("Surrogate-Control", "no-store");
  }
}

@Configuration
class CacheSafetyConfiguration {
  @Bean
  FilterRegistrationBean<ApiCacheSafetyFilter> apiCacheSafety() {
    var registration =
        new FilterRegistrationBean<>(new ApiCacheSafetyFilter());
    registration.setUrlPatterns(List.of("/*"));
    registration.setDispatcherTypes(EnumSet.of(
        DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR));
    registration.setAsyncSupported(true);
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
    registration.setMatchAfter(false);
    return registration;
  }
}

This code has a Servlet 6.1 / Spring Framework 7 version floor, normally Spring Boot 4. OnCommittedResponseWrapper covers errors, legacy redirects, flush, and close; the explicit overrides intercept all three Servlet 6.1 redirect overloads. On Servlet 6.0 / Spring Framework 6 (Spring Boot 3), omit those unavailable overloads but retain the inherited legacy redirect interception, commit hooks, and finally enforcement. Error URI and context-path handling preserve /api matching after dispatch to /error and under /shop.

There is no profile lookup or escape hatch. Keep public routes outside /api. For sensitive downloads elsewhere, register the same wrapper. A reviewed private-cache namespace can use a wrapper fixed to private, no-cache; never select it from request data.

Node.js Express safe defaults

Express middleware should run before authentication and routes. The on-headers hook below reasserts one exact allowlisted policy immediately before headers are written, so a downstream public or s-maxage assignment is replaced. Object.hasOwn and a null-prototype profile table prevent inherited-key selection; unknown or missing profiles fail to sensitive.

import onHeaders from "on-headers";

const PROFILES = Object.freeze(Object.assign(Object.create(null), {
  sensitive: Object.freeze({
    cacheControl: "no-store",
    pragma: "no-cache"
  }),
  privateRevalidate: Object.freeze({
    cacheControl: "private, no-cache",
    pragma: null
  })
}));

const SHARED_CACHE_HEADERS = Object.freeze([
  "CDN-Cache-Control",
  "Cloudflare-CDN-Cache-Control",
  "Surrogate-Control"
]);

function resolveProfile(name) {
  return typeof name === "string" && Object.hasOwn(PROFILES, name)
    ? PROFILES[name]
    : PROFILES.sensitive;
}

function applyFinalPolicy(response, profile) {
  response.setHeader("Cache-Control", profile.cacheControl);
  if (profile.pragma === null) response.removeHeader("Pragma");
  else response.setHeader("Pragma", profile.pragma);
  for (const header of SHARED_CACHE_HEADERS) {
    response.setHeader(header, "no-store");
  }
}

export function cacheSafety(req, res, next) {
  // Safe immediately, including authentication failures and error handlers.
  applyFinalPolicy(res, PROFILES.sensitive);

  onHeaders(res, function enforceFinalPolicy() {
    const profile = resolveProfile(res.locals.cacheProfile);
    applyFinalPolicy(this, profile);
  });
  next();
}

export function useCacheProfile(name) {
  return function selectProfile(req, res, next) {
    // The final hook validates name; never index PROFILES here.
    res.locals.cacheProfile = name;
    next();
  };
}

app.use("/api", cacheSafety);
app.get("/api/reset-token", issueResetToken);
app.get(
  "/api/me/preferences",
  requireUser,
  useCacheProfile("privateRevalidate"),
  sendPreferences
);
app.use(apiErrorHandler);

Mount the middleware before anything that can answer. Test the real proxy stack. Public endpoints belong on a separate allowlisted router.

Verify edge behavior with curl

Probe the deployed hostname and, when available, both origin and CDN paths:

curl -sS -D - -o /dev/null \
  -H "Authorization: Bearer test-token" \
  https://api.example.test/api/me/preferences

curl -sS -D - -o /dev/null \
  https://api.example.test/api/reset-token

curl -sS -D - -o /dev/null \
  https://api.example.test/api/missing-route

Expect the exact policy, no public or s-maxage, and no growing Age. Cache-Status or CF-Cache-Status should show bypass. Repeat with two identities; probe redirects, HEAD, Range, authentication failures, and 500.

Integration tests for cache safety

Use an embedded servlet container, not standalone MockMvc. The fixture attempts public, s-maxage=60, exercises error and async dispatches, streams a download, and gives each redirect overload its own route:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
    properties = "server.servlet.context-path=/shop")
class ApiCacheSafetyIT {
  @LocalServerPort int port;
  HttpClient client = HttpClient.newBuilder()
      .followRedirects(HttpClient.Redirect.NEVER).build();

  // Fixture calls: sendRedirect("/next"); sendRedirect("/next", false);
  // sendRedirect("/next", 307); sendRedirect("/next", 308, false).
  @ParameterizedTest
  @ValueSource(strings = {
      "/api", "/api/late-rewrite", "/api/error", "/api/download",
      "/api/async-stream", "/api/security-short-circuit",
      "/api/redirect-default", "/api/redirect-buffer",
      "/api/redirect-status", "/api/redirect-status-buffer"
  })
  void everyApiExitIsNonStorable(String path) throws Exception {
    var request = HttpRequest.newBuilder(
        URI.create("http://localhost:" + port + "/shop" + path)).build();
    var response = client.send(request, BodyHandlers.discarding());
    String policy = response.headers()
        .firstValue("Cache-Control").orElseThrow();

    assertEquals("no-store", policy);
    assertFalse(policy.matches("(?i).*\\b(public|s-maxage)\\b.*"));
    assertEquals("no-cache",
        response.headers().firstValue("Pragma").orElseThrow());
  }
}

Express tests cover downstream overrides, including CDN-specific fields, and inherited keys:

for (const attempted of [undefined, "toString", "constructor"]) {
  test(`profile ${String(attempted)} fails closed`, async () => {
    const app = makeTestApp(attempted);
    const response = await request(app).get("/api/secret");
    expect(response.headers["cache-control"]).toBe("no-store");
    expect(response.headers.pragma).toBe("no-cache");
  });
}

test("forbidden downstream directives are removed", async () => {
  const attemptedOverrides = {
    "Cache-Control": "public, s-maxage=60",
    "CDN-Cache-Control": "public, s-maxage=600",
    "Cloudflare-CDN-Cache-Control": "public, max-age=600",
    "Surrogate-Control": "public, max-age=600"
  };
  // The fixture sets every attempted override after cacheSafety.
  const response = await request(makeUnsafeApp(attemptedOverrides))
    .get("/api/secret");
  expect(response.headers["cache-control"]).toBe("no-store");
  expect(response.headers["cache-control"]).not.toMatch(/public|s-maxage/i);
  expect(response.headers["cdn-cache-control"]).toBe("no-store");
  expect(response.headers["cloudflare-cdn-cache-control"]).toBe("no-store");
  expect(response.headers["surrogate-control"]).toBe("no-store");
});

Run these tests behind the production reverse-proxy configuration as well. Unit success cannot detect a CDN rule that overrides origin headers.

Incident response checklist

  1. Disable shared caching for the affected host or path; do not wait for a code release.
  2. Purge every CDN key and surrogate variant, while remembering that purge does not clear browsers or service workers.
  3. Revoke exposed tokens, sessions, signed URLs, and one-time links.
  4. Preserve sanitized configuration and access evidence without copying sensitive bodies.
  5. Determine affected routes, identities, tenants, regions, time window, statuses, and cache layers.
  6. Deploy a fail-closed origin policy and edge bypass, then verify with two identities.
  7. Notify security, privacy, legal, and customers under the applicable response plan.
  8. Add regression and deployment tests before restoring any public caching.

Common mistakes

  • Assuming HTTPS, authentication, Set-Cookie, or Vary disables storage.
  • Using no-cache when the requirement is “do not store.”
  • Adding public or s-maxage globally for performance.
  • Protecting 200 responses but missing redirects, errors, HEAD, and downloads.
  • Letting a missing profile fall back to framework defaults.
  • Looking up profile names through an inherited JavaScript property.
  • Testing origin headers while a reverse proxy rewrites them.
  • Logging Authorization, cookies, signed URLs, bodies, or cache keys containing secrets.

Edge cases

Cache poisoning and cache deception are distinct. Poisoning stores attacker-influenced content under a reusable key; deception tricks a cache into storing private content under a cacheable-looking route. Defend with strict key inputs, route normalization, origin authorization, sensitive-path bypass, and final-policy tests—without relying on file-like suffixes.

Browser back-forward cache is a page snapshot, not the HTTP cache; recheck authentication on pageshow. A service worker follows application code, so cache only an allowlist of public requests. Proxies may strip credentials or override policy, while logs are another store. Record route templates, classification, final policy, Age, and cache status, but redact credentials and bodies.

Practical checklist

  • Inventory bodies, headers, URLs, redirects, errors, and downloads containing private data.
  • Assign non-storable, private-revalidated, or separately reviewed public classification.
  • Make missing and unknown classifications fail to no-store.
  • Send Pragma: no-cache only as legacy compatibility alongside modern no-store.
  • Bypass Authorization, session cookies, and sensitive paths at every shared layer.
  • Keep public and s-maxage out of sensitive policy modules.
  • Test success, error, redirect, HEAD, range, and streaming behavior.
  • Test missing profiles, misspellings, and inherited JavaScript keys.
  • Verify two identities through the production CDN and reverse proxy.
  • Keep service workers from storing authenticated responses.
  • Monitor final policy, Age, cache status, and cross-tenant isolation without logging secrets.
  • Document purge, revocation, notification, and evidence-preservation procedures.

Frequently asked questions

Should every authenticated response use no-store?

Not necessarily. private, no-cache is reasonable when local retention is acceptable and every reuse is revalidated. Credentials, one-time flows, and high-impact private data should remain no-store.

Is private, max-age=0 equivalent to private, no-cache?

No. no-cache explicitly requires validation before reuse. max-age=0 makes a response immediately stale, but other rules can affect stale reuse. Express the intended validation requirement directly.

Does no-store remove an older cached response?

Do not rely on it as a purge mechanism. Purge controlled shared caches, version or invalidate affected URLs, revoke secrets, and address browser or application stores separately.

Can a CDN cache a response with Authorization?

Only under restricted HTTP conditions or overriding edge configuration. Directives such as public and s-maxage can enable shared storage, which is why sensitive responses need explicit policy and edge bypass.

What should observability record?

Record route templates, safe classification names, status, final cache policy, cache-status headers, Age, region, and request ID. Never record bearer tokens, cookies, signed query strings, or private bodies.

Sources

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. Which policy protects a personalized account response while allowing a browser to retain it only after revalidation?

2. A downstream handler adds CDN-Cache-Control: public after sensitive-response middleware ran; what must final policy enforcement do?