Learnixo
Back to blog
Backend Systemsintermediate

Google reCAPTCHA v3 in ASP.NET Core: Bot Protection Without User Friction

Integrate Google reCAPTCHA v3 into ASP.NET Core Web API. Covers site/secret key setup, frontend token generation, server-side verification, score thresholds, action validation, and production patterns.

LearnixoJune 4, 20267 min read
.NETC#reCAPTCHASecurityBot ProtectionASP.NET CoreGoogle
Share:𝕏

What is reCAPTCHA v3?

reCAPTCHA v3 runs invisibly in the background — no checkboxes, no image puzzles. It analyses user behaviour and assigns a score from 0.0 to 1.0:

  • Score close to 1.0 → likely a human
  • Score close to 0.0 → likely a bot

You decide what score is acceptable for each action.

User fills signup form
       ↓
reCAPTCHA analyses behaviour (mouse movements, timing, browser fingerprint)
       ↓
Returns token to your frontend
       ↓
Frontend sends token to your API
       ↓
API verifies token with Google → receives score (e.g., 0.8)
       ↓
API allows or rejects the request based on score

Setup: Google reCAPTCHA Console

  1. Go to https://www.google.com/recaptcha/admin
  2. Register a new site → choose reCAPTCHA v3
  3. Add your domain(s)
  4. Get two keys:
    • Site Key — used in your frontend (public)
    • Secret Key — used in your backend (keep secret)

Configuration

JSON
// appsettings.json
{
  "RecaptchaSettings": {
    "SecretKey": "YOUR_SECRET_KEY_HERE",
    "SiteKey":   "YOUR_SITE_KEY_HERE",
    "MinimumScore": 0.5,
    "VerificationUrl": "https://www.google.com/recaptcha/api/siteverify"
  }
}
C#
// Settings/RecaptchaSettings.cs
public class RecaptchaSettings
{
    public const string SectionName = "RecaptchaSettings";

    [Required] public string SecretKey       { get; set; } = "";
    [Required] public string SiteKey         { get; set; } = "";
    public double MinimumScore               { get; set; } = 0.5;
    public string VerificationUrl            { get; set; } =
        "https://www.google.com/recaptcha/api/siteverify";
}

Backend Service

C#
// Models/RecaptchaVerificationResponse.cs
public class RecaptchaVerificationResponse
{
    [JsonPropertyName("success")]
    public bool Success { get; set; }

    [JsonPropertyName("score")]
    public double Score { get; set; }

    [JsonPropertyName("action")]
    public string Action { get; set; } = "";

    [JsonPropertyName("challenge_ts")]
    public string ChallengeTs { get; set; } = "";

    [JsonPropertyName("hostname")]
    public string Hostname { get; set; } = "";

    [JsonPropertyName("error-codes")]
    public List<string>? ErrorCodes { get; set; }
}

// Services/RecaptchaService.cs
public interface IRecaptchaService
{
    Task<RecaptchaResult> VerifyAsync(
        string token,
        string expectedAction,
        CancellationToken ct = default);
}

public record RecaptchaResult(bool IsValid, double Score, string? ErrorCode = null);

public class RecaptchaService : IRecaptchaService
{
    private readonly HttpClient _http;
    private readonly RecaptchaSettings _settings;
    private readonly ILogger<RecaptchaService> _logger;

    public RecaptchaService(
        HttpClient http,
        IOptions<RecaptchaSettings> settings,
        ILogger<RecaptchaService> logger)
    {
        _http     = http;
        _settings = settings.Value;
        _logger   = logger;
    }

    public async Task<RecaptchaResult> VerifyAsync(
        string token,
        string expectedAction,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(token))
            return new RecaptchaResult(false, 0, "missing-token");

        // Call Google verification endpoint
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["secret"]   = _settings.SecretKey,
            ["response"] = token
        });

        HttpResponseMessage response;
        try
        {
            response = await _http.PostAsync(_settings.VerificationUrl, content, ct);
            response.EnsureSuccessStatusCode();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to contact reCAPTCHA verification service");
            return new RecaptchaResult(false, 0, "verification-failed");
        }

        var result = await response.Content
            .ReadFromJsonAsync<RecaptchaVerificationResponse>(ct);

        if (result is null)
            return new RecaptchaResult(false, 0, "invalid-response");

        // Validate
        if (!result.Success)
        {
            _logger.LogWarning("reCAPTCHA failed: {Errors}", result.ErrorCodes);
            return new RecaptchaResult(false, 0, result.ErrorCodes?.FirstOrDefault());
        }

        if (result.Action != expectedAction)
        {
            _logger.LogWarning("reCAPTCHA action mismatch. Expected: {Expected}, Got: {Got}",
                expectedAction, result.Action);
            return new RecaptchaResult(false, result.Score, "action-mismatch");
        }

        if (result.Score < _settings.MinimumScore)
        {
            _logger.LogWarning("reCAPTCHA score too low: {Score} (minimum: {Min})",
                result.Score, _settings.MinimumScore);
            return new RecaptchaResult(false, result.Score, "score-too-low");
        }

        return new RecaptchaResult(true, result.Score);
    }
}

Registration

C#
// Program.cs
builder.Services.AddOptions<RecaptchaSettings>()
    .BindConfiguration(RecaptchaSettings.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddHttpClient<IRecaptchaService, RecaptchaService>();

Controller Integration

C#
// Models/SignupRequest.cs
public class SignupRequest
{
    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [Required, MinLength(8)]
    public string Password { get; set; } = "";

    [Required]
    public string RecaptchaToken { get; set; } = "";  // token from frontend
}

// Controllers/AuthController.cs
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IRecaptchaService _recaptcha;
    private readonly IAuthService _auth;

    public AuthController(IRecaptchaService recaptcha, IAuthService auth)
    {
        _recaptcha = recaptcha;
        _auth      = auth;
    }

    [HttpPost("signup")]
    public async Task<IActionResult> Signup(
        [FromBody] SignupRequest request,
        CancellationToken ct)
    {
        // Verify reCAPTCHA first — before any business logic
        var captchaResult = await _recaptcha.VerifyAsync(
            request.RecaptchaToken,
            expectedAction: "signup",  // must match what frontend sends
            ct);

        if (!captchaResult.IsValid)
        {
            return BadRequest(new
            {
                error = "Human verification failed.",
                code  = captchaResult.ErrorCode
            });
        }

        // Proceed with signup
        var user = await _auth.RegisterAsync(request, ct);
        return CreatedAtAction(nameof(GetProfile), new { id = user.Id }, new { user.Id });
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var captchaResult = await _recaptcha.VerifyAsync(
            request.RecaptchaToken,
            expectedAction: "login",
            ct);

        if (!captchaResult.IsValid)
            return BadRequest(new { error = "Human verification failed." });

        var token = await _auth.LoginAsync(request, ct);
        return Ok(new { token });
    }
}

Frontend Integration (HTML + JavaScript)

HTML
<!DOCTYPE html>
<html>
<head>
    <!-- Load reCAPTCHA v3 script -->
    <script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
</head>
<body>
<form id="signupForm">
    <input type="email"    name="email"    placeholder="Email" required />
    <input type="password" name="password" placeholder="Password" required />
    <button type="submit">Sign Up</button>
</form>

<script>
document.getElementById('signupForm').addEventListener('submit', async (e) => {
    e.preventDefault();

    // Get reCAPTCHA token — specify the action
    const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'signup' });

    const response = await fetch('/api/auth/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            email:           e.target.email.value,
            password:        e.target.password.value,
            recaptchaToken:  token    // ← send token to backend
        })
    });

    if (response.ok) {
        alert('Registered successfully!');
    } else {
        const error = await response.json();
        alert(error.error);
    }
});
</script>
</body>
</html>

Action-Based Protection

Different actions can have different score thresholds:

C#
public class RecaptchaActions
{
    public const string Signup           = "signup";
    public const string Login            = "login";
    public const string PasswordReset    = "password_reset";
    public const string ContactForm      = "contact";
    public const string Checkout         = "checkout";
}

// Custom thresholds per action
public async Task<RecaptchaResult> VerifyWithCustomThresholdAsync(
    string token,
    string action,
    double minimumScore,
    CancellationToken ct)
{
    var result = await VerifyAsync(token, action, ct);

    // Override the global threshold for this specific action
    if (result.Score < minimumScore)
        return new RecaptchaResult(false, result.Score, "score-too-low");

    return result;
}

// High-risk actions require higher score
await _recaptcha.VerifyWithCustomThresholdAsync(token, "checkout", 0.7, ct);
await _recaptcha.VerifyWithCustomThresholdAsync(token, "contact",  0.3, ct);

Testing

C#
// Unit test — mock the service
public class AuthControllerTests
{
    [Fact]
    public async Task Signup_ValidCaptcha_ReturnsCreated()
    {
        var recaptcha = Substitute.For<IRecaptchaService>();
        recaptcha.VerifyAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(new RecaptchaResult(true, 0.9));

        var controller = new AuthController(recaptcha, _authService);
        var result = await controller.Signup(new SignupRequest
        {
            Email          = "test@example.com",
            Password       = "SecurePass123!",
            RecaptchaToken = "fake-token"
        }, CancellationToken.None);

        Assert.IsType<CreatedAtActionResult>(result);
    }

    [Fact]
    public async Task Signup_LowCaptchaScore_ReturnsBadRequest()
    {
        var recaptcha = Substitute.For<IRecaptchaService>();
        recaptcha.VerifyAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(new RecaptchaResult(false, 0.1, "score-too-low"));

        var controller = new AuthController(recaptcha, _authService);
        var result = await controller.Signup(new SignupRequest
        {
            Email = "bot@spam.com", Password = "pass", RecaptchaToken = "bot-token"
        }, CancellationToken.None);

        Assert.IsType<BadRequestObjectResult>(result);
    }
}

Interview Questions

Q: What is the difference between reCAPTCHA v2 and v3? v2 shows a challenge to the user (checkbox, image puzzle). v3 is invisible — it analyses behaviour and returns a score without user interaction. v3 provides better UX but requires you to decide what score is acceptable. v2 gives binary pass/fail; v3 gives a probability score.

Q: Why verify the reCAPTCHA action on the server? The action name in the token must match what you expect. A bot could record a valid token from the login page and replay it on the signup endpoint. Verifying the action ensures the token was generated for the specific operation you're protecting.

Q: What are reCAPTCHA token expiry implications? Tokens expire after 2 minutes. If a user takes longer than that on a form (slow connection, distracted), the token will fail verification. Your frontend should refresh the token before submission, or your backend should return a specific error that triggers token regeneration.

Q: How do you test reCAPTCHA integration without hitting Google's API in tests? Abstract the service behind IRecaptchaService and mock it in unit tests. For integration tests, use test API keys that Google provides — they always return a passing score for any token during development.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.