Rails 8 today · bounded profile proposed next

The form is the allowlist.

SignedForm watches the fields Rails actually renders, authenticates that field tree, then permits only those fields on submission. When the corresponding options are configured, its policy can also bind the request destination and a rendered-view revision; those protections are not automatic.

01 · renderRails view declares fields→ 02 · authenticateHidden trust receipt→ 03 · submitStrong Parameters permits

The form is the permit—not the permission. Application authorization, value validation, and Rails CSRF protection still apply.

Works on current SignedForm master

One option keeps view and controller in sync.

The revived master is tested with Ruby 3.2–3.4 and Rails 8.0.5–8.1.3. Its modern entry point is real today; the SDB1 adapter later on this page is not.

01 · Viewactual master API
<%= form_with model: @user,
      signed: true do |form| %>
  <%= form.text_field :name %>
  <%= form.email_field :email %>
  <%= form.submit %>
<% end %>
02 · Controller hookactual master API
class ApplicationController < ActionController::Base
  include SignedForm::ActionController::PermitSignedParams
end
03 · Updateauthenticated allowlist
def update
  @user = User.find(params[:id])
  @user.update!(params[:user])
end

# name + email are permitted.
# an injected admin field is not.
Source statusThe Rails 8 revival lives on current SignedForm master, which is ahead of the older v0.5.0 tag. Pin the source you tested until a new release identifies these changes.

A trust receipt for one rendered form

The browser may carry it. It cannot rewrite it.

The current hidden token authenticates the Ruby policy assembled during rendering. The values below are illustrative: field-tree authentication is fundamental, while destination and view-revision checks apply only when their options are configured.

hidden inputform_signature
allowed fieldsuser[name] · user[email]
destination (optional)PATCH /users/42
view policy (optional)rendered template dependency digest
signaturelegacy HMAC-SHA1 compatibility field treeauthenticated before permit request targetchecked when destination binding is enabled view changeold digest refused after configured grace

Disabled controls are not submitted by browsers and are not added automatically. SignedForm’s real form.add_signed_fields API can declare a disabled or JavaScript-added field explicitly.

Go ahead—tamper with it

Four attacks. Policy-dependent outcomes.

SignedForm separates harmless extra input from attempts to enlarge the authenticated permit. Replay protections depend on which destination and revision options the application enables.

Filtered

Add admin=true

The token still verifies, but admin was never rendered into the signed allowlist. Strong Parameters drops it.

Rejected

Edit the allowlist

Changing the token to claim admin changes authenticated bytes. Signature verification fails before the policy is loaded.

Conditional

Replay another route

With method and destination binding enabled, a form rendered for /users/42 cannot authorize /admin/users/42; a different effective HTTP method fails too. Without that option, this control is absent.

Conditional

Submit an old view

When view-revision policy is enabled and the rendered dependencies change, the configured grace period protects in-flight forms, then the old digest is refused. This is revision rejection, not a general token expiry.

Security boundarySigned fields constrain parameter names, not values or business authority. Keep model validation, record-level authorization, CSRF checks, rate limits, and session policy.

Exact current compatibility wire

Useful Rails behavior, legacy Ruby bytes.

The August 2026 master deliberately preserves the historical token so already-rendered forms continue to verify.

Signercurrent implementation
data = Base64.strict_encode64(
  Marshal.dump(policy)
)

signature = HMAC_SHA1(secret, data)
token = "#{data}--#{signature}"
Verifierimportant ordering
data, signature = token.split("--", 2)

verify_hmac_sha1!(signature, data)
policy = Marshal.load(
  Base64.strict_decode64(data)
)
Authenticated policyRuby-specific
{
  "user" => [:name, :email],
  _options_: {
    method: :patch,
    url: "/users/42",
    digest: Digestor.new(...)
  }
}
No compatibility claim yetA current SignedForm token is not a SignedData token. It is Base64-wrapped Marshal data followed by a hexadecimal HMAC-SHA1. Verification correctly precedes Marshal.load, but the payload remains Rails/Ruby-specific.

Proposal · not implemented in SignedForm

Keep the Rails API. Replace the object wire.

SFP1 is a proposed bounded binary form-policy payload carried unchanged by SDB1. It is specification work, not a claim that either repository ships a Rails adapter.

HTML carriersf1.<base64url of complete SDB1 record>
SDB1 purposesigned-form/submission/1
critical schemaurn:signed-form:policy:1
raw payloadSFP1 fixed header + bounded field paths
cryptoHMAC-SHA-256 for ordinary Rails payloadno Marshal, JSON, or inner Base64 policymethod · target · view · field kinds limits8 KiB policy · 128 fields
Raw inside, text-safe outsideA normal HTML hidden input cannot carry arbitrary binary. The proposed sf1. carrier Base64url-encodes the complete SDB1 record only at that presentation edge. SDB1 still authenticates the exact SFP1 payload bytes without transcoding them. Binary transports can send the record directly.
Rails viewunchanged surface
<%= form_with model: @user,
      signed: true do |form| %>
  <%= form.text_field :name %>
  <%= form.email_field :email %>
<% end %>
Proposed codecillustrative, not shipped
policy = SignedForm::PolicyV1.build(fields)

record = SignedData.sign_binary(
  policy,
  signer: rails_hmac_sha256,
  purpose: "signed-form/submission/1".b,
  content_type: "application/vnd.signed-form.policy".b,
  schema_id: "urn:signed-form:policy:1".b,
  issuer: "rails:accounts-production".b,
  issued_at: now,
  expires_at: now + 5.minutes
)

"sf1.#{base64url(record)}"
Proposed verifierstrict profile policy
verified = SignedData.verify_binary(
  record,
  signer: trusted_rails_key,
  purpose: "signed-form/submission/1".b,
  content_type: "application/vnd.signed-form.policy".b,
  schema_id: "urn:signed-form:policy:1".b,
  issuer: "rails:accounts-production".b,
  require_expiry: true,
)

policy = PolicyV1.parse(verified.payload)

An explicit, downgrade-safe transition

New forms move forward. Old forms get a deadline.

Format selection belongs to trusted SignedForm configuration. An incoming token never gets to negotiate a weaker verifier.

  1. Deployrender = sf1new onlyStart emitting only proposed SFP1-over-SDB1 tokens.
  2. Dispatchprefix == "sf1."strictMalformed sf1. is rejected; it never falls back to legacy.
  3. Bridgelegacy_accept_untilboundedKeep the old verifier isolated for an explicit operational window.
  4. Retirelegacy = offfinalReject old forms and retire the domain-separated legacy key.
The digest grace period is not a token lifetimeA legacy form whose view never changes can remain valid indefinitely. Migration therefore needs an explicit server-side cutoff; it cannot infer safety from the current five-minute digest grace.

One stack, three owners

Keep each security decision where it belongs.

The profile becomes easier to trust when the generic envelope does not absorb Rails semantics.

SignedData owns
  • SDB1 framing and exact signing input
  • algorithm and key identity
  • purpose, issuer, time, and critical extensions
  • bounded verification before payload use
SignedForm owns
  • SFP1 field paths and leaf kinds
  • optional HTTP method and destination binding
  • optional view-revision policy
  • HTML carrier and legacy migration
Application owns
  • authorization and Rails CSRF policy
  • value and model validation
  • keys, rotation, replay state, and cutoffs
  • business consequences after verification
Still uncoupled todayThe design can be specified and fixture-tested without importing either repository today. SignedData remains a raw-byte envelope; SignedForm remains the Rails integration and migration owner.