Jeremy Smith on February 12, 2025

Electronic Signatures with Rails

I was recently exploring electronic signature solutions for a client project and found a nice approach using Stimulus, Active Storage, and Signature Pad. I investigated several Javascript signature libraries and found Signature Pad to have the smoothest looking signatures, with a solid API.

My goal was to wrap a good signature library in a Stimulus controller, and use a Rails form and Active Storage to upload and store the signature for the current user.

signature.png

Laying the Groundwork

To start, let’s assume we have a User model with one attached signature.

class User < ApplicationRecord
  has_one_attached :signature
end

I tend to avoid has_many_attached as much as possible, as in my experience, there’s a good chance that I’ll want to store additional information related to each file and that it needs it’s own model. For example, in this case, if I wanted to allow a user to have multiple signatures, I would probably create a Signature model belonging to the user, with one attached file hanging off of it. And it may be that I add a status field to allow certain signatures to be active, or a deleted_at field to handle soft-deleted records.

Next, let’s also assume we have a user settings page in our app, where we’ll add a form to upload the signature file. In the routes.rb file, we’ll need a settings resource, with a nested resource for the signature. Note that these are both singular resources, as the settings page is only for the current user, and the user only has one signature.

Rails.application.routes.draw do
  resource :settings, only: [:show] do
    resource :signatures, only: [:create, :destroy]
  end
end

When I first started working with Rails, I assumed that controllers should always map directly to Active Record models (maybe because this is what happens with scaffolding). But over time I realized that, while a CRUD mapping to a model is sometimes called for, there are many times where it’s not. When planning controllers, I think it’s best to start from the user interface, determine what discrete actions would be preferable, and then find a way to map those actions back to RESTful resource concepts, which may or may not be named after a particular Active Record model.

In this case, it doesn’t make sense to have a CRUD controller for users, as the current user shouldn’t have access to a list of all users, and should only ever be able to update their own record. Also, ideally we wouldn’t expose the user’s ID in the route. And it likely wouldn’t be a good user experience if we had one giant form to save all user information, from the email address, to the password and confirmation, to the signature upload. Each of those may have specific constraints that would be better handled through separate requests. So while there are no Settings and Signature Active Record models, we can use the concepts of settings and signatures as things we want to access and make requests for.

Here we have the settings controller, with only a show action.

class SettingsController < ApplicationController
  def show
    @user = Current.user
  end
end

We’ll also add a signatures controller which will only be used to set and delete the attached signature file.

class SignaturesController < ApplicationController
  def create
    if Current.user.update(signature_params)
      redirect_to [:settings], notice: "Signature saved"
    else
      redirect_to [:settings], alert: "Signature failed to save"
    end
  end

  def destroy
    Current.user.signature.purge
    redirect_to [:settings], notice: "Signature removed"
  end

  private

  def signature_params
    params.require(:user).permit(:signature)
  end
end

Setting Up Signature Pad

Stimulus wrappers for Javascript libraries are often pretty simple. I find that the most time-consuming part of creating one is simply coming to understand the underlying library and how best to work with it. I started by reviewing the Signature Pad README, playing with the demo, and reading through the demo’s source code to understand the implementation.

The demo provides functionality for the user to download the signature locally, but from the demo source code, I found that I could call toBlob() on the canvas element to build the file in memory. From there, I discovered I could use DataTransfer() to assign the file to a form input.

Now we have all the pieces needed to generate a signature, assign it to the form file input, and submit the form.

Here’s the Stimulus controller, which will be connected to the signature upload form, with targets for the <canvas> element and the file input field.

// signature_pad_controller.js

import { Controller } from "@hotwired/stimulus";
import SignaturePad from "signature_pad";

export default class extends Controller {
  static targets = ["canvas", "input"];

  connect() {
    this.signaturePad = new SignaturePad(
      this.canvasTarget,
      { dotSize: 3, minWidth: 1, maxWidth: 3 }
    );
  }

  disconnect() {
    this.signaturePad.off();
  }

  clear() {
    this.signaturePad.clear();
  }

  submit(event) {
    event.preventDefault();

    if (this.signaturePad.isEmpty()) {
      alert("Please add signature before saving.");
      return;
    }

    this.canvasTarget.toBlob((blob) => {
      const signatureFile = new File([blob], "signature.png", { type: "image/png" });
      const dataTransfer = new DataTransfer();

      dataTransfer.items.add(signatureFile);
      this.inputTarget.files = dataTransfer.files;

      event.target.submit();
    });
  }
}

The connect() method creates a new instance of SignaturePad for the <canvas> element and passes in some configuration options. If you had multiple use-cases for the signature pad and wanted configuration options to vary for each, you could use Stimulus Values to map to the configuration options. In this case, I only had one use-case for the signature pad, so it wasn’t worth the added complexity.

The disconnect() method unbinds event handlers set up by SignaturePad. And the clear() method clears the <canvas> so that a user can wipe the signature and start over if need be.

The submit() method stops the form submission and checks if the <canvas> is empty and alerts the user if so. Otherwise, the contents of the canvas are converted to a blob, assigned to the file input field, and then the form is submitted. This is just as if the user had selected an image from their own filesystem to upload.

Now let’s look at the ERB view. You’ll see we check if the user has a signature attached already. If so, we display it along with a delete button. If not, we display the signature upload form, wired up to the Stimulus controller, with the field field, the <canvas> for drawing the signature, followed by the save and clear buttons.

<%= tag.h3 "Signature", class: "text-gray-500 text-lg font-semibold mb-2" %>

<% if @user.signature.attached? %>
  <%= image_tag @user.signature, class: "max-w-[600px] rounded border border-gray-300 mb-2" %>

  <%= button_to "Delete Signature", [:settings, :signatures], method: :delete, class: "bg-red-500 hover:bg-red-700 text-white px-2 py-1 rounded cursor-pointer" %>
<% else %>
  <%= form_with model: @user, url: [:settings, :signatures], method: :post, data: { controller: "signature-pad", action: "signature-pad#submit" } do |f| %>
    <%= f.label :signature, class: "sr-only" %>
    <%= f.file_field :signature, data: { signature_pad_target: "input" }, accept: "image/png", hidden: true %>

    <%= tag.div class: "w-fit" do %>
      <%= tag.canvas width: 600, height: 200, data: { signature_pad_target: "canvas" }, class: "rounded border border-blue-500 mb-2 hover:bg-blue-50 cursor-pointer" %>

      <%= tag.div class: "flex justify-between" do %>
        <%= f.submit "Save Signature", class: "bg-gray-500 hover:bg-gray-700 text-white px-2 py-1 rounded cursor-pointer" %>

        <%= tag.button "Clear", type: "button", data: { action: "signature-pad#clear" }, class: "bg-gray-200 hover:bg-gray-300 text-gray-500 px-2 py-1 rounded" %>
      <% end %>
    <% end %>
  <% end %>
<% end %>

The Stimulus actions on the form and clear button don’t need to specify an event name, as they will use the default event for that element, thanks to Event Shorthand.

The file_field is set to hidden, as we don’t want users to be able to pick a file from their own filesystem. The Stimulus controller will take care of assigning the file blob to the input for us, but the user doesn’t need to see it. Also, we can use the file accept attribute to ensure the user can’t attach any other type of file to this input.

The <canvas> needs to have an explicit width and height set. Since I want the save and clear buttons to align to the left and right below it, I’m using the w-fit Tailwind class on the wrapping <div> to ensure it fits that size and doesn’t extend to the full width of the viewport.

Concluding Thoughts

While this solution is pretty solid, there are a few remaining details I would want to address to make this production-ready.

First, although we’ve restricted the input file type to PNG in the form markup, there’s no corresponding server-side check for the attachment file type. While it’s unlikely, it is possible that a user could post to the signatures endpoint and upload some other file type. Active Storage doesn’t provide much by way of validation. I sometimes resort to writing my own custom content type validator for attachments, though I’m still not sure it’s the best answer.

Second, the alert() raised if the <canvas> happens to be empty on submit isn’t a great user experience. It would probably be better to inject an error message into a <div> below the signature pad, or tie into an global feedback mechanism on the page, such as a toast system.

Third, with a defined width and height, this solution doesn’t provide support for resizing of the <canvas> for smaller breakpoints. And apparently, when you resize a canvas, the contents will be cleared by the browser. The README suggests reading out the image contents before resizing the element, and then writing them back afterward.

Finally, in practice, signature files are best if they’ve been cropped exactly to size, with surrounding whitespace removed. Otherwise, it’s difficult to size and position them consistently in HTML or PDF renderings, for example. Signature Pad doesn’t support a trim feature, but provides a few suggestions on how to achieve this, whether client-side or server-side.


Need help building or maintaining a Rails app?

Book a one-hour project inquiry call or reach out via email.