Ruby on rails Cloudflare turnstile tutorial


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 tunrstile works?

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 Turnstile Dashboard

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

These are your keys: Turnstile Dashboard

Now just invest 10 minutes in the cloudflares 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 ;)

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 to the following:

Execute this command:

rails g device: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 works in this blog?

Well, this blog is not in Rails.

It’s statically generated using gohugo.io 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 will 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 follwing 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 achitecture different execution.

Conclusion

One of the cool things about turnstile is that it gives you extra data to understand user behavior:

Turnstile Dashboard

This help you see who 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 me that you are not an AI bot 😂

Roland 🤠


Related posts

Back to homepage