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.
Step 1: Create the widget
- Sign in at
dash.cloudflare.com, open Turnstile, click Add Widget. - Hostnames: your domain, plus
localhostfor testing. - Widget mode: Managed, it decides per visitor whether to show anything.
- 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"
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>
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;
}
}
builder.Services.Configure<TurnstileOptions>(
builder.Configuration.GetSection("Turnstile"));
builder.Services.AddHttpClient<TurnstileVerifier>(c =>
c.Timeout = TimeSpan.FromSeconds(5));
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);
}
}
app.MapPost("/api/register", RegisterHandler)
.AddEndpointFilter<TurnstileFilter>();
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)