RO EN

Applied security for web forms (2/4): validation, signed tokens, and challenge

Applied security for web forms (2/4): validation, signed tokens, and challenge ✨ Imagine generată cu AI
Doru Bulubașa
11 November 2026
80 views

In the first part we saw what a public form is useful for an attacker and established the design rule that eliminates the most dangerous abuse: nothing controlled by the visitor goes to an address controlled by the visitor. Now we enter the code, with the following three layers: data validation and encoding, a honeypot with a minimum time that cannot be forged, and a challenge verified on the server.

Applied Security series for web forms:

Layer 2: validation, encoding and headers

The second layer treats the form data as what it is: hostile text until proven otherwise. Three rules cover almost everything.

Length limits everywhere

A name does not have 3,000 characters. Explicit limits (for example 100 for name, 254 for email, 5,000 for message) stop an entire class of abuse and protect everything that comes after: the database, email, filters. The model below is used throughout the series:

public sealed record ContactForm(
    [property: Required, StringLength(100)] string Name,
    [property: Required, EmailAddress, StringLength(254)] string Email,
    [property: Required, StringLength(5000, MinimumLength = 10)] string Message,
    string? Website,          // honeypot, should remain empty
    string? TimingToken,      // see layer 3
    string? TurnstileToken);  // see layer 4

Input never goes into headers

The email subject is fixed, the sender is your address, and the visitor's address appears only in Reply-To, after it has been validated. Modern libraries, such as MimeKit, encode headers correctly, but the rule remains: do not build headers from free text.

Encoding on display, including in email

The message arrives in the team's inbox as HTML. If you paste it into the email body without encoding, anyone can put a link or a button that looks official into your own inbox. The same applies to the admin panel: Razor does implicit encoding, but Html.Raw or MarkupString in Blazor bypass protection.

var encoder = HtmlEncoder.Default;
var messageHtml = string.Join("<br>",
    form.Message.Split('\n').Select(line => encoder.Encode(line.TrimEnd('\r'))));

var email = new MimeMessage();
email.From.Add(new MailboxAddress("Website form", "formular@notificari.exemplu.ro"));
email.To.Add(new MailboxAddress("Team", "contact@exemplu.ro"));
if (MailboxAddress.TryParse(form.Email, out var replyTo))
    email.ReplyTo.Add(replyTo);

email.Subject = "New message from contact form"; // fixed, no input
email.Body = new TextPart(TextFormat.Html)
{
    Text = $"<p><strong>Name:</strong> {encoder.Encode(form.Name)}</p><p>{messageHtml}</p>"
};

Layer 3: honeypot and minimum time, but signed

The honeypot (a hidden field that only bots fill) and the minimum completion time are cheap and invisible to the user. They are useful but must be viewed honestly: they stop naive bots and slightly raise the cost for others. In our case, the bot learned to wait a few hours.

However, a common weakness can be easily fixed: the completion time calculated in JavaScript or sent as a simple timestamp can be forged. The robust variant is a token signed on the server, issued when the form is displayed, with a limited lifetime. ASP.NET Core Data Protection does this in a few lines:

public sealed class FormTimingToken(IDataProtectionProvider provider)
{
    private readonly ITimeLimitedDataProtector _protector =
        provider.CreateProtector("ContactForm.Timing").ToTimeLimitedDataProtector();

    // Issued when the form is displayed, valid for two hours
    public string Issue() =>
        _protector.Protect(
            DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
            TimeSpan.FromHours(2));

    public bool IsHumanPace(string? token, TimeSpan minimum)
    {
        if (string.IsNullOrEmpty(token)) return false;
        try
        {
            var issuedAt = DateTimeOffset.FromUnixTimeSeconds(
                long.Parse(_protector.Unprotect(token), CultureInfo.InvariantCulture));
            return DateTimeOffset.UtcNow - issuedAt >= minimum;
        }
        catch (CryptographicException)
        {
            return false; // forged or expired
        }
    }
}

The token forces the bot to load the page before each submission and cannot be invented. It does not stop a bot that does exactly that and then waits, which is why the next layers exist.

Layer 4: a challenge verified on the server

A modern challenge no longer necessarily means identifying traffic lights. The most used variants run mostly invisibly and require interaction only when signals are suspicious.

  Cloudflare Turnstile Google reCAPTCHA hCaptcha
How it decides Invisible challenges in the browser, usually no puzzle v3: risk score between 0 and 1; v2: checkbox or images Checkbox or images, invisible mode available
Cost Free for regular use Free up to 10,000 assessments per month, then billed via Google Cloud Free plan, paid plans for advanced features
Note Does not require the site to be on Cloudflare Keys are now managed through a Google Cloud project Focus on privacy

Whichever you choose, one thing is non-negotiable: the token is verified on the server, on every submission. The widget on the page does not protect anything by itself: a bot can send the POST directly to the endpoint without ever loading the page. For Turnstile, verification is done through the siteverify endpoint. The token is valid for 300 seconds and can be used only once, and the documentation recommends verifying hostname and action as well, not just success.

public sealed class TurnstileVerifier(HttpClient http, IOptions<TurnstileOptions> options)
{
    public async Task<bool> VerifyAsync(string? token, string? remoteIp, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(token) || token.Length > 2048) return false;

        var fields = new Dictionary<string, string>
        {
            ["secret"] = options.Value.SecretKey,
            ["response"] = token,
        };
        if (remoteIp is not null) fields["remoteip"] = remoteIp;

        using var response = await http.PostAsync(
            "https://challenges.cloudflare.com/turnstile/v0/siteverify",
            new FormUrlEncodedContent(fields), ct);
        if (!response.IsSuccessStatusCode) return false;

        var result = await response.Content.ReadFromJsonAsync<TurnstileResult>(ct);
        return result is { Success: true }
            && result.Hostname == options.Value.ExpectedHostname
            && result.Action == "contact";
    }

    private sealed record TurnstileResult(
        [property: JsonPropertyName("success")] bool Success,
        [property: JsonPropertyName("hostname")] string? Hostname,
        [property: JsonPropertyName("action")] string? Action,
        [property: JsonPropertyName("error-codes")] string[]? ErrorCodes);
}

// Program.cs
builder.Services.AddHttpClient<TurnstileVerifier>(c => c.Timeout = TimeSpan.FromSeconds(5));

The secret key is not stored in appsettings.json in the repository. Its place is in Key Vault or environment variables, read with Managed Identity, as shown in the article about secrets without passwords.

What to do when the challenge service does not respond

The above code is fail-closed: if verification fails for any reason, the form rejects the message. This is the safe choice, but means that a problem with the provider blocks your form. The alternative is controlled fail-open: you accept the message but pass it through a stricter score threshold and do not send anything outside. It is a graceful degradation decision, like those discussed in the article about what to do when a dependency is down. The important thing is to make the decision deliberately, not by accident.

And one limit worth knowing: CAPTCHA-solving farms exist and are cheap. A challenge raises the cost of an automated attack significantly, but does not make it impossible.

What’s next

So far we have filtered each submission individually. Our attack showed, however, that the real problem is volume: 41 IPs, one message every few minutes, for days on end. In the third part we move on to layers that look at the big picture: rate limiting on multiple keys, content scoring, isolating email sending, and monitoring.