GET does not automatically mean safe to cache. GET has safe semantics, but that does not mean its response is reusable across users or requests. The response can still depend on the current user, tenant, role, subscription, feature flag, locale, authorization policy, or cookie state.
Cache a GET response only when you can explain exactly what makes two responses equivalent. A public catalog page might vary by route, query string, language, and currency. A GET /api/me/orders response varies by the authenticated user and probably by permissions. Treat those as different caching problems.
In ASP.NET Core, keep shared server-side output caching for public data explicit:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("PublicCatalog", policy => policy
.Expire(TimeSpan.FromMinutes(2))
.SetVaryByQuery("category", "page"));
});
app.UseOutputCache();
app.MapGet("/api/catalog", async (
string? category,
int page,
CatalogDb db,
CancellationToken ct) =>
{
return await db.GetPublicCatalogPageAsync(category, page, ct);
})
.CacheOutput("PublicCatalog");
That policy says something concrete: the response contains public data, is cached briefly on the server, and is separated by the query values that affect the payload.
Be much more careful with authenticated responses. Do not put a shared HTTP cache in front of an endpoint just because it is a GET:
app.MapGet("/api/me/orders", async (
ClaimsPrincipal user,
OrdersDb db,
CancellationToken ct) =>
{
var userId = user.RequireUserId();
return await db.GetOrdersForUserAsync(userId, ct);
})
.RequireAuthorization();
ASP.NET Core’s default output-cache policy does not cache authenticated requests, and that is a good default. If you intentionally override it, a simple vary setting is not enough. The custom output-cache policy must fail closed, enable lookup and storage only for the intended requests, limit methods, reject unsuitable status codes, reject responses that set cookies, and build a complete key from trusted application context.
Output caching must also run after authentication and authorization so that the policy sees the resolved principal and authorization context:
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache();
That key may need more than a user ID. Depending on the system, it may need tenant, issuer, canonical internal user ID, role or entitlement version, subscription, impersonation context, and relevant query values. If any of those change the answer, they are part of the cache boundary.
Cookies and Authorization headers are warning signs. They usually mean the response is not public. Do not make authenticated responses publicly cacheable or place them in a shared HTTP cache such as a CDN or reverse-proxy cache unless its HTTP caching configuration explicitly enforces the authorization boundary. Browser caches are private caches, so decide separately whether the client should retain the response. For ASP.NET Core server-side output caching, use a deliberate internal cache key derived from trusted application context.
For most user-specific data, prefer application-level caching around the expensive backend lookup instead of HTTP-level response caching. A HybridCache key can be built after authentication and authorization have resolved the real application context:
builder.Services.AddHybridCache();
app.MapGet("/api/me/orders", async (
ClaimsPrincipal user,
HybridCache cache,
OrdersDb db,
CancellationToken ct) =>
{
var userId = user.RequireUserId();
var tenantId = user.RequireTenantId();
return await cache.GetOrCreateAsync(
$"orders:{tenantId}:{userId}",
async cancel => await db.GetOrdersForUserAsync(
tenantId,
userId,
cancel),
cancellationToken: ct);
})
.RequireAuthorization();
Use output caching for public or explicitly varied server-side responses. Use application-level caching when the expensive part is a backend lookup and the cache key can be built from trusted application context. If you cannot write the cache key down confidently, do not cache the response yet.