Cache Poisoning in Rails with Jwt Tokens
Cache Poisoning in Rails with Jwt Tokens — how this specific combination creates or exposes the vulnerability
Cache poisoning occurs when an attacker causes a cache to store malicious content and serve it to other users. In Rails applications that use JWT tokens for authentication, a common misconfiguration can lead to cached responses being shared across users with different authorization states. This happens when fragment or page caching is based on a URL that does not include the token or a user-specific claim, so a response that includes sensitive data for an authenticated user may be served to an unauthenticated or lower-privileged user.
For example, if a controller action caches output using caches_action or low-level caching with Rails.cache.fetch keyed only by the request path, the cached entry can be reused. When JWT tokens are validated after cache lookup (or skipped via a before_action condition), a cached response that was generated while a valid JWT was present might later be served to a request without a valid JWT or with a different user’s JWT. This can expose profile information, CSRF tokens embedded in views, or even sensitive headers that were present in the original authenticated response.
Because JWTs are often used to convey roles and scopes, cached content that reflects role-specific UI or data can be served to users who should not see it. If the cache key does not incorporate a per-user or per-token claim such as sub or a hash of the token payload, the boundary between users is broken. In black-box scanning, this manifests as BOLA/IDOR findings or Data Exposure findings when cached endpoints return different representations depending on authorization state but the cache does not differentiate them.
Rails’ default static asset host and reverse proxy caches can also inadvertently cache responses that include authorization-sensitive headers when the JWT is passed in an Authorization header but the cache key excludes it. Because the scanner tests unauthenticated attack surfaces, it may detect endpoints where a cached response leaks data that should be gated by a JWT, highlighting the need to include user context in cache keys and to avoid caching sensitive representations.
Jwt Tokens-Specific Remediation in Rails — concrete code fixes
To remediate cache poisoning risks with JWT tokens in Rails, ensure cache keys incorporate claims that differentiate authorization states and users. Avoid caching responses that contain sensitive data unless the cache key includes a stable, non-sensitive user identifier or token hash. Below are concrete code examples that demonstrate secure patterns.
1. Include a token-derived or user-derived cache key
When using low-level caching, derive part of the cache key from the sub claim or a hash of the JWT payload. Do not rely on request path alone.
# app/controllers/application_controller.rb
before_action :set_cache_context_from_jwt
def set_cache_context_from_jwt
if request.headers['Authorization'].to_s.start_with?('Bearer ')
token = request.headers['Authorization'].split(' ').last
# decode without verification only for cache context; validation still happens in auth check
begin
decoded = JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: 'HS256' })
@current_user_id = decoded.first['sub']
rescue JWT::DecodeError
@current_user_id = nil
end
end
@cache_key_user = @current_user_id || 'public'
end
Then use @cache_key_user in your cache keys:
# app/models/article.rb
class Article < ApplicationRecord
def self.cached_list(user_key)
Rails.cache.fetch("articles/index-v2/#{user_key}", expires_in: 12.hours) do
Article.published.to_a
end
end
end
In the controller:
# app/controllers/articles_controller.rb
def index
@articles = Article.cached_list(@cache_key_user)
respond_with @articles
end
2. Skip caching for authenticated, sensitive endpoints
If an endpoint returns user-specific data, avoid caching entirely when a valid JWT is present, or ensure per-user fragmentation of the cache.
# app/controllers/concerns/caching_controls.rb
module CachingControls
extend ActiveSupport::Concern
def conditional_cache_store(enabled: true)
return unless enabled
request_key = "#{request.path}"
request_key += "/user-#{@cache_key_user}" if @cache_key_user
Rails.cache.fetch(request_key, expires_in: 5.minutes) { yield }
end
end
Use the concern to wrap actions that must remain private:
# app/controllers/profile_controller.rb
include CachingControls
def show
profile_data = conditional_cache_store(enabled: true) do
current_user.as_json(only: [:id, :display_name], methods: [:avatar_url])
end
render json: profile_data
end
3. Validate JWT before cache lookup when feasible
In some architectures, verifying the token before checking the cache prevents using a cached response derived from a different token. This can increase latency but reduces risk of mixing user contexts.
# app/services/jwt_cache_validator.rb
class JwtCacheValidator
def self.user_id_from_token(token)
decoded = JWT.decode(token, Rails.application.secrets.secret_key_base, true, { algorithm: 'HS256' })
decoded.first['sub']
rescue JWT::DecodeError
nil
end
end
Then in the controller, choose whether to include the user identifier in the cache key or bypass cache when tokens differ.
4. Use Vary headers for HTTP caches
When using HTTP caches (e.g., CDN or reverse proxy), ensure responses include a Vary: Authorization header so caches do not serve a response authenticated for one user to another. In Rails, you can set this in the controller:
response.headers['Vary'] = 'Authorization'
Frequently Asked Questions
How can I verify my cache keys properly differentiate JWT user contexts?
sub or roles change, and avoid key components that are purely path-based.Does including the JWT in the cache key compromise token security?
sub claim in cache keys does not expose the token if the cache storage is properly secured. Do not log full tokens or store them in logs; use a stable user identifier or a salted hash of the token payload instead.