Persiltech.Turnstile.Blazor 1.0.5

dotnet add package Persiltech.Turnstile.Blazor --version 1.0.5
                    
NuGet\Install-Package Persiltech.Turnstile.Blazor -Version 1.0.5
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Persiltech.Turnstile.Blazor" Version="1.0.5" />
                    
Directory.Packages.props
<PackageReference Include="Persiltech.Turnstile.Blazor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Persiltech.Turnstile.Blazor --version 1.0.5
                    
#r "nuget: Persiltech.Turnstile.Blazor, 1.0.5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Persiltech.Turnstile.Blazor@1.0.5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
                    
Install as a Cake Addin
#tool nuget:?package=Persiltech.Turnstile.Blazor&version=1.0.5
                    
Install as a Cake Tool

Persiltech.Turnstile.Blazor

NuGet License

A modern Blazor WebAssembly component library for Cloudflare Turnstile integration with clean architecture and built-in verification support using the Result pattern.


✨ Features

  • Cloudflare Turnstile Integration - User-friendly CAPTCHA alternative
  • Built-in Verification - Component handles both token generation and validation
  • Result Pattern - Returns AppResult (Persiltech.Result) for elegant error handling
  • Customizable UI - Theme, size, and appearance options
  • Clean Architecture - Well-structured, maintainable, testable code
  • Type-Safe - Full C# nullable reference types support
  • Detailed Error Codes - Specific error codes for different failure scenarios
  • Functional API - Support for both imperative and functional programming styles
  • Multiple Instances - Support for multiple components on the same page

πŸ“¦ Installation

Client (Blazor WebAssembly)

dotnet add package Persiltech.Turnstile.Blazor

Server (ASP.NET Core Web API)

dotnet add package Persiltech.Captcha.Factory

Note: Persiltech.Captcha.Factory provides the verification endpoint automatically.


βš™οΈ Configuration

Client Setup

1. Register services in Program.cs:

using Persiltech.Turnstile.Blazor;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddTurnstileServices(builder.Configuration);

// Optionally configure the HttpClient used to call your verification endpoint:
// builder.Services.AddTurnstileServices(
//     builder.Configuration,
//     httpClientBuilder => httpClientBuilder.AddLocalizationDelegatingHandler());

await builder.Build().RunAsync();

2. Configure appsettings.json:

⚠️ The section is named Turnstile (the value of TurnstileOptions.SectionKey), not TurnstileOptions. A missing section throws InvalidOperationException: Configuration section 'Turnstile' not found on startup.

{
  "Turnstile": {
    "SiteKey": "your-turnstile-site-key",
    "WebApiBaseAddress": "https://your-api-domain.com"
  }
}

Both keys are required β€” TurnstileOptionsValidator fails startup validation if either is empty.

3. Load Cloudflare's script in index.html:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>

Without it the component logs api.js no estΓ‘ cargado and renders nothing.

Server Setup

1. Register services and endpoint in Program.cs:

using Persiltech.Captcha.Factory;

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddCaptcha(builder.Configuration);

var app = builder.Build();

// Register endpoint (POST /captcha/verify)
app.MapCaptchaVerifyEndpoint();

app.Run();

2. Configure appsettings.json:

{
  "CaptchaOptions": {
    "DefaultProvider": "turnstile",
    "Turnstile": {
      "SecretKey": "your-turnstile-secret-key",
      "VerifyEndpoint": "https://challenges.cloudflare.com/turnstile/v0/siteverify"
    }
  }
}

πŸš€ Usage

Basic Example (Imperative Style)

@page "/contact"
@using Persiltech.Turnstile.Blazor.Components
@using Persiltech.Turnstile.Blazor.Enums
@using Persiltech.Result

<EditForm Model="Model" OnValidSubmit="HandleSubmitAsync">
    <DataAnnotationsValidator />
    
    <div class="mb-3">
        <label>Name</label>
        <InputText @bind-Value="Model.Name" class="form-control" />
        <ValidationMessage For="@(() => Model.Name)" />
    </div>

    <div class="mb-3">
        <label>Email</label>
        <InputText @bind-Value="Model.Email" class="form-control" />
        <ValidationMessage For="@(() => Model.Email)" />
    </div>

    
    <TurnstileComponent @ref="_turnstile"
                        Action="contact_form"
                        Theme="TurnstileTheme.Light"
                        Size="TurnstileSize.Normal"
                        OnSuccess="HandleSuccess"
                        OnError="HandleError"
                        OnExpired="HandleExpired" />

    @if (!string.IsNullOrEmpty(_errorMessage))
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }

    <button type="submit" class="btn btn-primary" disabled="@_isSubmitting">
        @(_isSubmitting ? "Submitting..." : "Submit")
    </button>
</EditForm>

@code {
    private TurnstileComponent _turnstile = default!;
    private bool _isSubmitting;
    private string? _errorMessage;
    private string? _currentToken;
    private ContactModel Model { get; set; } = new();

    private async Task HandleSubmitAsync()
    {
        _isSubmitting = true;
        _errorMessage = null;
        
        try
        {
            // Token is automatically obtained when user completes challenge
            if (string.IsNullOrEmpty(_currentToken))
            {
                _errorMessage = "Please complete the security check";
                return;
            }

            // Verify token with Result pattern
            var result = await _turnstile.VerifyAsync(_currentToken);
            
            // Check for errors
            if (result.IsFailure)
            {
                _errorMessage = GetFriendlyErrorMessage(result.ErrorMessage);
                await _turnstile.ResetAsync(); // Reset widget for retry
                return;
            }

            // Process form
            await SubmitFormAsync();
        }
        finally
        {
            _isSubmitting = false;
        }
    }

    private void HandleSuccess(string token)
    {
        _currentToken = token;
        _errorMessage = null;
        StateHasChanged();
    }

    private void HandleError()
    {
        _errorMessage = "Security check failed. Please try again.";
        _currentToken = null;
        StateHasChanged();
    }

    private async Task HandleExpired()
    {
        _errorMessage = "Security check expired. Please try again.";
        _currentToken = null;
        await _turnstile.ResetAsync();
        StateHasChanged();
    }

    private string GetFriendlyErrorMessage(string? errorCode)
    {
        return errorCode switch
        {
            "missing-token" => "Security check not completed.",
            "invalid-token" => "Security check is invalid. Please try again.",
            "network-error" => "Network error. Please check your connection.",
            "timeout-error" => "Verification timed out. Please try again.",
            _ => "Security verification failed. Please try again."
        };
    }

    private async Task SubmitFormAsync()
    {
        // Your form submission logic
        await Task.Delay(1000);
        _errorMessage = "Form submitted successfully!";
    }

    public class ContactModel
    {
        [Required]
        public string Name { get; set; } = string.Empty;
        
        [Required, EmailAddress]
        public string Email { get; set; } = string.Empty;
    }
}

Functional Style with Match

@using Persiltech.Result.Extensions

@code {
    private async Task HandleSubmitAsync()
    {
        if (string.IsNullOrEmpty(_currentToken))
        {
            _errorMessage = "Please complete the security check";
            return;
        }

        _isSubmitting = true;
        
        try
        {
            var result = await _turnstile.VerifyAsync(_currentToken);

            // Match takes an Action for success and an Action<AppResult> for failure
            Func<Task> next = () => Task.CompletedTask;

            result.Match(
                onSuccess: () => next = SubmitFormAsync,
                onError: failed => next = async () =>
                {
                    _errorMessage = GetFriendlyErrorMessage(failed.ErrorMessage);
                    await _turnstile.ResetAsync();
                });

            await next();
        }
        finally
        {
            _isSubmitting = false;
        }
    }
}

Login Example with Auto Theme

@page "/login"
@using Persiltech.Turnstile.Blazor.Components
@using Persiltech.Turnstile.Blazor.Enums

<EditForm Model="Model" OnValidSubmit="HandleLoginAsync">
    <InputText @bind-Value="Model.Email" placeholder="Email" class="form-control mb-3" />
    <InputText @bind-Value="Model.Password" type="password" placeholder="Password" class="form-control mb-3" />
    
    
    <TurnstileComponent @ref="_turnstile"
                        Action="login"
                        Theme="TurnstileTheme.Auto"
                        Size="TurnstileSize.Normal"
                        OnSuccess="token => _token = token"
                        OnError="HandleError"
                        OnExpired="HandleExpired" />
    
    @if (!string.IsNullOrEmpty(_errorMessage))
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }
    
    <button type="submit" class="btn btn-primary w-100">Login</button>
</EditForm>

@code {
    private TurnstileComponent _turnstile = default!;
    private string? _token;
    private string? _errorMessage;
    private LoginModel Model { get; set; } = new();

    private async Task HandleLoginAsync()
    {
        if (string.IsNullOrEmpty(_token))
        {
            _errorMessage = "Please complete the security check";
            return;
        }

        var result = await _turnstile.VerifyAsync(_token);
        
        if (result.IsFailure)
        {
            _errorMessage = "Security verification failed";
            await _turnstile.ResetAsync();
            return;
        }
        
        await AuthService.LoginAsync(Model);
    }

    private void HandleError() => _errorMessage = "Security check failed";
    
    private async Task HandleExpired()
    {
        _errorMessage = "Security check expired";
        await _turnstile.ResetAsync();
    }
}

Compact Widget Example


<TurnstileComponent Action="newsletter_signup"
                    Size="TurnstileSize.Compact"
                    Theme="TurnstileTheme.Light"
                    OnSuccess="token => _newsletterToken = token"
                    OnError="() => ShowError('Verification failed')"
                    OnExpired="() => ShowError('Verification expired')" />

πŸ“š API Reference

TurnstileComponent

Parameters
Parameter Type Default Required Description
Action string - βœ… Yes Action identifier for analytics (e.g., "login", "register", "contact")
Theme TurnstileTheme? Auto No Widget theme: Light, Dark, or Auto
Size TurnstileSize Normal No Widget size: Normal, Compact
Appearance TurnstileAppearance Always No Widget visibility: Always, Execute, InteractionOnly
Language string? Browser default No Language code (e.g., "es", "en", "fr")
OnSuccess EventCallback<string> - βœ… Yes Callback when user successfully completes challenge (receives token)
OnError EventCallback - βœ… Yes Callback when challenge encounters an error
OnExpired EventCallback - βœ… Yes Callback when token expires
Methods
VerifyAsync(string? token)

Verifies the token with your backend using the Result pattern.

Task<AppResult> VerifyAsync(string? token)

Parameters:

  • token - Token received from OnSuccess callback. A null or blank token fails fast with missing-token β€” it never reaches the network.

Returns: AppResult (from Persiltech.Result) containing:

  • IsFailure - true if verification failed
  • IsSuccess - true if verification succeeded
  • ErrorMessage - The error code when verification failed (see the table below)
  • Errors - All errors recorded on the result

Example:

var result = await _turnstile.VerifyAsync(token);

if (result.IsFailure)
{
    Console.WriteLine($"Error: {result.ErrorMessage}");
}
ResetAsync()

Resets the widget to allow user to retry. If Cloudflare no longer recognises the widget β€” which happens when Blazor re-creates the container node β€” the widget is re-rendered instead of being left unusable.

ValueTask ResetAsync()

Example:

if (result.IsFailure)
{
    await _turnstile.ResetAsync(); // Reset for retry
}
RemoveAsync()

Removes the widget from the DOM.

ValueTask RemoveAsync()

Example:

// Clean up when component is no longer needed
await _turnstile.RemoveAsync();

🎨 Enums

TurnstileTheme

Widget color theme.

public enum TurnstileTheme
{
    Light,  // Light theme
    Dark,   // Dark theme
    Auto    // Follows system preference (default)
}

TurnstileSize

Widget size.

public enum TurnstileSize
{
    Normal,   // Standard size: 300x65px (default)
    Flexible, // Fills the width of its container (min. 300px)
    Compact   // Compact size: 130x120px
}

TurnstileAppearance

Widget visibility behavior.

public enum TurnstileAppearance
{
    Always,           // Always visible (default)
    Execute,          // Hidden until explicitly executed
    InteractionOnly   // Only shown when interaction is required
}

πŸ” Error Codes

The VerifyAsync() method reports the error code in AppResult.ErrorMessage:

Error Code Description Recommended Action
missing-token Token is null or blank; no request is sent Ensure user completed challenge
network-error The verification endpoint could not be reached Retry automatically or ask user to check connection
timeout-error Verification request timed out Retry with exponential backoff
unauthorized The endpoint answered 401/403 Check authentication on your verification endpoint
http-error-XXX HTTP error from your endpoint (e.g., http-error-400 for an invalid token, http-error-502 when Cloudflare is unreachable) Reset widget and retry, or show service unavailable
unknown-error Unexpected client-side failure Reset widget and retry

The provider-level codes Cloudflare returns (invalid-input-response, timeout-or-duplicate, …) are logged server-side by Persiltech.Turnstile.Server and surface here as http-error-400.

Example of handling specific errors:

var result = await _turnstile.VerifyAsync(token);

if (result.IsFailure)
{
    var (message, shouldReset) = result.ErrorMessage switch
    {
        "http-error-400" =>
            ("Verification expired. Please try again.", true),
        "network-error" or "timeout-error" => 
            ("Connection error. Retrying...", false),
        _ => 
            ("Verification failed. Please try again.", true)
    };
    
    ShowError(message);
    
    if (shouldReset)
        await _turnstile.ResetAsync();
}

πŸ”§ How It Works

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Blazor WebAssembly (Client)                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  1. User completes Turnstile challenge                      β”‚
β”‚  2. OnSuccess(token) β†’ Store token                          β”‚
β”‚  3. VerifyAsync(token) β†’ POST /captcha/verify               β”‚
β”‚  4. Receive AppResult carrying an error code                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
                   β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              ASP.NET Core API (Server)                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  5. Validate token with Cloudflare                          β”‚
β”‚  6. Return CaptchaVerificationResult or ProblemDetails       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ› Troubleshooting

Token Not Being Generated

Cause: User hasn't completed the challenge.
Solution:

  • Ensure OnSuccess callback is properly wired
  • Check browser console for JavaScript errors
  • Verify Site Key in configuration

Verification Returns http-error-400

Common Causes:

  • Token already used (tokens are single-use)
  • Token expired (valid for ~5 minutes)
  • Mismatched keys (Site Key vs Secret Key)

Solution:

if (result.ErrorMessage == "http-error-400")
{
    await _turnstile.ResetAsync(); // Allow user to retry
}

[Cloudflare Turnstile] Nothing to reset found for provided container

Cause: ResetAsync() ran against a widget whose container Blazor had already re-created, so Cloudflare no longer knows the widget id.

Solution: Fixed in 1.0.5 β€” the JS layer now contains the error and the component re-renders the widget instead of leaving it unusable. If you still see it, make sure you are not calling RemoveAsync() and ResetAsync() on the same instance.

Widget Not Visible

Causes:

  • Appearance = InteractionOnly (intended behavior)
  • CSS conflicts
  • Ad blocker interference

Solution:

  1. Set Appearance = Always for testing
  2. Check browser developer tools for CSS issues
  3. Temporarily disable ad blockers

network-error or timeout-error

Solution: Implement retry logic:

private async Task<AppResult> VerifyWithRetryAsync(
    string token, 
    int maxRetries = 3)
{
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        var result = await _turnstile.VerifyAsync(token);

        if (result.IsSuccess)
            return result;

        if (result.ErrorMessage is not ("network-error" or "timeout-error") || attempt == maxRetries)
            return result;

        await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)));
    }

    return AppResult.Fail("max-retries-exceeded");
}

CORS Issues

Configure CORS in your backend:

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowClient", policy =>
        policy.WithOrigins("https://your-domain.com")
              .AllowAnyMethod()
              .AllowAnyHeader());
});

app.UseCors("AllowClient");

🎯 Best Practices

1. Always Handle All Three Callbacks

<TurnstileComponent OnSuccess="HandleSuccess"
                    OnError="HandleError"
                    OnExpired="HandleExpired" />

2. Reset on Errors

private async Task HandleError()
{
    _errorMessage = "Verification failed";
    await _turnstile.ResetAsync(); // Allow retry
}

3. Store Token Immediately

private void HandleSuccess(string token)
{
    _currentToken = token;
    _errorMessage = null;
    // Optionally auto-submit form
    // await HandleSubmitAsync();
}

4. Use Appropriate Size


<TurnstileComponent Size="TurnstileSize.Normal" />


<TurnstileComponent Size="TurnstileSize.Compact" />

5. Match Theme to Your Design


<TurnstileComponent Theme="TurnstileTheme.Light" />


<TurnstileComponent Theme="TurnstileTheme.Dark" />


<TurnstileComponent Theme="TurnstileTheme.Auto" />

6. Use Specific Action Names


<TurnstileComponent Action="login" />
<TurnstileComponent Action="register" />
<TurnstileComponent Action="checkout" />

Required

Optional

Alternative


πŸ”— Resources


βš–οΈ Turnstile vs reCAPTCHA

Feature Turnstile reCAPTCHA v3
Visibility Visible widget Invisible
User Interaction May require interaction No interaction
Privacy Privacy-focused, no tracking Tracks user behavior
Speed Fast challenges Background analysis
Customization Theme, size options Limited customization
Best For Privacy-conscious apps Invisible security

πŸ“„ License

Copyright Β© 2026 Persiltech. All rights reserved.

Licensed under the MIT License.


πŸ“§ Support

For issues or questions, please open an issue on the GitHub repository.


Made with ❀️ by Edinson Aldaz

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Persiltech.Turnstile.Blazor:

Package Downloads
Persiltech.Membership.Blazor

Contains razor clases for use in frontend membership projects

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.5 102 8/13/2026
1.0.4 131 7/5/2026
1.0.3 110 6/28/2026
1.0.2 119 6/6/2026
1.0.1 107 5/5/2026
1.0.0 111 5/3/2026