Implementing Google Captcha v3 in .NET Core 6 C#

Recently, I had needed to use Google reCaptcha v2 and v3 depending on the project and depending on the requirements. With this in mind, why not implement them both? In this tutorial, I'll explain how to implement Google reCaptcha v2 and v3 using ASP .NET Core.

I like to start from the user end, in this case, when the page is rendered. In this case, I'll start to implement the view models.

public static class GoogleCaptchaConstants
{
    public static string Version_2 = "2";
    public static string Version_3 = "3";
}

public class CaptchaViewModel
{
    public bool IsEnabled { get; set; }
    public string SiteKey { get; set; }
    public string Action { get; set; }
    public string Version { get; set; }

    public bool IsVersion2() => Version == GoogleCaptchaConstants.Version_2;
    public bool IsVersion3() => Version == GoogleCaptchaConstants.Version_3;

    public override string ToString() => $"{nameof(IsEnabled)}: {IsEnabled}, {nameof(SiteKey)}: {SiteKey}, {nameof(Action)}: {Action}, {nameof(Version)}: {Version}";
}

Now, just for kicks, I'll leave the actual Razor page implementation to the end to make sure all is set, options are being configured and the service is connected. Now, talking about services, let's create the service that talks with Google reCaptcha.

public interface ICaptchaService
{
    Task<CaptchaVerificationV2ResponseModel> VerifyV2Async(string token);
    Task<CaptchaVerificationV3ResponseModel> VerifyV3Async(CaptchaVerificationRequestModel request);
}

Obviously, let's plug in the ResponseModel's.

public class CaptchaVerificationV2ResponseModel
{
    [JsonProperty("success")]
    public bool Success { get; set; }

    [JsonProperty("challenge_ts")]
    public DateTime Date { get; set; }

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

    public override string ToString() => $"{nameof(Success)}: {Success}, {nameof(Date)}: {Date}, {nameof(Hostname)}: {Hostname}";
}

public class CaptchaVerificationV3ResponseModel : CaptchaVerificationV2ResponseModel
{
    [JsonProperty("score")]
    public decimal Score { get; set; }

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

    public override string ToString() => $"{base.ToString()}, {nameof(Score)}: {Score}, {nameof(Action)}: {Action}";
}

Before the actual implementation, we need to set up some options, using the IOptions pattern on .NET. This is actual great because allow us to enable/disable, set keys, etc. A simple implementation would be something like:

public class GoogleCaptchaOptions
{
    public bool Enabled { get; set; }
    public string SiteKey { get; set; }
    public string Secret { get; set; }
    public string Action { get; set; }
    public decimal ScoreThreshold { get; set; }
    public string Version { get; set; }

    public bool IsVersion2() => Version == GoogleCaptchaConstants.Version_2;
    public bool IsVersion3() => Version == GoogleCaptchaConstants.Version_3;

    /// <inheritdoc />
    public override string ToString() => $"{nameof(Enabled)}: {Enabled}, {nameof(SiteKey)}: {SiteKey}, {nameof(Action)}: {Action}, {nameof(ScoreThreshold)}: {ScoreThreshold}, {nameof(Version)}: {Version}";
}

Now, where the real magic happens, the actual service implementation.

public class GoogleCaptchaService : ICaptchaService
{
    private readonly HttpClient _httpClient;
    private readonly GoogleCaptchaOptions _options;

    public GoogleCaptchaService(HttpClient httpClient, IOptions<GoogleCaptchaOptions> options)
    {
        _httpClient = httpClient;
        _options = options.Value;
    }

    public async Task<CaptchaVerificationV2ResponseModel> VerifyV2Async(string token)
    {
        string verification = await _httpClient.GetStringAsync($"recaptcha/api/siteverify?secret={_options.Secret}&response={token}");

        return JsonConvert.DeserializeObject<CaptchaVerificationV2ResponseModel>(verification);
    }

    public async Task<CaptchaVerificationV3ResponseModel> VerifyV3Async(CaptchaVerificationRequestModel request)
    {
        var requestInput = new Dictionary<string, string>
        {
            { "secret", _options.Secret },
            { "response", request.Token }
        };

        if (request.HasRemoteIp())
            requestInput.Add("remoteip", request.RemoteIp);

        HttpResponseMessage response = await _httpClient.PostAsync("recaptcha/api/siteverify", new FormUrlEncodedContent(requestInput));
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<CaptchaVerificationV3ResponseModel>();
    }
}

With this in place, we need to set up some settings and I personally prefer appsettings.json but you can set up where ever you prefer.

  "GoogleCaptcha": {
    "Enabled": true,
    "SiteKey": "site-key-goes-here",
    "Secret": "site-secret-goes-here",
    "Action": "the-action-you-prefer",
    "ScoreThreshold": 0.5,
    "Version": "2"
  }

Now, just need to plug things in by registering this service and configuration into the dependency injection.

public static IServiceCollection AddGoogleCaptcha(this IServiceCollection services, IConfiguration configuration)
{
    services.Configure<GoogleCaptchaOptions>(configuration.GetSection("GoogleCaptcha"));
    services.AddHttpClient<ICaptchaService, GoogleCaptchaService>(httpClient =>
    {
        httpClient.BaseAddress = new Uri("https://www.google.com/");
    });

    return services;
}

🔔 Send this to the view

An easy way to do this, is simply extend your view model to have the new GoogleCaptchaViewModel object there as a property. To do that, let's inject the service and the options into the constructor of the controller.

public HomeController(IOptions<GoogleCaptchaOptions> googleCaptchaOptions, ICaptchaService captchaService)
{
    _googleCaptchaOptions = googleCaptchaOptions.Value;
    _captchaService = captchaService;
}

Now, in your view model just send those new options into the view, using composition within your existing view model. In this example I call it viewModel and that can be any type the developer wants.

viewModel.Captcha = new CaptchaViewModel
{
    SiteKey = _googleCaptchaOptions.SiteKey,
    IsEnabled = _googleCaptchaOptions.Enabled,
    Action = _googleCaptchaOptions.Action,
    Version = _googleCaptchaOptions.Version
};

In the Razor side, we want to distinguish between version 2 and version 3 because they act differently.


@if (Model.Captcha.IsVersion2())
{
    <div class="g-recaptcha" data-sitekey="@Model.Captcha.SiteKey"></div>
}

@if (Model.Captcha.IsVersion3())
{
    <button class="btn btn-primary g-recaptcha" disabled type="submit"
        data-action="@Model.Captcha.Action" data-sitekey="@Model.Captcha.SiteKey" data-callback="onSubmit">
        Submit
    </button>
}
else
{
    <button class="btn btn-primary" disabled type="submit">
        Submit
    </button>
}

@section Scripts {
    @if (Model.Captcha.IsEnabled)
    {
        @if (Model.Captcha.IsVersion2())
        {
            <script src="https://www.google.com/recaptcha/api.js" defer></script>
        }

        @if (Model.Captcha.IsVersion3())
        {
            <script src="https://www.google.com/recaptcha/[email protected]"></script>
            <script>
                function onSubmit(token) {
                    document.getElementById("my-form-id").submit();
                }
            </script>
        }
    }
}

🍒 Just the cherry on top of the cake is to validate this per controller action. Here's a good example.

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(FormViewModel model)
{
    // validate all model state and business logic before (or after)

    if (_googleCaptchaOptions.Enabled)
    {
        string googleCaptchaResponse = Request.Form["g-recaptcha-response"].ToString();
        if (string.IsNullOrEmpty(googleCaptchaResponse))
        {
            ModelState.AddModelError(string.Empty, "Please verify the captcha before continue.");
        }

        if (_googleCaptchaOptions.IsVersion2())
        {
            CaptchaVerificationV2ResponseModel response = await _captchaService.VerifyV2Async(googleCaptchaResponse);
            if (!response.Success)
            {
                ModelState.AddModelError(string.Empty, "Request doesn't match the minimum security requirements. Please try again later.");
            }
        }
        else if (_googleCaptchaOptions.IsVersion3())
        {
            var request = new CaptchaVerificationRequestModel
            {
                Token = googleCaptchaResponse,
                RemoteIp = HttpContext.Connection.RemoteIpAddress?.ToString()
            };

            CaptchaVerificationV3ResponseModel response = await _captchaService.VerifyV3Async(request);

            if (!response.Success)
            {
                ModelState.AddModelError(string.Empty, "Something went wrong with your verification request. Please try again later.");
            }
            else
            {
                // it was a success captcha response
                // doesn't mean the scope is great
                // this step here is optional. You can have multiple actions within the same application.
                // And by action, it can be: login, reset password, change password (being logged in), etc.
                // Being so, doesn't make sense to be verified by setting but by context instead

                if (!response.Action.Equals(_googleCaptchaOptions.Action, StringComparison.InvariantCulture))
                {
                    ModelState.AddModelError(string.Empty, "Site action mismatch. Please try again later.");
                }

                if (response.Score < _googleCaptchaOptions.ScoreThreshold)
                {
                    ModelState.AddModelError(string.Empty, "Request doesn't match the minimum security requirements. Please try again later.");
                }
            }
        }
        else // _googleCaptchaOptions.Version is invalid
        {
            ModelState.AddModelError(string.Empty, "Request doesn't match the minimum security requirements. Please try again later.");
        }
    }
}

📣 Here's the source code where you can clone, add pull requests, download and do whatever you feel like with the code.