All articles Integration

One Trial per Device in Rails With Devise Registrations

Devise gives you registrations for free, and that is the problem. POST /users accepts any email that passes validation, and an abuser with a catch-all domain can create a new trial account every minute from the same machine. Email uniqueness is the only dedupe Devise does, and email is the cheapest identity there is.

This guide adds a device check to a Devise app: verify a Prynt requestId before the user is built, refuse or flag devices that already hold accounts, and link the new user id after the row commits so the next signup from that device sees it. If you haven’t wired the agent into Rails yet, start with adding device fingerprinting to Rails; this post assumes the Stimulus controller from that guide.

Put the requestId in the registration form

Generate the Devise views if you haven’t (rails g devise:views) and add a hidden field to registrations/new. The field sits outside the user scope so you don’t have to permit it through strong parameters:

<%= form_for(resource, as: resource_name, url: registration_path(resource_name),
      data: { controller: "prynt", prynt_key: "pk_live_…", action: "submit->prynt#attach" }) do |f| %>
  <%= render "devise/shared/error_messages", resource: resource %>
  <%= f.email_field :email, autocomplete: "email" %>
  <%= f.password_field :password, autocomplete: "new-password" %>
  <%= hidden_field_tag :prynt_request_id, nil, id: "prynt_id" %>
  <%= f.submit "Start free trial" %>
<% end %>

The Stimulus controller calls identify({ tag: { action: 'signup' } }), writes requestId into #prynt_id, and submits. If the agent is blocked by an extension, the field stays empty and the server decides what that means.

Install the Ruby SDK

# Gemfile
gem "prynt"
# config/initializers/prynt.rb
PRYNT = Prynt::Server.new(
  secret_key: Rails.application.credentials.prynt_secret_key, # sk_live_…
  timeout: 3
)

The SDK uses only the standard library. get_event returns a Hash; errors raise Prynt::Error with #status (0 when the API couldn’t be reached) and #code.

Override the registrations controller

Point Devise at your own controller:

# config/routes.rb
devise_for :users, controllers: { registrations: "users/registrations" }

Then check the device before Devise builds anything:

# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
  MAX_ACCOUNTS_PER_DEVICE = 1 # one trial per device
  REFUSAL = "We couldn't create an account from this device. If this is a mistake, contact support."

  before_action :check_signup_device, only: :create

  protected

  def build_resource(hash = {})
    super
    resource.prynt_request_id = @prynt_request_id
    resource.prynt_flag = @prynt_flag
  end

  private

  def check_signup_device
    @prynt_request_id = params[:prynt_request_id].presence
    return @prynt_flag = "missing_request_id" unless @prynt_request_id

    event = PRYNT.get_event(@prynt_request_id)
    others = event.dig("accountsOnDevice", "count").to_i

    if event["linkedId"].present?
      refuse!("request_id_reused")
    elsif event["decision"] == "block"
      refuse!("prynt_block")
    elsif others >= MAX_ACCOUNTS_PER_DEVICE
      refuse!("device_account_limit")
    elsif event["decision"] == "challenge"
      @prynt_flag = "prynt_challenge"
    end
  rescue Prynt::Error => e
    # 404: a requestId this key never issued (forged, or from another environment)
    return refuse!("event_not_found") if e.status == 404
    Rails.logger.warn("[prynt] unavailable: #{e.code}")
    @prynt_flag = "prynt_unavailable" # fail open
  end

  def refuse!(reason)
    Rails.logger.info("[prynt] signup refused: #{reason}")
    build_resource(sign_up_params)
    resource.errors.add(:base, REFUSAL)
    render :new, status: :unprocessable_entity
  end
end

Because before_action renders on refusal, Devise’s create never runs: no row, no confirmation email, no trial provisioned. render :new with :unprocessable_entity keeps Turbo happy and shows the message through Devise’s normal error partial.

A few choices here are worth spelling out:

  • event["linkedId"].present? catches a replayed requestId. Once you’ve linked an account to an event, the same id coming back on a different signup means someone copied it. Request ids don’t expire on their own, so this check is yours to make.
  • accountsOnDevice.count counts accounts you’ve linked on that device. With a limit of 1, the first signup passes and the second is refused.
  • Challenge becomes a flag, not a refusal. Prynt returns challenge for traffic that is suspicious but not conclusive. Let the account exist, mark it, and gate the expensive part of the trial.

Add two virtual attributes and a callback on the model:

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :confirmable, :validatable

  attr_accessor :prynt_request_id, :prynt_flag

  after_create_commit :link_prynt_device, if: -> { prynt_request_id.present? }

  private

  def link_prynt_device
    PryntLinkJob.perform_later(prynt_request_id, id.to_s, prynt_flag)
  end
end
# app/jobs/prynt_link_job.rb
class PryntLinkJob < ApplicationJob
  retry_on Prynt::Error, attempts: 5, wait: :polynomially_longer

  def perform(request_id, user_id, flag)
    PRYNT.update_event(request_id, linked_id: user_id)
    User.where(id: user_id).update_all(signup_review: flag) if flag
  end
end

Use after_create_commit, not plain after_create. The plain callback runs inside the transaction, so a failed validation on an associated record could roll the user back after you’ve already told Prynt it exists. The commit callback fires only once the row is durable.

Moving the PUT /v1/events/{requestId} call into a job keeps signup latency flat and gives you retries. The tradeoff is a short window where two signups racing from the same device both pass. If that matters, call update_event inline instead; it is one HTTP request.

The signup_review column is whatever your app uses for a review queue. Store the reason string, not a boolean, so an analyst can see why the account was flagged.

Gate the trial on the flag

Refusing at signup is the blunt tool. For flagged accounts, the softer move is to let them in but hold back what costs money:

class TrialsController < ApplicationController
  def start
    if current_user.signup_review.present? && !current_user.phone_verified?
      return redirect_to verify_phone_path, notice: "Verify your phone to unlock your trial."
    end
    current_user.start_trial!
  end
end

Shared office machines and family laptops are real, and a phone step is a small toll for them while being a real cost for someone creating their tenth account.

Before you turn on refusal

Ship with refuse! swapped for a flag for a week and look at the data. How many signups arrive without a requestId? How many devices already hold an account? That baseline tells you whether a limit of 1 is right or whether your audience shares devices more than you thought. Device-based signup limits goes deeper on picking the number, and stopping trial farming covers the broader playbook.

The full response fields are in the docs, and the Ruby SDK sits alongside the others on the SDKs page. Add the hidden field, add the before_action, link after commit, and Devise stops handing out a new trial to every new inbox.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading