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
end02The 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
end03Permit 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" }
end04The 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
- The Devise pin.
~> 4.9errors on Rails 8.1 before any of this code runs: Devise builds its route mappings during route load and 4.9.4 breaks under 8.1's lazy loading. Pin~> 5.0. - The hidden field arrives as a JSON string in
params[:user][:huma_session_data]. Parse it in the service; permitting a nested arbitrary hash through strong parameters is the wrong tool. - The rescue list matters.
OpenSSL::SSL::SSLError,Errno::ECONNRESETandErrno::ETIMEDOUTare real outage shapes; a list without them turns fail-open into a 500 mid-signup. - Routes need both halves: the controller subclass and the
devise_for ... controllers:entry. The class alone is never invoked. - The validation runs alongside the others, so the verify call fires even when email or password validation already failed. If that spend bothers you, check
errors.empty?first insideverify_human. - The 422 re-render assumes the Devise initializer's
config.responder.error_status = :unprocessable_entity, generated by default in new apps. Apps upgraded from old Devise may still respond 200 and re-render anyway.
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.
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.