diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee947a8..aec03f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## [Unreleased]
+
+### Added
+
+- `SolidQueueMonitor.csrf_protection_enabled` config option (default `false`). When enabled, the engine no longer skips `verify_authenticity_token`: all dashboard forms embed an `authenticity_token`, `csrf_meta_tags` are added to the layout, and unverified `POST` requests to the destructive actions (retry / discard / pause / resume / execute / reject / remove / prune) are rejected. Disabled by default for backward compatibility, since the gem does not assume the host app has a session store. See the new "CSRF Protection" section in the README for requirements.
+
## [2.1.0] - 2026-05-13
### Added
diff --git a/README.md b/README.md
index 3dba0ec..2999ca7 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,9 @@ SolidQueueMonitor.setup do |config|
# Disable the chart on the overview page to skip chart queries entirely
# config.show_chart = true
+
+ # Enable CSRF protection for the dashboard's destructive actions (opt-in)
+ # config.csrf_protection_enabled = false
end
# Optional: inherit from a host-app controller to plug into your existing auth.
@@ -140,6 +143,27 @@ If you don't need the job activity chart, disable it to skip chart queries entir
config.show_chart = false
```
+### CSRF Protection
+
+The dashboard's destructive actions (retry, discard, pause, resume, execute, reject, remove/prune workers) are all `POST` requests. By default CSRF protection is **disabled**, because the gem does not assume the host application has a session store (it works in API-only apps without one).
+
+If your host app has a session store and the dashboard is mounted on the same origin, you should enable CSRF protection:
+
+```ruby
+config.csrf_protection_enabled = true
+```
+
+When enabled:
+
+- All dashboard forms embed an `authenticity_token`, and `csrf_meta_tags` are added to the layout for JS/`fetch`-driven requests.
+- Unverified `POST` requests are rejected by Rails' standard `verify_authenticity_token` (returns `422 Unprocessable Entity`). Safe methods (`GET`/`HEAD`) pass through.
+
+Requirements:
+
+- The host app has a session store configured (e.g. `config.session_store :cookie_store`).
+- `config.api_only` is not enabled (or session middleware is otherwise present).
+- The dashboard is mounted on the same origin as the host app, so `form_authenticity_token` works.
+
### Authentication
By default, Solid Queue Monitor does not require authentication to access the dashboard. This makes it easy to get started in development environments.
diff --git a/app/assets/javascripts/solid_queue_monitor/application.js b/app/assets/javascripts/solid_queue_monitor/application.js
index 3f73653..421d4c9 100644
--- a/app/assets/javascripts/solid_queue_monitor/application.js
+++ b/app/assets/javascripts/solid_queue_monitor/application.js
@@ -322,7 +322,9 @@
function bulkSubmit(action, promptMsg) {
var ids = checkedBoxes().map(function (checkbox) { return checkbox.value; });
if (ids.length === 0 || !window.confirm(promptMsg)) return;
- Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"]')).forEach(function (input) { input.remove(); });
+ // Only clear previously-appended job id inputs. Other hidden inputs
+ // (e.g. the CSRF authenticity_token) must be preserved.
+ Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"][name="job_ids[]"]')).forEach(function (input) { input.remove(); });
form.action = action;
ids.forEach(function (id) { appendHidden('job_ids[]', id); });
form.submit();
diff --git a/app/controllers/solid_queue_monitor/application_controller.rb b/app/controllers/solid_queue_monitor/application_controller.rb
index 9a84c4a..6981c7f 100644
--- a/app/controllers/solid_queue_monitor/application_controller.rb
+++ b/app/controllers/solid_queue_monitor/application_controller.rb
@@ -13,7 +13,12 @@ class ApplicationController < SolidQueueMonitor.base_controller_class.safe_const
before_action :authenticate, if: -> { SolidQueueMonitor::AuthenticationService.authentication_required? }
layout 'solid_queue_monitor/application'
- skip_before_action :verify_authenticity_token
+
+ # CSRF protection is opt-in (config.csrf_protection_enabled). By default the
+ # token check is skipped so the dashboard works in hosts without a session
+ # store. When the host enables it, the standard verify_authenticity_token
+ # before_action runs and unverified POSTs are rejected.
+ skip_before_action :verify_authenticity_token, unless: -> { SolidQueueMonitor.csrf_protection_enabled }
def set_flash_message(message, type)
# Store in instance variable for access in views
diff --git a/app/controllers/solid_queue_monitor/assets_controller.rb b/app/controllers/solid_queue_monitor/assets_controller.rb
index 9d03c83..95f03d7 100644
--- a/app/controllers/solid_queue_monitor/assets_controller.rb
+++ b/app/controllers/solid_queue_monitor/assets_controller.rb
@@ -4,6 +4,10 @@ module SolidQueueMonitor
class AssetsController < ApplicationController
skip_before_action :authenticate, raise: false
+ # Public read-only assets: exempt from CSRF so the cross-origin JavaScript
+ # guard doesn't reject GETs for the JS asset when csrf_protection_enabled.
+ skip_forgery_protection
+
MIME_TYPES = { '.css' => 'text/css', '.js' => 'application/javascript' }.freeze
FINGERPRINT_PATTERN = /\A(?[A-Za-z0-9_]+)-(?[a-f0-9]+)(?\.css|\.js)\z/
diff --git a/app/helpers/solid_queue_monitor/application_helper.rb b/app/helpers/solid_queue_monitor/application_helper.rb
index 7efbea1..d4c15ed 100644
--- a/app/helpers/solid_queue_monitor/application_helper.rb
+++ b/app/helpers/solid_queue_monitor/application_helper.rb
@@ -25,6 +25,23 @@ def message_class(type)
type.to_s == 'success' ? 'message-success' : 'message-error'
end
+ # Hidden authenticity_token field for raw HTML POST forms.
+ # Renders nothing unless CSRF protection is enabled, so hosts without a
+ # session store are unaffected (form_authenticity_token needs a session).
+ def csrf_token_field_if_enabled
+ return ''.html_safe unless SolidQueueMonitor.csrf_protection_enabled
+
+ hidden_field_tag(:authenticity_token, form_authenticity_token)
+ end
+
+ # CSRF meta tags for JS/fetch-driven POSTs (defense in depth).
+ # Only emitted when CSRF protection is enabled, for the same reason.
+ def csrf_meta_tags_if_enabled
+ return ''.html_safe unless SolidQueueMonitor.csrf_protection_enabled
+
+ csrf_meta_tags
+ end
+
def queue_link(queue_name, css_class: nil)
return '-' if queue_name.blank?
diff --git a/app/views/layouts/solid_queue_monitor/application.html.erb b/app/views/layouts/solid_queue_monitor/application.html.erb
index cd827b7..d0a4c23 100644
--- a/app/views/layouts/solid_queue_monitor/application.html.erb
+++ b/app/views/layouts/solid_queue_monitor/application.html.erb
@@ -4,6 +4,7 @@
Solid Queue Monitor - <%= content_for?(:title) ? yield(:title) : 'Dashboard' %>
+ <%= csrf_meta_tags_if_enabled %>
<%= stylesheet_link_tag asset_url_for('application.css'), nonce: content_security_policy_nonce %>