Guide · Rails 8.1 + Devise 5

In Rails, the model refuses
and Devise renders the refusal.

One validation with on: :create, one virtual attribute, one permitted parameter. The rejection shows up in the same error list as a taken email, with the standard 422 and Turbo re-render, and Devise's controller keeps doing what it already did. Also: the one Gemfile pin that decides whether any of this runs on Rails 8.1.

01The verifier

# Gemfile — the pin matters on Rails 8.1:
#   gem "devise", "~> 5.0"
# Devise 4.9.4 errors under Rails 8.1's lazy route loading (devise#5800);
# 5.0 is the release with Rails 8.1 support.

# app/services/huma_verifier.rb  (stdlib Net::HTTP, no extra gem)
require "net/http"
require "json"

class HumaVerifier
  ENDPOINT = URI("https://humaverify.com/api/v1/verify")

  # Returns the parsed verify response, or nil on outage (fail open).
  def self.call(user_id:, session_data:)
    session_data = JSON.parse(session_data) if session_data.is_a?(String)

    http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
    http.use_ssl      = true
    http.open_timeout = 2  # the ceiling this adds to every signup on outage
    http.read_timeout = 3

    request = Net::HTTP::Post.new(ENDPOINT, {
      "Authorization" => "Bearer #{Rails.application.credentials.dig(:huma, :api_key)}",
      "Content-Type"  => "application/json"
    })
    request.body = { userId: user_id, sessionData: session_data }.to_json

    response = http.request(request)
    return nil unless response.is_a?(Net::HTTPSuccess)  # non-2xx: fail open
    JSON.parse(response.body)
  rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError,
         Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, JSON::ParserError
    nil  # our outage must not become your outage
  end
end

02The model

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

  # Virtual attribute: no migration, and absent on every save path except
  # the signup form (console, seeds, OAuth callbacks, tests).
  attr_accessor :huma_session_data

  validate :verify_human, on: :create

  private

  def verify_human
    verdict = HumaVerifier.call(user_id: email, session_data: huma_session_data)
    return if verdict.nil?  # outage: fail open
    unless verdict["human"]
      errors.add(:base, "We couldn't verify this signup. Please try again.")
    end
  end
end

03Permit the parameter

# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]

  protected

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [:huma_session_data])
  end
  # No #create override needed: Devise's create is build_resource then
  # resource.save, so the on-:create validation runs automatically.
end

# config/routes.rb — BOTH halves are required, the class alone does nothing:
Rails.application.routes.draw do
  devise_for :users, controllers: { registrations: "users/registrations" }
end

04The form carries the signals

<%# app/views/devise/registrations/new.html.erb %>
<script src="https://humaverify.com/huma.js"></script>
<%= form_for(resource, as: resource_name,
             url: registration_path(resource_name),
             html: { data: { controller: "huma" } }) do |f| %>
  <%= f.hidden_field :huma_session_data, data: { huma_target: "field" } %>
  <%# ... email / password fields ... %>
<% end %>

// app/javascript/controllers/huma_controller.js  (Stimulus)
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static targets = ["field"]
  connect() {
    this.element.addEventListener("submit", () => {
      if (window.Huma) {
        this.fieldTarget.value = JSON.stringify(window.Huma.debug().features)
      }
    })
  }
}

What bites

Why this instead of a CAPTCHA

A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.

Next.jsWordPressClerkAuth0Supabase AuthAuth.js / NextAuthFirebase Auth

Rails for the app, useHUMA for humanity.

Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.

Get your API key →

The Devise 5 requirement, the controller override point and the form facts on this page were checked against Rails and Devise documentation and source on 26 Aug 2026. If something has drifted, tell us at team@humaverify.com and it gets fixed.

Help