RL ROLAND LOPEZ
// 6 min read

Ruby on Rails Cloudflare Turnstile Tutorial

Ruby on Rails Cloudflare Turnstile Tutorial — Stop bot signups in Rails with Cloudflare Turnstile. Step-by-step setup with Devise, Stimulus, Turbo, and a verifier model - keep your email authority clean.

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:

  1. Get your private and public keys.
  2. Add a script to your browser.
  3. The script will look for a specific class in your document.
  4. Render the widget.
  5. The widget will fetch the Cloudflare API to get a signed token using the public key.
  6. Insert the signed token in the form so that it’s present when the user submits the form.
  7. The user clicks the submit button and in your controller, you make an additional request to check the token submitted with a private key.
  8. If the challenge passes, you proceed with the action, else you send the cops.

In one image:

Turnstile overview

Implementation

Create your credentials

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

Turnstile dashboard - create site Turnstile dashboard - site settings

Then just leave everyting to default and add your site name and domain.

These are your keys:

Turnstile dashboard - site 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: turnstileCallback can 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:

Turnstile dashboard - analytics

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

Roland Lopez
Written by
Roland Lopez

Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.

Built by Agent Skynet See the agency