ASP.NET Core Form protection with Cloudflare’s Turnstile
You can see the full sample in my GitHub repository BEN ABT Samples — ASP.NET Core Form protection with Cloudflare’s Turnstile
Turnstile is a pretty new, free product from Cloudflare and is intended to be a better alternative to Google’s reCAPTCHA. Cloudflare itself advertises that Turnstile offers a better user experience and at the same time increases security and does not jeopardize data protection.
The free use of Turnstile is limited to
- 10 widgets
- The Cloudflare branding is included
- Only the use of the “managed mode” is possible
- 10 hostnames per widget
The paid version, which has no public pricing but is part of Cloudflare’s Enterprise plan, offers the following features
- Unlimited number of widgets
- No Cloudflare branding
- Different modes, managed, but also “never interactive mode”
- No limit on hostnames
Setup Cloudflare
Turnstile works very similarly to reCAPTCHA or hCAPTCHA
- There is an HTML widget that is loaded via JavaScript.
- The JavaScript file is loaded by the browser from a Cloudflare endpoint, and thus the user’s IP address is also transmitted to Cloudflare.
- A
data-cf-turnstile
attribute containing the ID of the widget is inserted into an HTML form. - When the form is sent, the turnstile widget inserts a hidden form data element that is required on the server for evaluation (token).
- The implementation of the server when accepting the form data performs the token check.
- For this purpose, a verify endpoint is addressed, to which the token and optionally the user IP address and a uniqueness feature (“idempotency key”) are sent
- In addition, a site key and a secret are required to authenticate the endpoint.
- The Verify endpoint responds whether the request was legitimate or not; additionally an error array if there were problems with the request.
To use Turnstile, you need to create an account with Cloudflare and register a page. The ‘Turnstile’ tab in the navigation menu takes you to the Turnstile overview, where you can create a new page.
After creation, the site key and the secret key are displayed and must be transferred to the configuration.
ASP.NET Core MVC View Implementation
The turnstile widget is integrated in the HTML view or, in this case, the ASP.NET Core View. To do this, the JavaScript file from Cloudflare is loaded and the data-cf-turnstile
attribute is inserted into the form.
@inject Microsoft.Extensions.Options.IOptions<CloudflareTurnstileSettings> CFTOptions
@{
var turnstileConfig = CFTOptions.Value;
ViewData["Title"] = "Home Page";
}
<!-- Cloudflare Turnstile Setup -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" defer></script>
<form method="post">
@(Html.AntiForgeryToken())
<input type="text" placeholder="Sample Text" name="@(nameof(PostSampleSubmitModel.SampleInput))" />
<div class="cf-turnstile" data-sitekey="@(turnstileConfig.SiteKey)" data-callback="javascriptCallback"></div>
<button type="submit">Submit</button>
</form>
The data-cf-turnstile
attribute is enriched with the site key, which is provided by Cloudflare to identify which website the setup applies to. In addition, there are various data-callback
attribute values that influence the behavior of the script and can be found in the documentation.
The form view then looks as follows:
.NET Server Side Implementation
The server-side implementation is currently a bit more complex in .NET, as there is no ready-made SDK from Cloudflare; unfortunately, .NET is not taken into account for many things from Cloudflare, so you have to write a lot yourself.
Thankfully, however, there are many things in .NET that make life easier, e.g. Refit for addressing endpoints. Via the Refit definition of a client via an interface, the Verify endpoint is defined by Turnstile, which includes both a request model and a result model.
public interface ICloudflareTurnstileClient
{
[Post("/siteverify")]
[Headers("Content-Type: application/json")]
public Task<CloudflareTurnstileVerifyResult> Verify(CloudflareTurnstileVerifyRequestModel requestModel,
CancellationToken ct);
}
public record class CloudflareTurnstileVerifyRequestModel(
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation
[property: JsonPropertyName("secret")] string SecretKey,
[property: JsonPropertyName("response")] string Token,
[property: JsonPropertyName("remoteip")] string? UserIpAddress,
[property: JsonPropertyName("idempotency_key")] string? IdempotencyKey);
public record class CloudflareTurnstileVerifyResult(
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("error-codes")] string[] ErrorCodes,
[property: JsonPropertyName("challenge_ts")] DateTimeOffset On,
[property: JsonPropertyName("hostname")] string Hostname
);
This fully describes the endpoint communication to Cloudflare. However, to make things easier in the code itself, it is worth writing an additional provider here, which takes over communication with the client and should then also include exception handling in a productive implementation.
// This code example is very simplified to show the basic functionality. You should also add interfaces and exception handling to your code.
public class CloudflareTurnstileSettings
{
[Required]
public string BaseUrl { get; set; } = null!;
[Required]
public string SiteKey { get; set; } = null!;
[Required]
public string SecretKey { get; set; } = null!;
}
public class CloudflareTurnstileProvider
{
private readonly CloudflareTurnstileSettings _turnstileSettings;
private readonly ICloudflareTurnstileClient _client;
public CloudflareTurnstileProvider(IOptions<CloudflareTurnstileSettings> turnstileOptions,
ICloudflareTurnstileClient client)
{
_turnstileSettings = turnstileOptions.Value;
_client = client;
}
public async Task<CloudflareTurnstileVerifyResult> Verify(string token, string? idempotencyKey = null,
IPAddress? userIpAddress = null, CancellationToken ct = default)
{
CloudflareTurnstileVerifyRequestModel requestModel = new(_turnstileSettings.SecretKey, token, userIpAddress?.ToString(), idempotencyKey);
CloudflareTurnstileVerifyResult result = await _client
.Verify(requestModel, ct)
.ConfigureAwait(false);
return result;
}
}
The provider requires settings that are stored in appsettings.json
(API URL, key, secret), as well as the client that handles communication with the verify endpoint. This makes the use in the code (e.g. in the logic or in the action) as simple as possible and the communication with Cloudflare is encapsulated. This also helps enormously when writing tests.
The only thing missing now is dependency injection, which registers all components of the Cloudflare Turnstile implementation.
public static class CloudflareTurnstileRegistration
{
public static void AddCloudflareTurnstile(
this IServiceCollection services, IConfigurationSection configurationSection)
{
// configure
services.Configure<CloudflareTurnstileSettings>(configurationSection);
// read url required for refit
string clientBaseUrl = configurationSection.GetValue<string>(nameof(CloudflareTurnstileSettings.BaseUrl))!;
// we can enforce not-null because we have a required attribute in the settings
// in this sample the provider can be a singleton
services.AddSingleton<CloudflareTurnstileProvider>();
// add refit client
services.AddRefitClient<ICloudflareTurnstileClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(clientBaseUrl));
}
}
The method AddCloudFlareTurnstile
is called in Startup.cs
or Program.cs
(Minimal API) and registers all components of the Cloudflare Turnstile implementation. The settings are read from appsettings.json
and the client is configured. The provider is registered as a singleton, as it does not save any states and can therefore be reused for all requests.
// Program.cs
// Add Cloudflare Turnstile
builder.Services.AddCloudflareTurnstile(builder.Configuration.GetRequiredSection("CloudflareTurnstile"));
// appsettings.json
{
"CloudflareTurnstile": {
"BaseUrl": "https://challenges.cloudflare.com/turnstile/v0",
"SiteKey": "0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"SecretKey": "0xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
Verify the Token in the Controller
The setup is complete — the token can now be checked in the action when processing the form. To do this, the CloudflareTurnstileProvider
is injected into the controller and the token is passed to the provider. This simple sample only uses the IP address at this point, but it is generally recommended by Turnstile to also use the idempotencyKey
in order to uniquely identify the request.
public class HomeController(CloudflareTurnstileProvider cloudflareTurnstileProvider) : Controller
{
[HttpGet]
public IActionResult Index() => View();
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(PostSampleSubmitModel submitModel,
[FromForm(Name = "cf-turnstile-response")] string turnstileToken)
{
// read users ip address
// proxy? => https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-8.0&WT.mc_id=DT-MVP-5001507
IPAddress? userIP = Request.HttpContext.Connection.RemoteIpAddress;
// verify token
CloudflareTurnstileVerifyResult cftResult = await cloudflareTurnstileProvider
.Verify(turnstileToken, userIpAddress: userIP);
// in a productive environment you can implement this as action filter
// present result
ViewBag.Result = cftResult;
return View();
}
}
public record class PostSampleSubmitModel(string SampleInput);
If you wish, you can outsource the entire provider logic to a filter and provide the action with a corresponding attribute. This keeps the actions simple if tokens are to be checked in several places.
Full Sample
The full example and all relevant code snippets can be found in my GitHub repository BEN ABT Samples — ASP.NET Core Form protection with Cloudflare’s Turnstile
Have fun protecting your application!
Autor
Benjamin Abt
Ben is a passionate developer and software architect and especially focused on .NET, cloud and IoT. In his professional he works on high-scalable platforms for IoT and Industry 4.0 focused on the next generation of connected industry based on Azure and .NET. He runs the largest german-speaking C# forum myCSharp.de, is the founder of the Azure UserGroup Stuttgart, a co-organizer of the AzureSaturday, runs his blog, participates in open source projects, speaks at various conferences and user groups and also has a bit free time. He is a Microsoft MVP since 2015 for .NET and Azure.
Originally published at https://schwabencode.com on February 29, 2024.