DEV Community

Mahmood Al Sarraj
Mahmood Al Sarraj

Posted on • Originally published at Medium

Stop Letting Bots Register Accounts: Add Cloudflare Turnstile to Your .NET App

Your signup form sends an SMS OTP. Each one costs you a few cents.

A bot loops POST /api/register with a rotating IP pool and throwaway numbers, and you pay for every message. Registration is the one endpoint you leave open to strangers — no auth, no API key, no rate limit that survives a fresh IP. It's the only place where someone else can spend your money in a loop.

Cloudflare Turnstile closes it. Free, invisible to most users, and it works even if your site doesn’t sit behind Cloudflare: the widget issues a token, and your backend asks Cloudflare whether that token is real.

That second half is the whole thing. A token you never verify server-side is decoration.

.NET with Turnstile

Step 1: Create the widget

  1. Sign in at dash.cloudflare.com, open Turnstile, click Add Widget.
  2. Hostnames: your domain, plus localhost for testing.
  3. Widget mode: Managed, it decides per visitor whether to show anything.
  4. Create, then copy the Site Key and Secret Key.

The site key is public. The secret key is not, keep it out of appsettings.json:

dotnet user-secrets set "Turnstile:SecretKey" "YOUR_SECRET_KEY"
Enter fullscreen mode Exit fullscreen mode

In production it belongs in Key Vault or your platform’s equivalent.

Step 2: Add the widget to your form

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="post" action="/api/register">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-theme="auto"></div>
  <button type="submit">Create account</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The widget injects a hidden field named cf-turnstile-response. On a SPA, read it with turnstile.getResponse() and send it as a header.

Step 3: Verify on the server

One reusable service, not an inline call in your handler:

private sealed record TurnstileResult
{
    [JsonPropertyName("success")] public bool Success { get; init; }
    [JsonPropertyName("error-codes")] public string[]? ErrorCodes { get; init; }
    [JsonPropertyName("hostname")] public string? Hostname { get; init; }
    [JsonPropertyName("challenge_ts")] public string? ChallengeTs { get; init; }
}
public sealed class TurnstileVerifier(
    HttpClient http,
    IOptions<TurnstileOptions> options,
    ILogger<TurnstileVerifier> logger)
{
    private const string VerifyUrl =
        "https://challenges.cloudflare.com/turnstile/v0/siteverify";
    public async Task<bool> IsHumanAsync(
        string? token, string? remoteIp, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(token))
            return false;
        var payload = new Dictionary<string, string>
        {
            ["secret"] = options.Value.SecretKey,
            ["response"] = token
        };
        if (!string.IsNullOrWhiteSpace(remoteIp))
            payload["remoteip"] = remoteIp;
        using var response = await http.PostAsync(
            VerifyUrl, new FormUrlEncodedContent(payload), ct);
        if (!response.IsSuccessStatusCode)
        {
            // Cloudflare unreachable — fail closed, on purpose.
            logger.LogWarning("Turnstile unavailable: {Status}", response.StatusCode);
            return false;
        }
        var result = await response.Content
            .ReadFromJsonAsync<TurnstileResult>(cancellationToken: ct);
        return result?.Success == true;
    }
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.Configure<TurnstileOptions>(
    builder.Configuration.GetSection("Turnstile"));
builder.Services.AddHttpClient<TurnstileVerifier>(c =>
    c.Timeout = TimeSpan.FromSeconds(5));
Enter fullscreen mode Exit fullscreen mode

Step 4: Guard the endpoint

public sealed class TurnstileFilter(TurnstileVerifier verifier) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var req = ctx.HttpContext.Request;
        var token = req.Headers["cf-turnstile-response"].FirstOrDefault()
            ?? (req.HasFormContentType
                ? req.Form["cf-turnstile-response"].FirstOrDefault()
                : null);
        var ip = req.Headers["CF-Connecting-IP"].FirstOrDefault()
            ?? ctx.HttpContext.Connection.RemoteIpAddress?.ToString();
        if (!await verifier.IsHumanAsync(token, ip, ctx.HttpContext.RequestAborted))
            return Results.Problem("Human verification failed",
                statusCode: StatusCodes.Status403Forbidden);
        return await next(ctx);
    }
}
Enter fullscreen mode Exit fullscreen mode
app.MapPost("/api/register", RegisterHandler)
   .AddEndpointFilter<TurnstileFilter>();
Enter fullscreen mode Exit fullscreen mode

One line per endpoint. On MVC, the same logic fits into an IAsyncActionFilter behind a [VerifyHuman] attribute.

Three things that will bite you

Tokens are single-use and expire in about five minutes. A retry with the same token fails — call turnstile.reset() on error.

Pick your failure mode deliberately. If Cloudflare is unreachable, do you block signups or let them through? Payments product: fail closed. Newsletter: fail open. The wrong answer is not having thought about it.

Turnstile is not a rate limiter. It answers “human?”, not “how many times?”. Keep your rate limiting — Turnstile removes the cheap, high-volume noise, which is most of it.

For CI, Cloudflare publishes dummy keys: site key 1x00000000000000000000AA passes and 2x00000000000000000000AB blocks; secret 1x0000000000000000000000000000000AA passes and 2x0000000000000000000000000000000AA fails. Write the failing test first.

Registration, password reset, OTP resend — anything a script can call a thousand times at your expense.

A few minutes of work, and you stop paying for other people’s loops.

Top comments (0)