Context: Why Turnstile For Business?
A client asked for help because he was targeted by a bot that would sign up with “legit” google emails.
This was critical for the business because these emails would end up in the marketing email system.
When new marketing emails were sent, some would get rejected, causing the business’s domain to lose authority and making new email campaigns less effective.
Ultimately, this would harm the business by reducing conversions rates and making any diagnosis unclear in Stripe dashboards.
What tripped him up was the reason for the email server rejection: “Inbox full”.
This probably means that the hacker had a database of “clean” emails that were used in many attacks, leading to the inboxes being filled.
We were considering three options:
- Fail2Ban
- Captcha
- Cloudflare Turnstile
In our case, Fail2Ban was not an option since it was not the same email or IP that was abusing us.
Captcha is nice, but Cloudflare Turnstile is free.
Also, this might be just personal, but I can’t stand failing Captcha anymore…
I personally end up trusting websites that have turnstiles more than captcha.
How does Turnstile work?
In simple words:
- Get your private and public keys.
- Add a script to your browser.
- The script will look for a specific class in your document.
- Render the widget.
- The widget will fetch the Cloudflare API to get a signed token using the public key.
- Insert the signed token in the form so that it’s present when the user submits the form.
- The user clicks the submit button and in your controller, you make an additional request to check the token submitted with a private key.
- If the challenge passes, you proceed with the action, else you send the cops.
In one image:

Implementation
Create your credentials
Create your account if none, log-in and create your site:

Then just leave everyting to default and add your site name and domain.
These are your keys:

Now just invest 10 minutes in the Cloudflare docs:
Adding the JS
Simple, add in app/views/layouts/application.html in the head tag:
<%=
javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js",
"data-turbo-track": "reload",
defer: true
%>
Protect your form
In your form, add this HTML with the data-sitekey:
<div
class="cf-turnstile mb-3"
data-controller="cloudflare-turnstile"
data-sitekey="<%= Rails.application.credentials.dig(:cloudflare_turnstile, :public_key) %>"
data-callback="cloudflareTurnstileCallback">
</div>
Render the widget with Turbo
// /app/javascript/controllers/cloudflare_turnstile_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="cloudflare-turnstile"
export default class extends Controller {
connect() {
// window.turnstile is available thanks to the js script you added in the head.
if (document.querySelector('.cf-turnstile') && window.turnstile) {
window.turnstile.render(".cf-turnstile")
}
}
}
This will allow you to keep the Turnstile widget alive on page navigations.
Keep an eye on the data-callback for later — we use it again on the static-site
variant at the bottom of this post.
Validate the token
Now, once the user clicks on the checkbox of the widget, it will insert a token that needs to be validated in the action that our form is targeting.
In our case, it’s the sign-up form in Devise, which is this controller:
# app/controllers/users/registrations_controller.rb
module Users
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :validate_cloudflare_turnstile, only: :create
# Your controller logic
def validate_cloudflare_turnstile
validation = TurnstileVerifier.check(params[:"cf-turnstile-response"], request.remote_ip)
return if validation
# If validation fails, we set our resource since this code is executed
# in a `prepend_before_action`
self.resource = resource_class.new sign_up_params
resource.validate
set_minimum_password_length
respond_with_navigational(resource) { render :new }
end
end
end
NOTE: To have the class above in your app, you need to do the following:
Execute this command:
rails g devise:controllers users -c=registrations
In your routes, add:
devise_for :users, controllers: { registrations: 'users/registrations' }
# app/models/turnstile_verifier.rb
require "net/http"
class TurnstileVerifier
PRIVATE_KEY = Rails.application.credentials.dig(:cloudflare_turnstile, :private_key)
# Catch relevant network errors only.
NETWORK_ERRORS = [Errno::ETIMEDOUT]
# Set max retries here.
MAX_RETRIES = 3
# Syntactic sugar syntax to avoid initialization in the controller
def self.check(payload, client_ip)
new(payload, client_ip).check
end
def initialize(payload, client_ip)
@payload = payload
@client_ip = client_ip
end
def check
return false unless @payload
result = request_verification
result["success"]
end
private
def request_verification
attempts ||= 0
verification_url = URI("https://challenges.cloudflare.com/turnstile/v0/siteverify")
response = Net::HTTP.post_form(verification_url,
secret: PRIVATE_KEY,
response: @payload,
remoteip: @client_ip
)
JSON.parse(response.body)
rescue *NETWORK_ERRORS
retry if (attempts += 1) < MAX_RETRIES # Retry mechanism
end
end
NOTE: A non ActiveRecord class in the models?!
Yes, and it’s okay. Read some gems, and you will notice that people do it. You don’t necessarily need everything to be in a Service namespace.
How does it work in this blog?
Well, this blog is not in Rails.
It’s statically generated with minimal JavaScript.
And I use Cloudflare Workers when I need a backend call, because it’s free and sufficient.
In my case, this is what I’m doing:
<!-- Rest of the form... -->
<div class="cf-turnstile flex justify-center" data-sitekey="my_public_key"
data-callback="turnstileCallback">
</div>
<!-- Rest of the form... -->
NOTE:
turnstileCallbackcan be used in the Stimulus controller.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["email", "fields", "thankyou", "submit"]
connect() {
// Setting variables
window.turnstileCallback = (token) => {
this.token = token
this.fieldsTarget.classList.remove("hidden")
}
}
onChange(event) {
// More ui logic
}
async submit(event) {
event.preventDefault()
if (this.emailTarget.value) {
await fetch(this.url, {
cors: "no-cors",
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// rest of the payload
token: this.token
}),
});
// More ui logic
} else {
alert("Enter your email first ;)")
}
}
}
NOTE: The following code is in a Cloudflare Worker.
// Worker config above..
app.post('/api/leads', async c => {
// Database logic here...
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const outcome = await result.json();
if (!outcome.success) {
c.status(422)
console.log("NOOP")
return c.text("No monsieurs")
}
// Proceed with database logic here...
})
export default app
Same architecture, different execution.
Conclusion
One of the cool things about Turnstile is that it gives you extra data to understand user behavior:

This helps you see how much cheating is happening in your lead collector and make sure you collect quality emails for your sales and marketing pipelines.
Alright, your turn — enter your email to prove that you are not an AI bot.
Roland
Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.
Design Patterns in Rails
When and how to use Singleton, Factory, and other design patterns in Rails. Concrete examples, anti-patterns to avoid, and a rule of thumb for overuse.
Ruby on Rails and the Art of Object-Oriented Design
Rails' conventions guide you toward design patterns, but real maintainability comes from applying SRP, delegation, and SQL-first thinking from day one.
Best Practices for Managing Gems in Your Rails Application
Screen gems with the TAM method, modify safely via monkey patches or forks, and keep your Gemfile lean - the rules pros use to keep Rails apps healthy.