Cookies are fundamental to web applications, but choosing the right storage method can make or break your app’s security and performance. Rails 7 offers multiple cookie storage mechanisms, each with distinct security properties and use cases. Let’s explore when to use each approach and why it matters.
The Cookie Storage Spectrum
Rails provides four main cookie storage methods, each offering different levels of security:
# 1. Plain cookies - readable and modifiable by client
cookies[:theme] = 'dark'
# 2. Signed cookies - readable but tamper-proof
cookies.signed[:discount_code] = 'SAVE10'
# 3. Encrypted cookies - hidden and tamper-proof
cookies.encrypted[:user_preferences] = { notifications: true }
# 4. Session storage - server-side with encrypted session cookie
session[:current_user_id] = user.id
1. Plain Cookies: When Transparency is Acceptable
Use for: Non-sensitive data where client-side reading/modification is acceptable or even desired.
# Setting a plain cookie
cookies[:theme] = 'dark'
cookies[:language] = 'en'
cookies[:consent_given] = 'true'
# With expiration
cookies[:temporary_banner_dismissed] = {
value: 'true',
expires: 1.day.from_now
}
Security implications:
- ✅ Fast and simple
- ❌ Completely readable in browser dev tools
- ❌ User can modify values freely
- ❌ No protection against tampering
Best for:
- UI preferences (theme, language)
- Non-critical flags (banner dismissal)
- Data you want JavaScript to access easily
2. Signed Cookies: Tamper-Proof but Visible
Signed cookies prevent modification while remaining readable. Rails uses HMAC-SHA1 with your secret_key_base to create a cryptographic signature.
# Setting signed cookies
cookies.signed[:discount_code] = 'SAVE10'
cookies.signed[:referral_source] = 'google_ads'
# Reading signed cookies
discount = cookies.signed[:discount_code] # Returns 'SAVE10' or nil if tampered
How it works:
# Rails internally does:
# 1. Create signature: HMAC-SHA1(secret_key_base, 'SAVE10')
# 2. Store: Base64.encode64('SAVE10--signature')
# 3. On read: verify signature matches content
Security implications:
- ✅ Tamper-proof – modification invalidates the cookie
- ✅ Prevents privilege escalation attacks
- ⚠️ Content still visible (Base64 encoded)
- ❌ Not suitable for truly sensitive data
Real-world example from our codebase:
# lib/session/cookie_discount_accessor.rb
def discount_code
# Prevents users from changing 'SAVE10' to 'SAVE50' in browser
@cookies.signed[:discount] && DiscountCode.find_by(name: @cookies.signed[:discount])
end
def set_discount_code(code)
@cookies.signed[:discount] = {
value: code.name,
expires: code.expiration || 30.days.from_now
}
end
Best for:
- Discount codes
- Referral tracking
- Non-sensitive IDs that shouldn’t be modified
- Data integrity without confidentiality requirements
3. Encrypted Cookies: Maximum Security
Encrypted cookies are both signed and encrypted, making them unreadable and tamper-proof.
# Setting encrypted cookies
cookies.encrypted[:credit_card_last4] = '4242'
cookies.encrypted[:user_preferences] = {
notifications: true,
marketing_emails: false
}
# Reading encrypted cookies
preferences = cookies.encrypted[:user_preferences]
Security implications:
- ✅ Content completely hidden from client
- ✅ Tamper-proof
- ✅ Suitable for sensitive data
- ⚠️ Slightly higher CPU overhead
- ⚠️ Size limitations (4KB total per domain)
Best for:
- Personal information
- Financial data
- Complex user preferences
- Any data you’d store in a database but need client-side
4. Session Storage: Server-Side Security
Rails sessions are encrypted cookies by default, but the data is conceptually server-side.
# Session storage
session[:current_user_id] = user.id
session[:shopping_cart] = cart.to_h
session[:two_factor_verified] = true
# Configuration in config/application.rb
config.session_store :cookie_store, key: '_myapp_session'
Security implications:
- ✅ Encrypted by default
- ✅ Automatic expiration handling
- ✅ CSRF protection integration
- ⚠️ 4KB size limit
- ⚠️ Lost on cookie deletion
Best for:
- User authentication state
- Shopping carts
- Multi-step form data
- Security-sensitive flags
Security Best Practices
1. Choose the Right Storage Method
# ❌ Don't store sensitive data in plain cookies
cookies[:ssn] = '123-45-6789' # Visible to everyone!
# ✅ Use appropriate security level
cookies.encrypted[:ssn] = '123-45-6789' # Hidden and protected
session[:user_id] = user.id # Server-side, encrypted
2. Set Proper Cookie Attributes
# Secure cookies for HTTPS
cookies[:theme] = {
value: 'dark',
secure: Rails.env.production?, # HTTPS only
httponly: true, # No JavaScript access
samesite: :strict # CSRF protection
}
3. Handle Cookie Tampering Gracefully
def current_discount_code
code_name = cookies.signed[:discount]
return nil unless code_name
DiscountCode.find_by(name: code_name)&.tap do |code|
# Remove if expired or invalid
cookies.delete(:discount) unless code.usable?
end
end
4. Use Expiration Strategically
# Short-lived sensitive data
cookies.signed[:password_reset_token] = {
value: token,
expires: 15.minutes.from_now,
secure: true,
httponly: true
}
# Long-lived preferences
cookies.encrypted[:user_preferences] = {
value: preferences.to_json,
expires: 1.year.from_now
}
Advanced Patterns
1. Cookie Accessor Classes
Create dedicated classes for complex cookie management:
class Session::CookieDiscountAccessor
def initialize(cookies)
@cookies = cookies
end
def discount_code
@cookies.signed[:discount] && DiscountCode.find_by(name: @cookies.signed[:discount])
end
def set_discount_code(code)
@cookies.signed[:discount] = {
value: code.name,
expires: code.expiration || 30.days.from_now
}
end
def remove_discount_code
@cookies.delete(:discount)
end
end
2. Validation and Cleanup
class Session::CheckAndRemoveDiscountCode
def initialize(cookies:)
@accessor = Session::CookieDiscountAccessor.new(cookies)
end
def run
# Remove referral conflicts
@accessor.referral_code && @accessor.remove_discount_code && return
# Remove expired codes
discount_code = @accessor.discount_code
@accessor.remove_discount_code if discount_code && !discount_code.usable?
end
end
3. Error Handling for Corrupted Cookies
def safe_read_encrypted_cookie(key)
cookies.encrypted[key]
rescue ActiveSupport::MessageVerifier::InvalidSignature,
ActiveSupport::MessageEncryptor::InvalidMessage
# Cookie was corrupted or created with different secret
cookies.delete(key)
nil
end
Performance Considerations
Cookie Size Limits
- Total limit: 4KB per domain
- Individual limit: ~4KB per cookie
- Count limit: ~50 cookies per domain
CPU Overhead
# Benchmark different storage methods
require 'benchmark'
Benchmark.bm do |x|
x.report("plain") { 1000.times { cookies[:test] = 'value' } }
x.report("signed") { 1000.times { cookies.signed[:test] = 'value' } }
x.report("encrypted") { 1000.times { cookies.encrypted[:test] = 'value' } }
end
# Results (approximate):
# user system total real
# plain 0.001000 0.000000 0.001000 ( 0.001000)
# signed 0.010000 0.000000 0.010000 ( 0.009000)
# encrypted 0.050000 0.000000 0.050000 ( 0.048000)
Configuration and Security Headers
Session Configuration
# config/application.rb
config.session_store :cookie_store,
key: '_myapp_session',
secure: Rails.env.production?,
httponly: true,
expire_after: 14.days,
same_site: :lax
Security Headers
# config/application.rb
config.force_ssl = true # HTTPS in production
# Use Secure Headers gem
SecureHeaders::Configuration.default do |config|
config.cookies = {
secure: true,
httponly: true,
samesite: {
lax: true
}
}
end
Testing Cookie Security
# spec/lib/session/coupon_code_spec.rb
RSpec.describe Session::CouponCode do
describe 'cookie tampering protection' do
it 'handles corrupted signed cookies gracefully' do
# Simulate tampered cookie
cookies.signed[:discount] = 'SAVE10'
cookies[:discount] = 'tampered_value' # Direct manipulation
accessor = Session::CookieDiscountAccessor.new(cookies)
expect(accessor.discount_code).to be_nil
end
end
end
Migration Strategies
Upgrading Cookie Security
def upgrade_cookie_security
# Read from old plain cookie
if (old_value = cookies[:legacy_data])
# Migrate to encrypted
cookies.encrypted[:legacy_data] = old_value
cookies.delete(:legacy_data)
end
end
Handling Secret Key Rotation
# config/credentials.yml.enc
secret_key_base: new_secret
legacy_secret_key_base: old_secret
# In application
def read_with_fallback(key)
cookies.encrypted[key] || begin
# Try with old secret
old_verifier = ActiveSupport::MessageEncryptor.new(
Rails.application.credentials.legacy_secret_key_base
)
old_verifier.decrypt_and_verify(cookies[key])
rescue
nil
end
end
Quick Decision Matrix
| Data Type | Sensitivity | Client Access Needed | Recommended Storage |
|---|---|---|---|
| Theme preferences | Low | Yes | Plain cookies |
| Discount codes | Medium | No | Signed cookies |
| User settings | Medium | No | Encrypted cookies |
| Authentication | High | No | Session |
| Credit card data | High | No | Database + session ID |
| Shopping cart | Medium | No | Session or encrypted |
| CSRF tokens | High | Limited | Session (built-in) |
Common Pitfalls to Avoid
- Don’t mix storage types for the same data
# ❌ Inconsistent
cookies[:user_id] = user.id # Sometimes
cookies.signed[:user_id] = user.id # Other times
# ✅ Consistent
session[:user_id] = user.id # Always
- Don’t store large objects in cookies
# ❌ Will hit 4KB limit
cookies.encrypted[:full_user] = user.to_json
# ✅ Store reference
session[:user_id] = user.id
- Don’t forget expiration
# ❌ Never expires
cookies.signed[:temp_token] = token
# ✅ Proper expiration
cookies.signed[:temp_token] = {
value: token,
expires: 1.hour.from_now
}
Conclusion
Cookie storage in Rails 7 offers a rich toolkit for different security and performance needs. The key is matching the storage method to your data’s sensitivity and access patterns:
- Plain cookies for non-sensitive, client-accessible data
- Signed cookies when you need tamper protection but not confidentiality
- Encrypted cookies for sensitive data that must remain client-side
- Session storage for server-side state with automatic encryption
Remember: the best cookie strategy combines appropriate storage methods with proper security headers, validation, and graceful error handling. When in doubt, err on the side of more security rather than less.
The Rails cookie system is designed to make secure defaults easy—take advantage of it to build applications that are both performant and secure.