RL ROLAND LOPEZ
// 5 min read

Ruby on Rails Cloudflare Turnstile Tutorial

Why Turnstile, and not a captcha

A client came to me with a bot problem. It was signing up with real-looking Google emails.

Those emails flowed straight into his marketing system. When campaigns went out, a chunk bounced, his domain lost sending authority, and conversions dropped with it.

The diagnosis was murky too. The only clue was the rejection reason: “Inbox full.” That likely meant the attacker was seeding the list with known-dead inboxes from past attacks.

We looked at three options:

  • Fail2Ban
  • Captcha
  • Cloudflare Turnstile

Fail2Ban was out. It was never the same email or IP abusing us.

Captcha works, but Turnstile is free. And personally, I trust a site with Turnstile more than one that makes me fail image puzzles.

How Turnstile works

In simple words:

  1. Get your private and public keys.
  2. Add a script to your page.
  3. The script looks for a specific class in your document.
  4. It renders the widget.
  5. The widget calls the Cloudflare API for a signed token using your public key.
  6. The signed token gets inserted into the form, ready for submit.
  7. On submit, your controller makes a second request to check that token with your private key.
  8. If the challenge passes, you proceed. If not, you send the cops.

In one image:

Turnstile overview

Implementation

Create your credentials

Create an account if you don’t have one, log in, and create your site:

Turnstile dashboard - create site Turnstile dashboard - site settings

Leave everything to default and add your site name and domain. These are your keys:

Turnstile dashboard - site keys

Then spend 10 minutes in the Cloudflare docs:

Add the JS (the view, layout head)

Add this in app/views/layouts/application.html, inside the head tag:

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

Protect your form (the view, 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 (the Stimulus controller, JS)

// /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 keeps the Turnstile widget alive across 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

Once the user clicks the widget, it inserts a token that needs validating in the action your form targets.

Here that’s the Devise sign-up, so we use a custom registrations controller.

The 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

To have that controller in your app, generate it:

rails g devise:controllers users -c=registrations

In your routes, add:

devise_for :users, controllers: { registrations: 'users/registrations' }

The model (the verifier)

# 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 people do it. You don’t need everything to live in a Service namespace.

How it works on this blog (the static-site version)

This blog is not in Rails. It’s statically generated with minimal JavaScript, and I use a Cloudflare Worker when I need a backend call, because it’s free and sufficient.

Here’s what I’m doing.

The view

  <!-- 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.

The Stimulus controller (JS)

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 ;)")
    }

  }
}

The backend (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 nice thing about Turnstile is the extra data it gives you on user behavior:

Turnstile dashboard - analytics

You can see how much cheating is happening in your lead collector, and keep your sales and marketing pipeline full of quality emails.

Alright, your turn. Enter your email to prove you are not an AI bot.

Roland

Roland Lopez
Written by
Roland Lopez

Technical founder & AI crack-head

Built by Agent Skynet See the agency