spree-security
Use when the user is hardening a Spree 6 app, reviewing a PR or finding for security issues, managing secrets and API keys, configuring CORS/CSP, securing the dashboard or /jobs, handling GDPR data requests, or asking about Spree-specific risks (publishable vs secret keys, scope minimization, IDOR on carts/orders, rich-text XSS, webhook HMAC and SSRF, encrypted columns, PCI scope). Common phrasings include "Spree security", "leaked secret key", "rotate API key", "CORS", "Allowed Origins", "CSP", "XSS in product description", "IDOR", "cross-store data leak", "mass assignment", "permitted attributes", "webhook signature", "SSRF", "Active Record encryption", "GDPR", "anonymize customer", "PCI", "Mission Control password". For roles, permission keys and login strategies use spree-auth-permissions.
How do I install this agent skill?
npx skills add https://github.com/spree/agent-skills --skill spree-securityIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides comprehensive security guidance and hardening instructions for Spree 6 applications, including best practices for secrets management, API key scoping, and data privacy. A minor security surface for indirect prompt injection is present due to the skill's intended use in reviewing untrusted data such as Pull Requests.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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 spree-auth-permissions.
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.
VITE_*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 (
git filter-repo). If you clean history first, the leaked key keeps working until it's rotated. secret_key_basemust stay stable per environment. Secret API keys are stored as HMAC-SHA256 digests keyed by it, so rotating it invalidates everysk_key. It's also the last fallback for JWT signing. Set a dedicated JWT secret withSPREE_JWT_SECRET_KEY(or credentialsjwt_secret_key).- Active Record encryption. Spree encrypts
Spree::WebhookEndpoint#secret_keyandSpree::GatewayCustomer#profile_id(deterministic) andSpree::UserIdentity#access_token/#refresh_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.create-spree-appwrites a dev set into.env;spree encryption initadds one to an older project's.env(never overwrites existing keys, no--force; then recreate containers withspree update, orspree devwhen ejected —spree restartkeeps the old env);spree encryption init --printorbin/rails db:encryption:initprints a fresh set for production. Use a separate set per environment and back it up in your secret manager.- The starter's
config/application.rbcopies the env vars (falling back to theactive_record_encryptioncredentials entry) intoconfig.active_record.encryption. That matters: the webhook-secret and gateway-customerencryptscalls checkRails.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 (seespree-upgrade-5-to-6). - 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 — setconfig.active_record.encryption.support_unencrypted_data = trueandextend_queries = true, 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
preferencescolumn, so treat the database and its backups as holding live secrets. Enter live gateway keys through the dashboard (Settings → Payments), not in seeds.
The spree/agent-skills 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 integrationswrite_all. The scope list is inspree-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
spree api-key revoke). - Writes made with a secret key are attributed to that key (
*_type: api_key), so you can audit what a leaked key did through the actor fields andWebhookDeliverylogs. - Bind a storefront's publishable key to its channel so shoppers can't switch channels by sending
X-Spree-Channel. - A leaked
pk_is a nuisance: rate-limit and rotate. A leakedsk_is a breach: revoke it, then audit.
Dashboard and staff sessions
The React dashboard (@spree/dashboard, served at /dashboard by spree_dashboard) holds no API keys:
- Staff sign in and the JWT lives in memory only (5-minute default lifetime,
admin_jwt_expiration). The refresh token is an HttpOnly, signed cookie scoped to/api/v3/admin/auth. JavaScript can't read it, and it rotates on every refresh. - Over HTTPS the cookie is
SameSite=None; Secure. Over plain HTTP it falls back toSameSite=Lax(dev only). - Same-origin (the default: dashboard served by Rails at
/dashboard) 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 (
Spree::AllowedOrigin,/api/v3/admin/allowed_origins). The starter'sconfig/initializers/cors.rbconsults that table for/api/v3/admin/*withcredentials: true. 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
GET /api/v3/admin/mepermission keys, but the API gate enforces them. Any dashboard plugin action must hit an endpoint that declaresscoped_resource(seespree-auth-permissions). Record-level refusals behind that gate come fromSpree.ability_class— a custom subclass can tighten staff access further, but it never loosens the key gate, and secret keys (Spree::ApiKeyAbility) bypass it. - Staff SSO: register
OidcStrategyand optionally remove:emailfromSpree.admin_authentication_strategies. Staff accounts are never auto-provisioned from the IdP. - Staff-management guardrails are built in (details in
spree-auth-permissions): only an admin can grant or remove theadminrole 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
admin@login.
Mission Control (/jobs)
The jobs UI is protected by HTTP Basic auth. In production set MISSION_CONTROL_USER and MISSION_CONTROL_PASSWORD; without them the dashboard stays locked. The starter's local defaults (spree / spree123) 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 (
X-Spree-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 throughcurrent_user.<association>. Never useModel.find(params[:id]). - In custom Admin controllers, use the inherited
scope(store-scoped) and declarescoped_resource. Multi-store apps share one database, soSpree::Order.findin a controller is a cross-store leak. - Every ID in a write body is a lookup too. Resolve referenced IDs (
stock_location_id,reason_id, a custom field's parent, translation targets, price-list variants, promotion rule products) throughcurrent_store(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*_idfrom params reopens the cross-store hole. - In development and test,
Spree::StoreScopeGuardflagsSELECTs on store-owned tables (anyspree_*table withstore_id) that are neither store-scoped nor id-filtered. It watches every API v3 request and any unit of work that assignsSpree::Current.store— jobs, webhook controllers, console scripts, specs — untilSpree::Currentresets. Mode:SPREE_STORE_SCOPE_GUARD/Spree::Config[:store_scope_guard]=log(default),raise(Spree's own API suite),off; never active in production. Wrap a deliberately global lookup inSpree::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 permitted_params — that drops what extensions add.
# 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 params.permit!. Never permit ownership or privilege columns (customer_id, store_id, role_ids, status) on customer-facing endpoints. Set those server-side.
Injection and XSS
- SQL: use parameterized
where('x > ?', v)or hash conditions, never string interpolation. Ransack is safe because it only filters on allowlisted attributes. Unknownq[...]predicates are silently dropped. Expose new filters withSpree.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_scopeskeyed:store/:seller); keep private data out of the storefront list when you widen it. Controllers inheriting the Store/SellerResourceControllerpass it for you (ransack_auth_object); a hand-rolled query in a customer-facing controller should callransack(params, auth_object: :store)itself. - Rich text: product, category, collection and seller descriptions are sanitized on save by
Spree::RichTextSanitizer(viahas_spree_rich_text/sanitizes_rich_text). 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, anddata:/javascript:URLs are stripped. The API returnsdescriptionas plain text anddescription_htmlas 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 (
update_columns,update_all, raw SQL, bulk imports that bypass models) are not sanitized. CallSpree::RichTextSanitizer.sanitize(html)yourself. - New rich-text columns on your models:
include Spree::SanitizableRichText+has_spree_rich_text :body. Translated attributes needsanitizes_rich_texton theTranslationclass too.
- To widen the allowlist deliberately:
- Storefront: render
*_htmlwith your framework's raw-HTML escape hatch (for example, React'sdangerouslySetInnerHTML) 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
# 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
origins '*'together withcredentials: true. 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 X-Spree-Webhook-Event. Receivers must:
- Verify against the raw body bytes, before JSON parsing.
- Compare with a constant-time function (
ActiveSupport::SecurityUtils.secure_compare,crypto.timingSafeEqual). - 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 eventid), and a manual redelivery sends the same event again.
@spree/sdk/webhooks exports verifyWebhookSignature(rawBody, signature, timestamp, secret, tolerance = 300). See spree-events-webhooks.
SSRF. Outside development, deliveries go through SsrfFilter, which blocks private, loopback and link-local targets. Development bypasses it so localhost receivers work. Don't copy Rails.env.development? branches into other code paths. webhooks_verify_ssl defaults to on outside development; leave it on. Creating webhook endpoints requires write_webhooks, which is deliberately separate from settings.
What write_webhooks/read_webhooks does not grant. customer.password_reset_requested carries a live reset token: it reaches only endpoints that name it (never * or customer.*), and subscribing to it — or repointing an endpoint that receives it — also needs write_customers. The delivery log stores credentials (cart token, reset/verification tokens, download URLs, payment-session secrets, gift card code) as [REDACTED], and the Admin API returns a delivery's payload only to callers who can read the underlying record (null 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 spree-providers, spree-payments).
Data privacy (GDPR)
- Subject requests are
Spree::DataRequestrecords. Customers create them withPOST /api/v3/store/customers/me/data_requests { kind: "access" | "erasure" }(erasure requirescurrent_password). Staff useGET /api/v3/admin/customers/:id/exportandPOST /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::Anonymizeworkflow. 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 publishescustomer.anonymized. - 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:
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:
Spree::ConsentRecordrecords acceptance events (purpose, source, time, document digest). The customer also hasemail_marketing_consent_updated_at/_source. Consent rows survive erasure (purpose, source and timestamp stay as proof) with email, IP and user agent cleared, and erasure appends anemail_marketingwithdrawal row (sourceanonymization) if the customer had opted in. Cookie consent is the storefront's responsibility. - Staff access to
DataRequestandConsentRecordridesread_customers/write_customers.
PCI scope
Spree never stores or transmits PANs. Card data goes browser → gateway through the gateway's hosted fields (Stripe Elements / Payment Element via spree_stripe, Adyen Drop-in, PayPal). Spree only receives tokens and payment sessions. Spree::CreditCard 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
number,verification_valueand the like. ExtendRails.application.config.filter_parametersfor 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 Rails.cache, so multi-process deployments need a shared store (Solid Cache or Redis). Put a CDN or WAF in front for volumetric abuse. See spree-api-v3 for the table.
Dependency hygiene
Run bundle audit, brakeman, and pnpm audit for the storefront and dashboard in CI. The plugin doesn't ship a CI workflow.
Deployment checklist
- Secrets in credentials/env. No secrets in
VITE_*, in the repo, or in seeds. -
secret_key_basestable;SPREE_JWT_SECRET_KEYset; the threeACTIVE_RECORD_ENCRYPTION_*keys set (production-only set, backed up) and read intoconfig.active_record.encryption. -
config.force_ssl = true; HTTPS on API, dashboard and storefront. - Allowed Origins lists only real dashboard/seller-panel origins. Storefront CORS lists explicit origins.
-
MISSION_CONTROL_USER/MISSION_CONTROL_PASSWORDset. - Integrations use minimum-scope secret keys, each named after its purpose.
write_allreserved for break-glass use. - Staff have individual accounts and least-privilege roles. SSO enforced if the org has an IdP.
- Webhook receivers verify HMAC, check the timestamp, and are idempotent.
- Custom controllers are store-scoped (Admin) or ownership-scoped (Store).
- Anonymization covers any personal-data tables you added.
- Shared cache store for rate limits. CDN/WAF in front.
- Database backups encrypted and stored away from the DB.
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:
spree-auth-permissions,spree-api-v3,spree-events-webhooks,spree-payments,spree-deployment.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/spree/agent-skills/spree-security">View spree-security on skillZs</a>