Cloudflare Turnstile for spam prevention in Rails

By Exequiel Rozas

- June 02, 2025

Deploying an application to production is usually an enriching experience: real people can use and enjoy what you've built.

Unfortunately, bad actors are a part of the internet and can be harmful if left uncontrolled.

In this article, we will learn how to add Cloudflare Turnstile to a Rails application to prevent or mitigate unwanted or malicious requests to parts of our application.

Let's start by configuring everything on the Cloudflare side:

What we will build

To showcase the Cloudflare Turnstile, we will build a simple application with a signup form to show how we can integrate the widget to any form in our application.

We will add a Turnstile widget to each one of these forms and make two verifications:

  • A client verification which is performed automatically by the widget in which Cloudflare determines if a user is human and returns a token.
  • A server-side verification, where we will use the token and verify it against Cloudflare's servers from our server. Only if this verification passes, we can consider the form safe for submission.

The final result will look like this:

Successful signup with Cloudflare Turnstile

Cloudflare setup

You are welcome to skip the application setup if you already have the keys needed to set the Turnstile.

To set things up on Cloudflare's side, we need an account and to have the site we want to protect with Turnstile added to our lists of sites in the dashboard.

Our dashboard should look like this:

Cloudflare dashboard with added website

Then, we locate the Turnstile link in the sidebar and navigate there:

Turnstile Overview Cloudflare dashboard

We are then presented with the Turnstile overview, where we have to click on the Add Widget button:

Turnstile overview  add widget

Next, we add information about the Turnstile and our site:

Turnstile configuration

We gave the widget a test name of Avo Turnstile Test, added a hostname which should correspond to a site we've already added to Cloudflare, and then, for the Widget Mode, we chose the Managed option where Cloudflare picks information about the user and decides whether to show the widget or not while validating whether the visitor is human.

There's also a question of whether we want to opt for pre-clearance for the site, which means that, based on the previous security clearance of a given user, the Turnstile can be avoided.

Pre-clearance question Cloudflare Turnstile configuration

I left No as it's the default option, and it marginally decreases the possibility for spam.

Finally, after clicking on the Create button, we get the SiteKey and SecretKey values:

Turnstile site key and secret key values

Keep those values safely stored in your Rails credentials or environment variables if that's what you prefer.

Please note that if we want to test the implementation locally, we need to tunnel your local environment to the internet and expose it using a valid domain. In my case, I'm using a domain I own, but we could also use services like ngrok to achieve the same result.

Now that we have everything set up on Cloudflare, let's set up our application:

Application Setup

We will build a simple application that has a comment form and a signup form using the Rails auth generator, where we will add the Cloudflare Turnstile to avoid unwanted submissions.

First, generate a new Rails application:

rails new turnstile --css=tailwind --javascript=esbuild

Let's add authentication like we did in the tutorial:

bin/rails generate authentication

Finally, we will add the Cloudflare keys to our credentials:

EDITOR=vim bin/rails credentials:edit --environment=development

With the following content:

cloudflare:
  turnstile_site_key: YOUR_SITE_KEY
  turnstile_secret_key: YOUR_SECRET_KEY

Finally, we will grab the Turnstile's script tag and add it to our application layout:

<%# app/views/layouts/application.html.erb %>
<%= javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", "data-turbo-track": "reload", defer: true %>

Don't forget to set the data-turbo-track attribute to reload. Otherwise, you might encounter issues with the script when navigating through the application.

Adding the Turnstile to our application

The verification process for the Turnstile has two phases:

  • Client-side verification: the widget is rendered and using the SiteKey value, an <iframe> is loaded from challenges.cloudflare.com that runs the challenge on the user's browser and provides a token which is stored in a hidden input with the cf-turnstile-response name.
  • Server-side verification: using the token provided by the widget, we make a POST request to Cloudflare's siteverify service where we pass the SecretKey we obtained before, the token and the request's IP. If this verification passes, we can safely continue with the form submission or action we were performing.

However, the widget rendering phase can be done implicitly by the JS we included in the application layout by defining an element with the .cf-turnstile class and also passing an onload parameter to the JS with a function name that will be run whenever the widget is loaded.

This is fine, but we will render the widget explicitly, and that's why we passed the render=explicit param.

To achieve the explicit rendering, we start by adding the HTML to the form we wish to protect from spam:

<div 
  class="cf-turnstile mt-4" 
  data-sitekey="<%= Rails.application.credentials.dig(:cloudflare, :turnstile_site_key) %>"
  data-size="flexible"
  data-theme="light"></div>

If we want the widget to occupy the full width of its container, we need to pass flexible to the size attribute. The same with the theme which can be light, dark or auto.

Now, we add the JavaScript that will render the widget:

// app/javascript/application.js
document.addEventListener("turbo:load", function() {
  const turnstileElement = document.querySelector(".cf-turnstile")

  if (turnstileElement && window.turnstile) {
    const siteKey = turnstileElement.dataset.sitekey

    window.turnstile.render(turnstileElement, {
      sitekey: siteKey,
    })
  }
})

As you can see, we evaluate the code after the turbo:load event, and we're querying for the turnstileElement which is the <div> we previously defined.

If that element and the window.turnstile are present, we render the widget by calling render on window.turnstile and passing the siteKey which we extract from the data attributes in the container div.

Now, if we load our form, we should see the following:

Cloudflare Turnstile explicit rendering

If we inspect the HTML after the Success message is shown, we can see the hidden input with the token:

Hidden input added by Turnstile

Now, if we submit the form, we can get the token from the params.

Let's add server-side validation to complete the feature:

Server-side validation

Let's create a class that's responsible for making the requests to Cloudflare and validating tokens:

# lib/turnstile_verifier.rb
class TurnstileVerifier
  URI = URI("https://challenges.cloudflare.com/turnstile/v0/siteverify")
  MAX_RETRIES = 3

  attr_reader :token, :ip

  def initialize(token, ip)
    @token = token
    @ip = ip
  end

  def verify
    return false if token.blank?

    MAX_RETRIES.times do |attempt|
      begin
        response = send_request

        if response["success"] == true
          return true
        else
          Rails.logger.warn "Turnstile verification failed: #{response["error-codes"].inspect}"
          sleep(2**attempt) if attempt < MAX_RETRIES - 1 
        end
      rescue StandardError => e
        Rails.logger.error "Turnstile verification error (attempt #{attempt + 1}/#{MAX_RETRIES}): #{e.message}"
        sleep(2**attempt) if attempt < MAX_RETRIES - 1
      end
    end

    false
  end

  private

  def send_request
    response = Net::HTTP.post_form(URI, {
      secret: secret_key,
      response: token,
      remoteip: ip
    })

    JSON.parse(response.body)
  end

  def secret_key
    Rails.application.credentials.dig(:cloudflare, :turnstile_secret_key).tap do |key|
      raise "Turnstile secret key is not configured" if key.blank?
    end
  end
end

This class receives a token and an ip and produces a boolean value when we call the verify method.

It also includes a basic retry logic with exponential back off, which re-attempts the requests at an increasing time frame to improve the chances of getting a successful response.

With that class defined, we then add a verify_turnstile action to our controller which is responsible for returning a flash message with an indication to complete the Turnstile challenge or else continue with the execution of the original action:

class RegistrationsController < ApplicationController
  allow_unauthenticated_access only: [:new, :create]
  before_action :verify_turnstile, only: [:create]

  # Rest of the code

  private
  def verify_turnstile
    turnstile_token = params["cf-turnstile-response"]
    is_valid =  TurnstileVerifier.new(turnstile_token, request.remote_ip).verify

    return if is_valid
    flash[:alert] = "Please complete the turnstile challenge to create an account."
    render :new, status: :unprocessable_entity
  end
end

Now, if the Turnstile is approved on the client side, we should be able to submit our registration form and get the following result:

Successful signup with Cloudflare Turnstile

Extracting behavior

Now that we have the feature working, let's extract the widget's HTML to a partial and add a Stimulus controller to handle rendering:

<%# app/views/shared/_cloudflare_turnstile.html.erb %>
<div data-controller="turnstile"
  class="mt-4"
  data-turnstile-sitekey-value="<%= Rails.application.credentials.dig(:cloudflare, :turnstile_site_key) %>"
  data-turnstile-size-value="flexible"
  data-turnstile-theme-value="light">
  <div data-turnstile-target="turnstile"></div>
</div>

Then, we move the behavior to a Stimulus controller:

// app/javascript/controllers/turnstile_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    sitekey: String,
    size: String,
    theme: String,
  }

  static targets = ["turnstile"]

  connect() {
    if (this.turnstileTarget && window.turnstile) {
      window.turnstile.render(this.turnstileTarget, {
        sitekey: this.sitekeyValue,
        size: this.sizeValue,
        theme: this.themeValue,
      })
    }
  }
}

Now, after we include the partial in our form:

<%= render "shared/cloudflare_turnstile" %>

We get the same behavior as before, but now we can render the widget anywhere we want. Of course, we would have to add the server-side verification on the controller action we wish to protect.

Submit button feedback

To improve the user experience, let's make the forms submit button disabled and enable it only after the challenge has been successful.

This way, we are more clear about what the widget does.

First, let's make the submit button disabled:

<%= f.button type: "submit", class: "w-full flex items-center justify-center gap-2 px-4 py-3 text-white bg-slate-900 rounded-md hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 transition-colors duration-300 disabled:opacity-50 disabled:cursor-not-allowed", disabled: true do %>
  <span>Sign Up</span>
<% end %>

Now, in the Stimulus controller, let's add the behavior on callback:

// app/javascript/controllers/turnstile_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    sitekey: String,
    size: String,
    theme: String,
  }

  static targets = ["turnstile"]

  connect() {
    if (this.turnstileTarget && window.turnstile) {
      window.turnstile.render(this.turnstileTarget, {
        sitekey: this.sitekeyValue,
        size: this.sizeValue,
        theme: this.themeValue,
        callback: () => {
          this._enableClosestSubmitButton();
        }
      })
    }
  }

  _enableClosestSubmitButton() {
    if (this.closestSubmitButton) {
      this.closestSubmitButton.removeAttribute("disabled");
    }
  }

  get closestSubmitButton() {
    return this.element.closest("form").querySelector("button[type='submit']");
  }
}

Which produces the following result:

Enable button after successful callback

Even though the Turnstile widget contemplates various scenarios like timeouts, unsupported browsers or errors, we can use custom callbacks to customize its behavior or improve user feedback. You can see a list of those callbacks and every config option we can pass to the window.turnstile in their client-side documentation.

TL;DR

Preventing spam submissions or automated requests to our application is important to avoid filling our database with junk or having our email sending reputation damaged.

There are multiple ways to prevent spam but, in this article, we used the Cloudflare Turnstile, which works as an unobtrusive captcha that doesn't require too much user input.

The first step to implementing it is to register our site in the Cloudflare dashboard to obtain a SiteKey and a SecretKey which we will use to render the Turnstile and to validate the token it returns.

Then, we include the JavaScript tag to load the widget in our application layout:

<%= javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", "data-turbo-track": "reload", defer: true %>

Next, we add the HTML to render the widget in any view with a form, and we pass the SiteKey we obtained before as a data attribute. If we want the widget to cover the full width, we add the flexible value for the size attribute:

<div class="cf-turnstile" 
    data-sitekey="<%= Rails.application.credentials.dig(:cloudflare, :turnstile_site_key) %>"
    data-theme="light"
    data-size="flexible"></div>

As we're passing the render=explicit parameter, we need to define the render logic in the client. The turnstileElement variable represents the actual div with the .cf-turnstile class, and then we access the turnstile object from the browsers window:

// app/javascript/application.js
document.addEventListener("turbo:load", () => {
  const turnstileElement = document.querySelector(".cf-turnstile")

  if (turnstileElement && window.turnstile) {
    const siteKey = turnstileElement.dataset.sitekey

    window.turnstile.render(turnstileElement, {
      sitekey: siteKey
    })
  }
})

This will load the widget every time the turbo:load is fired and a <div> with the .cf-turnstile class is present.

Then, after Cloudflare performs the human verification on the user's client, a hidden input with the cf-turnstile-response name attribute is added to the turnstileElement div with the token that Cloudflare returned as the value.

Now, after the user posts the form, we can access the token and verify it by making a POST request to Cloudflare's siteverify service with the SecretKey, the token we obtained and the remote IP for the request.

If the token is valid, the response will have a success key that evaluates to true, and we can proceed with the form submission assuming that the request is coming from a human.

There are many things that we can customize, but the flow is basically this. Don't hesitate to read Cloudflare Turnstile's documentation to make sure things work for you.

I hope this article was useful and that you can implement the feature to avoid the annoying and harmful spam requests.

Have a good one and happy coding!


Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.