Security & threat model
Every reactive action is a browser-reachable RPC. phlex-reactive makes the safe path the default, but you own the authorization boundary. Read this once.
What the signature guarantees (and what it doesn't)#
The DOM token is a MessageVerifier-signed payload:
- Record-backed:
{ "c" => "Todos::Item", "gid" => "gid://app/Todo/42" } - State-backed:
{ "c" => "Counter", "s" => { "count" => 3 } } - Record + state:
{ "c" => "Fields::InlineEdit", "gid" => "gid://app/User/7", "s" => { "attribute" => "name", "editing" => true } }
When a component declares both reactive_record and reactive_state, the record's GlobalID and the declared state are signed into one payload — so the transient mode (e.g. which column an inline edit may write) is tamper-proof alongside the record.
Guarantees (tampering any of these fails verification → HTTP 400):
- The component class can't be swapped (can't point a
Todotoken at anAdminUsercomponent). - The record's GlobalID can't be swapped or forged.
- State values can't be edited — including state signed alongside a record (the client can't switch an inline edit's
attributeto a column it shouldn't touch).
Does NOT guarantee: that this user may act on this record. The signature proves the token is one we minted, not that the current session is allowed to mutate the target. You must authorize.
Rule 2 — actions are default-deny, keep it that way#
Only methods declared with action :name are invokable. Don't declare an action you don't intend to expose. Public methods without action are unreachable, but don't rely on obscurity — declare narrowly.
Rule 3 — params are schema-coerced, declare them#
action :rename, params: { title: :string }
def rename(title:) = @todo.update!(title:) # only `title`, cast to StringAnything not in the schema is dropped before reaching your method, so a malicious { admin: true, title: "x" } body can't mass-assign. Never do @record.update!(params) — take explicit, declared params.
The schema is compiled once at declaration: a typo'd type symbol raises Phlex::Reactive::UnknownParamType at class load rather than silently coercing to a string at click time. A declared value that can't be coerced to its type (a bad :date/:decimal, a non-file for :file) is dropped — never fabricated — so the action sees its keyword default, not a bogus value.
Custom validators are a dropping type (Phlex::Reactive.param_type). The built-in coercions aren't the whole surface — you can register your own typed validator in an initializer. The block receives the raw client value and returns the coerced value, or Phlex::Reactive::ParamSchema::DROP to reject it. A drop keeps the exact same contract as a built-in: the keyword default applies, so a rejected value is never fabricated — it's simply absent.
Phlex::Reactive.param_type(:money) do |value|
/\A\d+(\.\d{1,2})?\z/.match?(value.to_s) ? BigDecimal(value) : Phlex::Reactive::ParamSchema::DROP
end
# then, in any component — validated at declaration like any built-in:
action :charge, params: { amount: :money }
def charge(amount:) = @invoice.charge!(amount) # a BigDecimal, or unset if rejectedThis makes the schema a validation boundary, not just a coercion one: a security-relevant rule (a currency shape, an allow-listed enum, a bounded integer) lives in the type and rejects a bad value with DROP before it reaches your method. DROP is a public sentinel exactly so custom types can use it. The registry is frozen after boot — register during initialization only; a runtime param_type call raises.
Rule 4 — don't put secrets in state-backed tokens#
State-backed tokens are signed (tamper-proof) but not encrypted — the values are readable in the DOM (base64). Don't sign secrets into reactive_state. For anything sensitive, use reactive_record (only the GlobalID is exposed) and read the sensitive data server-side.
CSRF and authentication#
The action endpoint inherits from Phlex::Reactive.base_controller_name (default ActionController::Base). For a real app:
Phlex::Reactive.base_controller_name = "ApplicationController"This gives you CSRF protection (the client sends X-CSRF-Token) and your auth filters. Caveat: if you have public reactive components (e.g. on a logged-out page) and your ApplicationController force-redirects unauthenticated requests to a login page, the action POST will be redirected and silently fail. Either:
skip_before_action :authenticatefor the action endpoint (subclass it), or- keep public components state-backed and authorize per-action where it matters.
Two seams: HTTP-layer vs component-layer#
There are two places to wrap a reactive action, and they see different things. Reach for the one that matches your concern:
Base controller (base_controller_name) | Phlex::Reactive.around_action | |
|---|---|---|
| Layer | HTTP request | the resolved component action |
| Sees | headers, session, request | the component instance, action name, coerced params, request |
| Runs | full Rails filter chain | inside with_connection_id, outside the transaction |
| Use for | auth, CSRF, coarse per-IP rate limiting | audit logging, component-aware rate limiting, assertions |
Plain Rails around_action / rate_limit on a dedicated base controller already covers attributes, authentication, and coarse per-IP throttling. What that layer cannot see is the resolved component, the declared action name, or the coerced params — and it can't position itself inside the connection-id scope but outside the action's transaction. Phlex::Reactive.around_action is that component-aware seam:
# config/initializers/phlex_reactive.rb
Phlex::Reactive.around_action do |ctx, &action|
Rails.logger.tagged("reactive", "#{ctx.component.class}##{ctx.action_name}") do
# A rate-limit rejection here NEVER opens a transaction (this seam
# sits outside transaction_wrapper). Raise a registered error -> 403.
RateLimiter.check!(ctx.request.remote_ip, ctx.action_name)
result = action.call
AuditLog.record!(actor: Current.user, action: ctx.action_name)
result # <- REQUIRED: return the continuation's value (see below)
end
endctx is a frozen Phlex::Reactive::ActionContext — component, action_name, params (already schema-coerced; dropped keys are gone), and request. The fold sits after token verify, component resolution, default-deny, and param coercion, so a wrapper can never widen what's invokable.
The one contract that bites: each wrapper MUST return action.call's value. The endpoint type-checks the action's return for a Phlex::Reactive::Response (replace / remove / redirect / …); a wrapper that ends on its logger's return value instead silently downgrades every reply to the implicit self-replace. Name the result and return it, as above.
A wrapper raising an error registered in Phlex::Reactive.authorization_errors renders as 403 (with the same diagnostics as an in-action authorize!); an unregistered raise is a 500, exactly as today. Multiple wrappers nest in registration order — the last-registered runs outermost. Tests reset the stack with Phlex::Reactive.reset_around_actions!.
The endpoint's failure modes#
- Tampered / forged / expired token →
400 Bad Request(kind: :tampered) - Token class no longer resolves →
400 Bad Request(kind: :unknown_class) - Token class resolves but isn't a reactive component →
400 Bad Request(kind: :not_reactive_class) - Undeclared action →
403 Forbidden(kind: :forbidden) authorize!raised (registered error) →403 Forbidden(kind: :forbidden)- Record GlobalID no longer resolves →
404 Not Found(kind: :not_found)
The two 400s share a status but carry distinct diagnostics: :unknown_class (the token's class no longer constantizes — a component renamed/removed while a page was open) vs :not_reactive_class (it resolves but doesn't include Phlex::Reactive::Component). The verbose_errors body and the error_flash kind differ between them, so you can tell one from the other.
The client runtime logs non-OK responses, applies no DOM change, and dispatches a reactive:error lifecycle event so your UI can react — see below.
Every failure is warn-logged as [phlex-reactive] … in every environment. With Phlex::Reactive.verbose_errors on (the default in development and test via Rails.env.local?; off in production), the response also carries a plain-text diagnostic body — the client prints it via console.error — saying which failure this was: a tampered/stale token, a token class that no longer resolves (or one that isn't reactive), an undeclared action (listing the declared ones), a registered authorization error, or a GlobalID that no longer finds its record. Param coercion additionally logs every dropped key with its full bracketed path and reason (undeclared / uncoercible), with a hint when a flat name looks like the bracketed twin of a declared nested key. The flag never changes a status — only the body and the logs.
Failure UX — the lifecycle events#
The generic controller dispatches three bubbling, composed CustomEvents around every action round trip, so an app can toast an error, veto a dispatch, instrument latency, or build retry UI without forking the controller:
reactive:before-dispatch— cancelable, fired before debounce/enqueue with{ action, params, element }.event.preventDefault()skips the round trip entirely (nothing is scheduled).reactive:applied— fired with{ action, params, html }once the streams were handed toTurbo.renderStreamMessage(Turbo applies them asynchronously — for post-morph timing listen to Turbo's own events).reactive:error— fired in every failure branch with{ action, params, kind, status?, body?, retry? };kindis one ofredirected | http | content-type | timeout | offline | network(all retriable) orapply— the server already completed the mutation but something AFTER the fetch threw inside the controller itself (a malformed response, a Turbo render error); it carries NOretry, since retrying would re-POST an action that already succeeded. A throwingreactive:appliedLISTENER is different — the DOM spec never propagates a listener's exception back todispatchEvent's caller, so it can't surface asreactive:errorat all; it just logs.retry()(when present) re-enters the request queue with the freshest token and freshly collected fields (and no-ops once the component left the DOM).
// A page-level toaster: one listener, or plain Stimulus composition —
// <body data-controller="toast" data-action="reactive:error->toast#show">
document.addEventListener("reactive:error", ({ detail }) => {
toast(`Action failed (${detail.kind}${detail.status ? ` ${detail.status}` : ""})`, {
onRetry: detail.retry, // re-enqueues with the freshest signed token
})
})The timeout and offline kinds bound the request itself. AbortSignal.timeout (default 30s, set <meta name="phlex-reactive-timeout">) aborts a hung request so one dead connection can no longer wedge the component's request queue; an offline click short-circuits BEFORE the fetch (the edit is never half-sent) and mirrors data-reactive-offline onto <html> as a pure CSS hook.
Showing the user a failure (error_flash, error bodies, dismiss_after)#
The lifecycle events are the hook; these three built-ins are the ready-made surface, all opt-in and status-preserving — no flag ever changes an HTTP status.
- In-action validation replies — a failure the action knows about returns a flash at 200:
reply.replace.flash(:error, "Title can't be blank"). Phlex::Reactive.error_flash— a->(kind) { "message" }lambda. When set, every endpoint rescue path (400/403/404) renders a turbo-stream flash the user sees, at the SAME status. It composes withverbose_errors(the flash wins the body, the diagnostic still logs) and degrades gracefully if the lambda raises (falls back to the bare/diagnostic body — never a 500).- Non-OK turbo-stream bodies are rendered — the client applies a turbo-stream error body instead of discarding it, and marks the root
data-reactive-error="<kind>"(styleable in pure CSS), cleared on the next success. dismiss_after:onreply.flash— a document-level handler removes the flash after the timeout, so it self-cleans reply AND broadcast flashes.
The exact kind symbols the lambda receives are the endpoint's server-side failure kinds — the same ones on the reactive_error path — so a complete mapping branches on all five:
:tampered— the token failed verification (a 400).:unknown_class— the token's component class no longer resolves (a 400).:not_reactive_class— it resolves but isn't a reactive component (a 400).:forbidden— an undeclared action, or a registeredauthorize!error (a 403).:not_found— the record's GlobalID no longer finds its row (a 404).
# config/initializers/phlex_reactive.rb
Phlex::Reactive.error_flash = ->(kind) do
case kind
when :not_found then "That item is no longer available."
when :forbidden then "You don't have permission to do that."
when :tampered, :unknown_class, :not_reactive_class
"This page is out of date — please refresh."
else "Something went wrong — please try again."
end
end
# In an action, a self-dismissing validation flash:
reply.replace.flash(:error, "Couldn't save — try again", dismiss_after: 4000)Token lifetime & rotation#
Tokens are signed with secret_key_base (or your Phlex::Reactive.verifier). They don't expire — the signing path (Phlex::Reactive.sign → verifier.generate(payload, purpose:)) threads no expires_in/expires_at, and expires_in is a per-message generate option, not a verifier-level setting. So swapping in a custom MessageVerifier alone won't impose expiry.
If you need expiry, include a server-checked timestamp in the signed state and reject a stale one inside the action — e.g. sign issued_at into reactive_state and compare it to Time.current before mutating. That puts the freshness check where the action already runs, at the same authorization boundary you own.
Rotating secret_key_base (or your verifier's secret) invalidates all outstanding tokens at once — open tabs must reload.
The signed payload is versioned (a "v" key, currently Phlex::Reactive::TOKEN_VERSION). This lets a future change to the token shape upgrade tokens already in flight instead of breaking every open page at deploy: verification runs the payload through an upgrader chain (oldest → current) before your component rebuilds from it. A token minted before versioning existed carries no "v" and is read as-is — introducing versioning invalidated nothing.
A token signed by a newer deploy than the running code (a rollback scenario) verifies its signature but carries a version this code doesn't understand. Rather than guess the newer shape, Phlex::Reactive.verify returns nil, so the endpoint answers 400 — the same path as a tampered token. Fail-closed, never fail-open.
Quick checklist#
- Every mutating action calls
authorize!(or is provably harmless). Phlex::Reactive.authorization_errorsincludes your authorizer's error.- Every action with input declares a
params:schema. - No
@record.update!(params)— only declared params. - No secrets in
reactive_state; sensitive data usesreactive_record. base_controller_nameset toApplicationController(CSRF + auth).- Public components don't get redirected to login on the action POST.