Spree Security
Spree inherits Rails' security model and adds an e-commerce attack surface: payment flows, customer PII, staff credentials and outbound webhooks. This skill covers what a developer extending Spree 6 has to get right. For
who may do what (roles, permission keys, scopes, storefront ownership, SSO), see
.
Threat model in four lines
- Store API: internet-facing and called by untrusted clients. Risks: IDOR on carts/orders, XSS via catalog content, abuse of cart/auth endpoints.
- Admin API + dashboard: staff credentials get phished and integrations leak keys. Risks: over-scoped secret keys, cross-store leaks in custom controllers, privilege creep in roles.
- Payments: money and PCI. Risks: card data in logs or the DB, unverified gateway webhooks, refund abuse.
- Outbound traffic: webhooks and integrations. Risks: SSRF, unverified receivers.
Secrets
- Keep them in Rails encrypted credentials or env vars, never in the repo. variables are compiled into the dashboard bundle, so never put a secret in one.
- Leaked secret: rotate at the provider first, then update credentials/env and deploy, then scrub git history (). If you clean history first, the leaked key keeps working until it's rotated.
- must stay stable per environment. Secret API keys are stored as HMAC-SHA256 digests keyed by it, so rotating it invalidates every key. It's also the last fallback for JWT signing. Set a dedicated JWT secret with (or credentials ).
- Active Record encryption. Spree encrypts
Spree::WebhookEndpoint#secret_key
and Spree::GatewayCustomer#profile_id
(deterministic) and Spree::UserIdentity#access_token
/ (OAuth tokens) only when keys are configured — without them they're plaintext, and the starter logs a warning at boot. Set all three env vars: ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
, ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
, ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
.
- writes a dev set into ; adds one to an older project's (never overwrites existing keys, no ; then recreate containers with , or when ejected — keeps the old env);
spree encryption init --print
or bin/rails db:encryption:init
prints a fresh set for production. Use a separate set per environment and back it up in your secret manager.
- The starter's copies the env vars (falling back to the credentials entry) into
config.active_record.encryption
. That matters: the webhook-secret and gateway-customer calls check Rails.configuration.active_record.encryption
, so keys that live only in credentials, without that snippet, leave those two columns plaintext. Apps from an older starter need the snippet (see ).
- Never change or lose the keys once data is encrypted. Rails can rotate the primary key, but not the deterministic key/salt the webhook secrets and gateway customer IDs use.
- Turning encryption on for existing data: identity tokens stay readable (
support_unencrypted_data: true
) and encrypt on next write. Webhook secrets and gateway customer IDs don't — set config.active_record.encryption.support_unencrypted_data = true
and , deploy with the keys, run [Spree::WebhookEndpoint, Spree::GatewayCustomer, Spree::UserIdentity].each { |m| m.find_each(&:encrypt) }
, then remove both settings.
- Payment-method and integration preferences are not encrypted. Gateway credentials live in the serialized column, so treat the database and its backups as holding live secrets. Enter live gateway keys through the dashboard (Settings → Payments), not in seeds.
The
plugin ships a hook that warns when an agent writes a known-shape secret (Stripe live keys, AWS keys, PATs). It's a tripwire, not a review.
API keys
pk_* Publishable. Safe in browser and mobile code. Store API only.
sk_* Secret. Server-to-server only. Never ship it in a bundle, app or VITE_* variable.
- Minimum scopes.
spree api-key create --type secret --scopes read_orders,write_fulfillments
. Don't give integrations . The scope list is in spree-api-v3/references/scopes.md
.
- Scopes and channel bindings are immutable. To rotate or change access, mint a new key, deploy it, then revoke the old one (Settings → API keys, or ).
- Writes made with a secret key are attributed to that key (), so you can audit what a leaked key did through the actor fields and logs.
- Bind a storefront's publishable key to its channel so shoppers can't switch channels by sending .
- A leaked is a nuisance: rate-limit and rotate. A leaked is a breach: revoke it, then audit.
Dashboard and staff sessions
The React dashboard (
, served at
by
) holds
no API keys:
- Staff sign in and the JWT lives in memory only (5-minute default lifetime, ). The refresh token is an HttpOnly, signed cookie scoped to . JavaScript can't read it, and it rotates on every refresh.
- Over HTTPS the cookie is . Over plain HTTP it falls back to (dev only).
- Same-origin (the default: dashboard served by Rails at ) needs no CORS or cookie configuration.
- Cross-origin (dashboard on a CDN): HTTPS on both sides, and add the dashboard origin under Settings → Allowed Origins (,
/api/v3/admin/allowed_origins
). The starter's config/initializers/cors.rb
consults that table for with . CSRF protection for the cookie is SameSite plus the strict origin allowlist, so keep the list exact and short.
- Gating in the UI is not authorization. The dashboard hides buttons based on permission keys, but the API gate enforces them. Any dashboard plugin action must hit an endpoint that declares (see ). Record-level refusals behind that gate come from — a custom subclass can tighten staff access further, but it never loosens the key gate, and secret keys () bypass it.
- Staff SSO: register and optionally remove from
Spree.admin_authentication_strategies
. Staff accounts are never auto-provisioned from the IdP.
- Staff-management guardrails are built in (details in ): only an admin can grant or remove the role and a store keeps at least one admin; invitations must name a role; invitation acceptance links come from a separate write-gated endpoint (never from listings) and resending rotates the token; staff password-reset links only point at the dashboard origin; login, current-password confirmation and invitation acceptance share one lockout (
Spree::Authentication::Lockout
) — route any password check you add through it too.
- Give every staff member their own account and a least-privilege role. Don't share an login.
Mission Control ()
The jobs UI is protected by HTTP Basic auth. In production set
and
; without them the dashboard stays locked. The starter's local defaults (
/
) apply only in development and test. Never copy them into production env files.
Store API: IDOR and cross-store leaks
- Carts and orders are readable by their owner (JWT) or by whoever holds the guest token (). Anyone holding the token can read that cart or order, so never log tokens or put them in URLs you share.
- Prefixed IDs are not secret (they're encodings of sequential keys). Authorize every lookup; never rely on the ID being hard to guess.
- In custom Store controllers, read through
storefront_access_policy.scope(Model.for_store(current_store))
or through current_user.<association>
. Never use .
- In custom Admin controllers, use the inherited (store-scoped) and declare . Multi-store apps share one database, so in a controller is a cross-store leak.
- Every ID in a write body is a lookup too. Resolve referenced IDs (, , a custom field's parent, translation targets, price-list variants, promotion rule products) through (and the seller, on seller surfaces), and take parent records from the route, never from a query/body param. Core does this for its own endpoints; a custom controller that assigns a raw from params reopens the cross-store hole.
- In development and test, flags s on store-owned tables (any table with ) that are neither store-scoped nor id-filtered. It watches every API v3 request and any unit of work that assigns — jobs, webhook controllers, console scripts, specs — until resets. Mode: /
Spree::Config[:store_scope_guard]
= (default), (Spree's own API suite), ; never active in production. Wrap a deliberately global lookup in Spree::StoreScopeGuard.skip { … }
. Take its warnings seriously.
Mass assignment
API v3 controllers build their allowlist from
resource_permitted_attributes
(custom controllers) plus each model's
additional_permitted_attributes
. There is no
Spree::PermittedAttributes
module. Don't override
— that drops what extensions add.
ruby
# Make an extension column writable on the existing endpoints (never `<<`, the array is frozen):
Spree::Product.additional_permitted_attributes += [:brand_id]
Spree::Address.additional_permitted_attributes += [{ tag_ids: [] }]
# Custom controllers list their own:
def resource_permitted_attributes = %i[name rating body]
Never use
. Never permit ownership or privilege columns (
,
,
,
) on customer-facing endpoints. Set those server-side.
Injection and XSS
- SQL: use parameterized or hash conditions, never string interpolation. Ransack is safe because it only filters on allowlisted attributes. Unknown predicates are silently dropped. Expose new filters with
Spree.ransack.add_attribute(Model, :attr)
, and never allowlist secrets or digests. Filters are a yes/no oracle, so the Store and Seller APIs get narrower allowlists than staff (storefront_ransackable_associations
, private_ransackable_attributes
/private_ransackable_scopes
keyed /); keep private data out of the storefront list when you widen it. Controllers inheriting the Store/Seller pass it for you (); a hand-rolled query in a customer-facing controller should call ransack(params, auth_object: :store)
itself.
- Rich text: product, category, collection and seller descriptions are sanitized on save by (via / ). The allowlist covers what the dashboard's Tiptap editor emits:
p br hr h1–h6 strong em s u code pre blockquote ul ol li a img
, and / URLs are stripped. The API returns as plain text and as sanitized markup.
- To widen the allowlist deliberately:
Spree::RichTextSanitizer.allowed_tags += %w[table thead tbody tr th td]
in an initializer.
- Writes that skip callbacks (, , raw SQL, bulk imports that bypass models) are not sanitized. Call
Spree::RichTextSanitizer.sanitize(html)
yourself.
- New rich-text columns on your models:
include Spree::SanitizableRichText
+ has_spree_rich_text :body
. Translated attributes need on the class too.
- Storefront: render with your framework's raw-HTML escape hatch (for example, React's ) only for these server-sanitized fields. Escape everything else.
- CSV exports: neutralize cells starting with , , or before writing files that staff open in spreadsheets.
CORS and CSP
ruby
# config/initializers/cors.rb: storefront on another origin (the admin block comes from the starter)
allow do
origins 'https://shop.example.com'
resource '/api/v3/store/*', headers: :any, methods: %i[get post patch put delete options]
end
- Never use together with . The admin, and a cross-origin seller panel, must use the Allowed Origins table.
- Set CSP on the app that renders HTML. For a Next.js storefront that means the Next app, not Rails. The Rails side mainly serves JSON plus the dashboard.
Webhooks
Outbound (Spree → your receiver). Each delivery carries
X-Spree-Webhook-Signature
= hex HMAC-SHA256 of
"#{timestamp}.#{raw_body}"
using the endpoint's secret, plus
X-Spree-Webhook-Timestamp
and
.
Receivers must:
- Verify against the raw body bytes, before JSON parsing.
- Compare with a constant-time function (
ActiveSupport::SecurityUtils.secure_compare
, ).
- Reject timestamps older than about 5 minutes (replay).
- Be idempotent. Non-2xx responses, timeouts and connection errors are retried with backoff by
Spree::WebhookDeliveryJob
(5 attempts in total, same event ), and a manual redelivery sends the same event again.
exports
verifyWebhookSignature(rawBody, signature, timestamp, secret, tolerance = 300)
. See
.
SSRF. Outside development, deliveries go through
, which blocks private, loopback and link-local targets. Development bypasses it so
receivers work. Don't copy
branches into other code paths.
defaults to on outside development; leave it on. Creating webhook endpoints requires
, which is deliberately separate from
.
What / does not grant. customer.password_reset_requested
carries a live reset token: it reaches only endpoints that name it (never
or
), and subscribing to it — or repointing an endpoint that receives it — also needs
. The delivery log stores credentials (cart
, reset/verification tokens, download URLs, payment-session secrets, gift card
) as
, and the Admin API returns a delivery's
only to callers who can read the underlying record (
otherwise).
Inbound (gateway → Spree, e.g.
/api/v3/webhooks/payments/...
): the record is found by its prefixed ID alone (the request can't name a store), the request runs in that payment method's/integration's store, and the provider verifies the signature against that record's own secret — returning 401 when it's invalid. The signature is the only authentication. If you write a custom provider, verify the signature before acting (see
,
).
Data privacy (GDPR)
- Subject requests are records. Customers create them with
POST /api/v3/store/customers/me/data_requests { kind: "access" | "erasure" }
(erasure requires ). Staff use GET /api/v3/admin/customers/:id/export
and POST /api/v3/admin/customers/:id/anonymize
. Exports are built in the background and emailed as expiring signed links.
- Erasure means anonymization, done by the
Spree::Customers::Anonymize
workflow. It scrubs the account, address book, order address snapshots, saved cards, identities and sessions, and strips the email, IP and user agent from consent rows (the rows themselves are kept — see Consent below). It keeps financial records (orders, payments, tax lines, line items, plus country, state and a truncated postcode for tax jurisdiction), and publishes .
- If you add a table holding personal data, extend anonymization in the same change. Core has a schema-guard spec that fails when a personal-data column isn't covered.
- Hooks:
ruby
Rails.application.config.after_initialize do
Spree.hooks.register('customers.anonymize.validate', 'MyApp::LegalHold') # workflow.reject!('Under legal hold')
Spree.hooks.register('data_requests.fulfill.extend_payload', 'MyApp::LoyaltyExport') # return a Hash to merge
end
- Consent: records acceptance events (purpose, source, time, document digest). The customer also has
email_marketing_consent_updated_at
/ . Consent rows survive erasure (purpose, source and timestamp stay as proof) with email, IP and user agent cleared, and erasure appends an withdrawal row (source ) if the customer had opted in. Cookie consent is the storefront's responsibility.
- Staff access to and rides / .
PCI scope
Spree never stores or transmits PANs. Card data goes browser → gateway through the gateway's hosted fields (Stripe Elements / Payment Element via
, Adyen Drop-in, PayPal). Spree only receives tokens and payment sessions.
holds brand, last4, expiry and a gateway reference, never the full number or CVC.
- Never add card-data columns or proxy raw card fields through your API. If you think you need to, use gateway tokenization instead.
- With hosted fields only, you're usually at SAQ A. Collecting card data yourself puts you at SAQ D.
- Param filtering already covers , and the like. Extend
Rails.application.config.filter_parameters
for any custom sensitive param names.
Rate limiting
The API throttles built in: per publishable key + IP (300/min), per secret key (600/min), and per-IP limits on login, registration, refresh and password reset. Counters live in
, so multi-process deployments need a shared store (Solid Cache or Redis). Put a CDN or WAF in front for volumetric abuse. See
for the table.
Dependency hygiene
Run
,
, and
for the storefront and dashboard in CI. The plugin doesn't ship a CI workflow.
Deployment checklist
Where to read further
- PCI:
node_modules/@spree/docs/dist/developer/security/pci_compliance.md
- Data privacy:
node_modules/@spree/docs/dist/developer/core-concepts/data-privacy.md
- Dashboard deployment and auth:
node_modules/@spree/docs/dist/developer/dashboard/deployment.md
- Admin API auth:
node_modules/@spree/docs/dist/api-reference/admin-api/authentication.md
- Background jobs / Mission Control:
node_modules/@spree/docs/dist/developer/deployment/background_jobs.md
- Rails Security Guide: https://guides.rubyonrails.org/security.html
- Related skills: , , , , .