The Problem Nobody Thinks About Until It Happens
You build a contact form. You connect it to SendGrid or Resend to deliver the submissions to your inbox. You deploy it. Everything works.
Three months later you get a billing alert.
A bot found your form endpoint — either by crawling your site or by scanning common API paths — and has been submitting it hundreds of times a day. Every submission triggers an email. Every email costs money. Your free tier ran out weeks ago and you didn't notice.
This isn't a hypothetical. It happens to small business sites, portfolio pages, and SaaS contact forms regularly. The fix is straightforward and takes less than 30 minutes to implement. The mistake is not knowing you need it until after the fact.
Why Bots Target Contact Forms
Contact forms connected to email services are attractive targets for several reasons:
- They're easy to find — most sites have a /contact page, and the form typically posts to a predictable endpoint like /api/contact or /api/email
- They trigger real actions — unlike a database write that nobody sees, a contact form triggers an email, which can be used for spam campaigns
- They often have no protection — developers add the form, test it manually, and ship it without considering automated abuse
- They cost the site owner money — which is sometimes the point of the attack
Even if the attacker gains nothing directly, flooding your email service endpoint drives up your costs and can get your account flagged for abuse.
Why Frontend Validation Is Not Enough
The first instinct is to add a rate limit or validation on the frontend. This does nothing.
A bot doesn't use your frontend. It sends HTTP requests directly to your API endpoint. Any validation that only exists in JavaScript is completely invisible to a bot making direct POST requests.
The protection must live server-side — specifically, it must run before your email service is called.
Why hCaptcha
hCaptcha is the right choice for most small-to-medium sites for a few reasons:
- Free tier is generous — up to 1 million verifications per month, which covers any portfolio or small business site comfortably
- Privacy-friendly — GDPR compliant, doesn't track users across sites the way some alternatives do
- Simple integration — a frontend widget and a single server-side verification call
- Works with any backend — the verification is a standard HTTP POST, so it works with .NET, Node.js, Python, or anything else
Setting Up hCaptcha
Sign up at hcaptcha.com. You'll receive two keys:
- Site key — used in your frontend to render the widget (public, safe to expose)
- Secret key — used server-side to verify tokens (private, treat like a password)
Store the secret key in your environment variables or secrets manager. Never put it in source code.
Frontend Integration
Add the hCaptcha script and widget to your form. When the user completes the challenge, hCaptcha provides a token. Include that token in your form submission.
<!-- Add to your HTML head -->
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<!-- Add inside your form -->
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
In React, use the official package:
npm install @hcaptcha/react-hcaptcha
import HCaptcha from '@hcaptcha/react-hcaptcha';
import { useRef, useState } from 'react';
export function ContactForm() {
const captchaRef = useRef<HCaptcha>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!captchaToken) {
alert('Please complete the captcha');
return;
}
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...formData, captchaToken }),
});
captchaRef.current?.resetCaptcha();
};
return (
<form onSubmit={handleSubmit}>
{/* your form fields */}
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
onVerify={(token) => setCaptchaToken(token)}
ref={captchaRef}
/>
<button type="submit">Send</button>
</form>
);
}
Server-Side Verification in ASP.NET Core
This is the critical part. The token from the frontend must be verified with hCaptcha's API before you do anything else — before you send an email, before you write to a database, before you do anything that costs money or has side effects.
// HCaptchaResponse.cs
public class HCaptchaResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("error-codes")]
public List<string>? ErrorCodes { get; set; }
}
// HCaptchaService.cs
public interface IHCaptchaService
{
Task<bool> VerifyAsync(string token);
}
public class HCaptchaService : IHCaptchaService
{
private readonly HttpClient _http;
private readonly string _secretKey;
public HCaptchaService(HttpClient http, IConfiguration config)
{
_http = http;
_secretKey = config["HCaptcha:SecretKey"]
?? throw new InvalidOperationException("HCaptcha secret key not configured");
}
public async Task<bool> VerifyAsync(string token)
{
if (string.IsNullOrWhiteSpace(token)) return false;
var response = await _http.PostAsync(
"https://hcaptcha.com/siteverify",
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("secret", _secretKey),
new KeyValuePair<string, string>("response", token)
})
);
if (!response.IsSuccessStatusCode) return false;
var result = await response.Content
.ReadFromJsonAsync<HCaptchaResponse>();
return result?.Success ?? false;
}
}
// Register in Program.cs
builder.Services.AddHttpClient<IHCaptchaService, HCaptchaService>();
// ContactController.cs
[ApiController]
[Route("api/[controller]")]
public class ContactController : ControllerBase
{
private readonly IHCaptchaService _captcha;
private readonly IEmailService _email;
public ContactController(IHCaptchaService captcha, IEmailService email)
{
_captcha = captcha;
_email = email;
}
[HttpPost]
public async Task<IActionResult> Submit(ContactFormDto dto)
{
// Verify captcha FIRST — before touching the email service
var isHuman = await _captcha.VerifyAsync(dto.CaptchaToken);
if (!isHuman)
return BadRequest(new { message = "Captcha verification failed" });
// Only reaches here if the user passed the captcha challenge
await _email.SendContactFormAsync(dto);
return Ok(new { message = "Message sent successfully" });
}
}
The order matters. Captcha verification first, email service second. A failed captcha exits before any paid service is called.
The ContactFormDto
public class ContactFormDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(2000)]
public string Message { get; set; } = string.Empty;
[Required]
public string CaptchaToken { get; set; } = string.Empty;
}
Note the [Required] on CaptchaToken. If the token is missing entirely, model validation rejects the request before it even reaches your controller action. Belt and suspenders.
Adding a Rate Limit on Top
hCaptcha handles bots. Rate limiting handles legitimate users who submit the form repeatedly. In .NET 7+, rate limiting middleware is built in:
// Program.cs
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("contact", limiter =>
{
limiter.PermitLimit = 3;
limiter.Window = TimeSpan.FromMinutes(10);
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 0;
});
});
app.UseRateLimiter();
// On the controller action
[EnableRateLimiting("contact")]
[HttpPost]
public async Task<IActionResult> Submit(ContactFormDto dto) { ... }
3 submissions per 10 minutes per IP. Adjust as needed. Combined with hCaptcha, this makes automated abuse essentially impossible at any meaningful scale.
Environment Variables Setup
Your appsettings.json should only contain the key names, not the values:
// appsettings.json — safe to commit
{
"HCaptcha": {
"SecretKey": "",
"SiteKey": ""
}
}
# .env or environment variables — never commit
HCaptcha__SecretKey=your_secret_key_here
HCaptcha__SiteKey=your_site_key_here
In Azure App Service, set these as Application Settings. In Docker, pass them as environment variables. In development, use .NET's user secrets (dotnet user-secrets set "HCaptcha:SecretKey" "your_key").
Testing Without the Widget
hCaptcha provides test keys for development that always pass verification without showing a challenge to the user. Use these in your development environment so you're not solving captchas on every form test:
- Test site key:
10000000-ffff-ffff-ffff-000000000001 - Test secret key:
0x0000000000000000000000000000000000000000
These always return success from hCaptcha's verification endpoint, so your server-side validation code runs correctly in tests without a real challenge.
The Implementation Checklist
- hCaptcha site key and secret key generated and stored securely
- Frontend widget renders and produces a token on completion
- Token is included in the form submission payload
- Server verifies the token with hCaptcha's API before any other action
- Failed verification returns a 400 and exits — no email sent
- CaptchaToken is marked Required on the DTO
- Rate limiting added as an additional layer
- Test keys used in development, real keys in production via environment variables
Worth the 30 Minutes
This implementation protects your email service from abuse, keeps your costs predictable, and prevents your domain from being flagged as a spam source. The hCaptcha free tier covers a million verifications per month — more than enough for any contact form.
I added this to the Embassy of Kenya platform before it went live specifically because the contact and appointment forms were connected to SendGrid. An unprotected form on a government-adjacent platform would have been an obvious target.
If you're building a contact form or any form connected to an email service and want to make sure it's properly protected, get in touch.