Cache Poisoning in Rails with Api Keys
Cache Poisoning in Rails with Api Keys — how this specific combination creates or exposes the vulnerability
Cache poisoning occurs when an attacker causes a cache to store malicious content, leading to other users receiving that content. In Rails applications that use API keys for authorization, a common misconfiguration is to include sensitive headers such as Authorization or custom API key headers in the cache key. If these keys are treated as part of the public cache namespace or are not excluded properly, an authenticated request from one user can be cached and then served to another user, effectively leaking one user’s data to another.
Consider a Rails controller that caches responses based on path and an API key header:
class Api::V1::ProfilesController < ApplicationController
before_action :authenticate_with_api_key
def show
fresh_when(etag: current_user, last_modified: current_user.updated_at, public: true, shared_max_age: 300)
render json: current_user.profile
end
private
def authenticate_with_api_key
api_key = request.headers['X-API-KEY']
@current_user = User.find_by(api_key: api_key)
head :unauthorized unless @current_user
end
end
If the HTTP cache (e.g., a CDN or reverse proxy) uses the full request URL plus headers such as X-API-KEY to form the cache key, and if that key is inadvertently exposed or predictable, an attacker who knows or guesses another user’s API key could retrieve cached responses intended for that user. Moreover, if the cache is shared across users and the API key is included in the cache key but the key is weak or leaked (for example, via logs or referrer headers), the vulnerability becomes easier to exploit. This pattern is problematic when the caching layer is configured to cache GET responses that are user-specific but are marked as public or improperly scoped.
Another scenario involves query parameters that contain API keys or tokens. For example:
GET /api/v1/data?api_key=abc123
If Rails caches the full URL including the api_key query parameter without stripping sensitive values, the cached entry can be reused in other contexts, or the key itself may be exposed in logs or intermediary caches. Sensitive API keys should never be used as part of cacheable identifiers, and Rails cache stores should not be used to store responses that contain user-specific authorization tokens without strict scope isolation.
The LLM/AI Security checks in middleBrick specifically test for system prompt leakage and other AI-specific issues, but cache poisoning related to API keys is a classic input validation and authorization boundary concern. Properly separating authentication from caching and ensuring that sensitive headers and parameters are excluded from cache keys mitigates the risk of one user’s cached response being served to another.
Api Keys-Specific Remediation in Rails — concrete code fixes
To remediate cache poisoning risks when using API keys in Rails, ensure that sensitive headers and parameters are excluded from cache keys and that user-specific data is never cached in a shared namespace. Below are concrete, safe patterns.
1. Exclude sensitive headers from cache keys
Configure Rails cache stores and HTTP caching to ignore authentication-related headers. If you use a CDN or reverse proxy that respects Rails cache directives, avoid including sensitive headers in the cache key. One approach is to normalize the request before caching by removing or hashing sensitive headers.
2. Use user-specific cache keys without exposing secrets
Instead of including raw API keys, derive a cache key from the user’s ID and a stable timestamp. This ensures that each user has a distinct cache entry without placing secrets into the cache namespace.
class Api::V1::ProfilesController < ApplicationController
before_action :authenticate_with_api_key
def show
# Use a cache key based on user id and updated_at, not the API key itself
cache_key = "user_profile/#{current_user.id}-#{current_user.updated_at.iso8601}"
cached = Rails.cache.read(cache_key)
if cached
render json: cached
else
render json: current_user.profile.tap { |json| Rails.cache.write(cache_key, json, expires_in: 5.minutes) }
end
end
private
def authenticate_with_api_key
api_key = request.headers['X-API-KEY']
@current_user = User.find_by(api_key: api_key)
head :unauthorized unless @current_user
end
end
3. Avoid API keys in query parameters for cacheable endpoints
Prefer using headers for API keys and avoid query parameters for secrets. If you must support query parameters, sanitize them before using in cache lookups.
# config/initializers/cache_sanitizer.rb
module CacheSanitizer
def self.sanitized_path(request)
# Remove sensitive query parameters before using in cache key
uri = URI(request.url)
params = Rack::Utils.parse_query(uri.query)
params.except!('api_key', 'token', 'secret')
new_query = params.to_query unless params.empty?
[uri.path, new_query].compact.join('?')
end
end
Then use this sanitized path as part of the cache key:
class Api::V1::DataController < ApplicationController
def index
safe_path = CacheSanitizer.sanitized_path(request)
cache_key = "shared_data/#{Digest::SHA256.hexdigest(safe_path)}"
result = Rails.cache.fetch(cache_key, expires_in: 10.minutes) do
ExpensiveModel.public_data.to_json
end
render json: result
end
end
4. Use HTTP cache controls to limit sharing
Ensure that user-specific responses are not marked public. Use private: true or appropriate no-store directives when the response contains sensitive authorization information.
class Api::V1::ProfilesController < ApplicationController
def show
if current_user
fresh_when(etag: current_user, last_modified: current_user.updated_at, private: true)
render json: current_user.profile
else
head :unauthorized
end
end
end
By excluding API keys from cache identifiers, normalizing request parameters, and scoping caches to the correct access level, you reduce the risk of cache poisoning while still benefiting from HTTP caching performance.