<%= form_with model: @user,
signed: true do |form| %>
<%= form.text_field :name %>
<%= form.email_field :email %>
<%= form.submit %>
<% end %>
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.
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.
class ApplicationController < ActionController::Base
include SignedForm::ActionController::PermitSignedParams
end
def update
@user = User.find(params[:id])
@user.update!(params[:user])
end
# name + email are permitted.
# an injected admin field is not.
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.
form_signatureuser[name] · user[email]PATCH /users/42rendered template dependency digestDisabled 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.
Add admin=true
The token still verifies, but admin was never rendered into the signed allowlist. Strong Parameters drops it.
Edit the allowlist
Changing the token to claim admin changes authenticated bytes. Signature verification fails before the policy is loaded.
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.
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.
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.
data = Base64.strict_encode64(
Marshal.dump(policy)
)
signature = HMAC_SHA1(secret, data)
token = "#{data}--#{signature}"
data, signature = token.split("--", 2)
verify_hmac_sha1!(signature, data)
policy = Marshal.load(
Base64.strict_decode64(data)
)
{
"user" => [:name, :email],
_options_: {
method: :patch,
url: "/users/42",
digest: Digestor.new(...)
}
}
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.
sf1.<base64url of complete SDB1 record>signed-form/submission/1urn:signed-form:policy:1SFP1 fixed header + bounded field pathssf1. 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.<%= form_with model: @user,
signed: true do |form| %>
<%= form.text_field :name %>
<%= form.email_field :email %>
<% end %>
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)}"
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.
- Deploy
render = sf1new onlyStart emitting only proposed SFP1-over-SDB1 tokens. - Dispatch
prefix == "sf1."strictMalformedsf1.is rejected; it never falls back to legacy. - Bridge
legacy_accept_untilboundedKeep the old verifier isolated for an explicit operational window. - Retire
legacy = offfinalReject old forms and retire the domain-separated legacy key.
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.
- SDB1 framing and exact signing input
- algorithm and key identity
- purpose, issuer, time, and critical extensions
- bounded verification before payload use
- SFP1 field paths and leaf kinds
- optional HTTP method and destination binding
- optional view-revision policy
- HTML carrier and legacy migration
- authorization and Rails CSRF policy
- value and model validation
- keys, rotation, replay state, and cutoffs
- business consequences after verification