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.
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 scoreSetup: Google reCAPTCHA Console
- Go to https://www.google.com/recaptcha/admin
- Register a new site → choose reCAPTCHA v3
- Add your domain(s)
- Get two keys:
- Site Key — used in your frontend (public)
- Secret Key — used in your backend (keep secret)
Configuration
// appsettings.json
{
"RecaptchaSettings": {
"SecretKey": "YOUR_SECRET_KEY_HERE",
"SiteKey": "YOUR_SITE_KEY_HERE",
"MinimumScore": 0.5,
"VerificationUrl": "https://www.google.com/recaptcha/api/siteverify"
}
}// 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
// 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
// Program.cs
builder.Services.AddOptions<RecaptchaSettings>()
.BindConfiguration(RecaptchaSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHttpClient<IRecaptchaService, RecaptchaService>();Controller Integration
// 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)
<!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:
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
// 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.