Spree Security
Before writing code
Fetch live docs:
- Check https://github.com/spree/spree/security for the security policy and known advisories.
- Cross-reference https://rubysec.com for Rails / Ruby gem CVEs.
- Inspect
Spree::Ability(CanCanCan) in the live source for current default permissions. - Verify auth config in
config/initializers/devise.rb. - For OAuth2, check Doorkeeper config in
config/initializers/doorkeeper.rb.
Conceptual Architecture
Auth Stack
| Layer | Tool |
|---|---|
| Customer authentication | Devise (v5 ships in-core; spree_auth_devise is archived) |
| Admin authentication | Same Devise system; differentiation via Spree::Role |
| Authorization | CanCanCan via Spree::Ability |
| API auth (v3 Store) | Publishable key + per-user JWT |
| API auth (v3 Admin) | Per-user API key + OAuth2 (Doorkeeper) |
| API auth (v2 legacy) | OAuth2 (Doorkeeper) password + client_credentials grants |
| OAuth2 server | Doorkeeper |
| CSRF | Rails default — required for HTML, exempted for API |
spree_auth_devise Is Deprecated
The standalone spree_auth_devise gem is archived as of Feb 2026. Spree v5+ ships Devise auth in the core gem. Do NOT install the old gem on new projects — it conflicts and creates a maintenance burden.
CanCanCan Permission Model
# app/models/spree/ability.rb
class Spree::Ability
include CanCan::Ability
def initialize(user)
user ||= Spree.user_class.new
if user.has_spree_role?(:admin)
can :manage, :all
elsif user.has_spree_role?(:order_manager)
can :manage, Spree::Order
else
can :read, [Spree::Product, Spree::Taxon]
end
end
end
Extend via decorator — don't replace.
API Auth Tiers (v3)
| Token type | Scope | Where stored |
|---|---|---|
Publishable key (pk_…) | Read-only public catalog + cart endpoints | Browser env (NEXT_PUBLIC_*) — safe |
| User JWT | Customer's account, their orders | httpOnly cookie server-side |
Cart token (order_token) | Anonymous cart only | httpOnly cookie server-side |
| Admin API key | Per-user admin scope | Server env vars or vault — never to browser |
| OAuth2 access token (admin scope) | App integration | Server-to-server only |
Webhook HMAC Verification
Webhooks 2.0 signs with HMAC-SHA256 over the raw body using a per-endpoint shared secret. Always verify:
def verify(body, header, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
ActiveSupport::SecurityUtils.secure_compare(expected, header.sub(/^sha256=/, ''))
end
Use secure_compare — never == (timing attack).
Rails OWASP Checklist for Spree
| Risk | Spree-specific mitigation |
|---|---|
| A01 Broken access control | CanCanCan Spree::Ability — never skip authorize!. Multi-store scoping. |
| A02 Cryptographic failures | Rails credentials. TLS everywhere. No secrets in YAML. |
| A03 Injection | ActiveRecord parameterization. Ransack: lock ransackable_attributes. |
| A04 Insecure design | Don't accept user-supplied IDs without ownership check. Use prefixed IDs. |
| A05 Misconfiguration | config.force_ssl = true. config.action_controller.default_url_options = { protocol: 'https' }. |
| A06 Vulnerable deps | bundler-audit, brakeman in CI. |
| A07 ID&A failures | Devise default rate-limits. Add 2FA via devise-two-factor. |
| A08 Software integrity | Pin gem versions. Verify gem signatures where supported. |
| A09 Logging | Filter params: Rails.application.config.filter_parameters += [:password, :credit_card]. |
| A10 SSRF | Never Net::HTTP.get(params[:url]). |
Ransack Filter Exposure
API v2 filters use Ransack (filter[status_eq]=complete). By default, all columns and associations are filterable. Lock down per-model:
# app/models/spree/order_decorator.rb
module SecureOrderRansack
def self.prepended(base)
base.class_eval do
def self.ransackable_attributes(_auth = nil)
%w[number state payment_state shipment_state created_at]
end
def self.ransackable_associations(_auth = nil)
%w[user line_items]
end
end
end
end
Spree::Order.prepend(SecureOrderRansack)
Without this, attackers can filter by sensitive columns (passwords, tokens).
Prefixed IDs (v3)
API v3's prefixed IDs (prod_…, ord_…) are opaque strings — they don't expose row counts (sequential integers do). But always pair with ownership checks:
order = current_store.orders.find(params[:id]) # scoped lookup
Not:
order = Spree::Order.find(params[:id]) # IDOR risk
PCI Scope
Spree's never touches raw card numbers when configured correctly:
- Stripe Elements / Adyen Drop-in / PayPal Buttons collect card data in the gateway's iframe
- Spree stores only the gateway's token (
tok_…,pm_…, etc.) - This keeps the merchant in SAQ-A scope, the lightest PCI tier
Don't build a custom card form that sends params[:card_number] to Rails. That escalates PCI scope dramatically.
Multi-Store Data Isolation
As covered in spree-multi-store, always scope queries by current_store. A single missed scope can leak another tenant's orders.
Devise Password Policies
Configure in config/initializers/devise.rb:
config.password_length = 12..128
config.maximum_attempts = 5
config.unlock_in = 30.minutes
config.timeout_in = 30.minutes
Add zxcvbn-style strength via devise_zxcvbn if needed.
2FA
Spree doesn't ship 2FA, but Devise extensions add it:
gem 'devise-two-factor'
Or use WebAuthn (webauthn-ruby) for hardware-key auth on admin accounts.
Rails Master Key Hygiene
RAILS_MASTER_KEYunlocksconfig/credentials.yml.enc- Store it in your platform's secret manager (Heroku config, AWS Secrets Manager, etc.)
- Never commit
config/master.key— gitignore it - Rotate periodically: re-encrypt credentials with a new key, update env
CSRF Exemption for APIs
class Spree::Api::V3::BaseController < ActionController::API
# ActionController::API doesn't include CSRF protection — correct for APIs
end
But never disable CSRF on HTML controllers (admin UI). Default Rails CSRF tokens protect admin from cross-origin attacks.
Rate Limiting
Spree's per-endpoint API rate limiting is built in (v5+). For brute-force protection on admin login, layer rack-attack:
# config/initializers/rack_attack.rb
Rack::Attack.throttle('admin_login', limit: 5, period: 15.minutes) do |req|
req.ip if req.path == '/admin/login' && req.post?
end
Implementation Guidance
Pre-Production Security Checklist
config.force_ssl = true+ HSTS headersRAILS_MASTER_KEYin vault (not in code)- Devise password policy raised from defaults
- CanCanCan abilities reviewed; no
can :manage, :alloutside admin role - Ransack
ransackable_attributeslocked per exposed model bundler-auditandbrakemanclean in CI- Webhook signature verification on every receiver
- OAuth2 application secrets rotated quarterly
- API key issuance auditable — Spree stores them; track in logs
- Multi-store scope test — assert customer can't fetch other stores' orders
- PCI scope verified — gateway iframes only; never custom card form
- Admin 2FA for all admin users
config.filter_parametersincludespassword,token,secret,credit_card- CSP headers for the storefront (
Content-Security-Policy) - Backup encryption at rest
- Image upload validation — limit MIME types, sizes; scan for ImageMagick CVEs
Brakeman in CI
- run: bundle exec brakeman -q --no-pager --exit-on-warn
Treat new findings as build failures.
Bundler-Audit
bundle exec bundler-audit c