The Captcha module used in my blog
NuGet Package Manager
Install-Package Edi.Captcha
or .NET CLI
dotnet add package Edi.Captcha
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(20);
options.Cookie.HttpOnly = true;
});
services.AddSessionBasedCaptcha();
// Don't forget to add this line in your `Configure` method.
app.UseSession();
or you can customize the options
services.AddSessionBasedCaptcha(option =>
{
option.Letters = "2346789ABCDEFGHJKLMNPRTUVWXYZ";
option.SessionName = "CaptchaCode";
option.CodeLength = 4;
});
private readonly ISessionBasedCaptcha _captcha;
public SomeController(ISessionBasedCaptcha captcha)
{
_captcha = captcha;
}
[Route("get-captcha-image")]
public IActionResult GetCaptchaImage()
{
var s = _captcha.GenerateCaptchaImageFileStream(
HttpContext.Session,
100,
36
);
return s;
}
app.UseSession().UseSessionCaptcha(options =>
{
options.RequestPath = "/captcha-image";
options.ImageHeight = 36;
options.ImageWidth = 100;
});
[Required]
[StringLength(4)]
public string CaptchaCode { get; set; }
<div class="col">
<div class="input-group">
<div class="input-group-prepend">
<img id="img-captcha" src="~/captcha-image" />
</div>
<input type="text"
asp-for="CommentPostModel.CaptchaCode"
class="form-control"
placeholder="Captcha Code"
autocomplete="off"
minlength="4"
maxlength="4" />
</div>
<span asp-validation-for="CommentPostModel.CaptchaCode" class="text-danger"></span>
</div>
_captcha.ValidateCaptchaCode(model.CommentPostModel.CaptchaCode, HttpContext.Session)
To make your code look more cool, you can also write an Action Filter like this:
public class ValidateCaptcha : ActionFilterAttribute
{
private readonly ISessionBasedCaptcha _captcha;
public ValidateCaptcha(ISessionBasedCaptcha captcha)
{
_captcha = captcha;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
var captchaedModel =
context.ActionArguments.Where(p => p.Value is ICaptchable)
.Select(x => x.Value as ICaptchable)
.FirstOrDefault();
if (null == captchaedModel)
{
context.ModelState.AddModelError(nameof(captchaedModel.CaptchaCode), "Captcha Code is required");
context.Result = new BadRequestObjectResult(context.ModelState);
}
else
{
if (!_captcha.Validate(captchaedModel.CaptchaCode, context.HttpContext.Session))
{
context.ModelState.AddModelError(nameof(captchaedModel.CaptchaCode), "Wrong Captcha Code");
context.Result = new ConflictObjectResult(context.ModelState);
}
else
{
base.OnActionExecuting(context);
}
}
}
}
and then
services.AddScoped<ValidateCaptcha>();
and then
public class YourModelWithCaptchaCode : ICaptchable
{
public string YourProperty { get; set; }
[Required]
[StringLength(4)]
public string CaptchaCode { get; set; }
}
[ServiceFilter(typeof(ValidateCaptcha))]
public async Task<IActionResult> SomeAction(YourModelWithCaptchaCode model)
{
// ....
}
Advantages of Stateless Captcha:
- ✅ Works in clustered/load-balanced environments
- ✅ No server-side session storage required
- ✅ Built-in expiration through encryption
- ✅ Secure token-based validation
- ✅ Better scalability
- ✅ Single API call for both token and image
services.AddStatelessCaptcha();
or with custom options:
services.AddStatelessCaptcha(options =>
{
options.Letters = "2346789ABCDGHKMNPRUVWXYZ";
options.CodeLength = 4;
options.TokenExpiration = TimeSpan.FromMinutes(5);
});
public class StatelessHomeModel
{
[Required]
[StringLength(4)]
public string CaptchaCode { get; set; }
public string CaptchaToken { get; set; }
}
See: src\Edi.Captcha.SampleApp\Controllers\StatelessController.cs and src\Edi.Captcha.SampleApp\Views\Stateless\Index.cshtml for a complete example.
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\shared-network-path\keys"))
.SetApplicationName("YourAppName"); // Must be consistent across all instances
services.AddStatelessCaptcha(options =>
{
// Your captcha configuration
});
}
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.PersistKeysToAzureBlobStorage("DefaultEndpointsProtocol=https;AccountName=...", "keys-container", "dataprotection-keys.xml")
.SetApplicationName("YourAppName");
services.AddStatelessCaptcha(options =>
{
// Your captcha configuration
});
}
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect("your-redis-connection"), "DataProtection-Keys")
.SetApplicationName("YourAppName");
services.AddStatelessCaptcha(options =>
{
// Your captcha configuration
});
}
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.PersistKeysToDbContext<YourDbContext>()
.SetApplicationName("YourAppName");
services.AddStatelessCaptcha(options =>
{
// Your captcha configuration
});
}
For single server deployments, no additional configuration is required. The default Data Protection configuration will work correctly.
To verify your cluster configuration is working:
- Generate a captcha on Server A
- Submit the form to Server B (or any other server)
- Validation should succeed
If validation fails with properly entered captcha codes, check your Data Protection configuration.
When to use Shared Key Stateless Captcha:
- ✅ Full control over encryption keys
- ✅ Works without ASP.NET Core Data Protection API
- ✅ Simpler cluster configuration
- ✅ Custom key rotation strategies
- ✅ Works across different application frameworks
- ✅ No dependency on external storage for keys
services.AddSharedKeyStatelessCaptcha(options =>
{
options.SharedKey = "your-32-byte-base64-encoded-key"; // Generate securely
options.FontStyle = FontStyle.Bold;
options.DrawLines = true;
options.TokenExpiration = TimeSpan.FromMinutes(5);
});
Important: Use a cryptographically secure random key. Here's how to generate one:
// Generate a secure 256-bit key (one-time setup)
using (var rng = RandomNumberGenerator.Create())
{
var keyBytes = new byte[32]; // 256 bits
rng.GetBytes(keyBytes);
var base64Key = Convert.ToBase64String(keyBytes);
Console.WriteLine($"Shared Key: {base64Key}");
}
{
"CaptchaSettings": {
"SharedKey": "your-generated-base64-key-here",
"TokenExpirationMinutes": 5
}
}
public void ConfigureServices(IServiceCollection services)
{
var captchaKey = Configuration["CaptchaSettings:SharedKey"];
var expirationMinutes = Configuration.GetValue<int>("CaptchaSettings:TokenExpirationMinutes", 5);
services.AddSharedKeyStatelessCaptcha(options =>
{
options.SharedKey = captchaKey;
options.TokenExpiration = TimeSpan.FromMinutes(expirationMinutes);
// Other options...
});
}
See: src\Edi.Captcha.SampleApp\Controllers\SharedKeyStatelessController.cs and src\Edi.Captcha.SampleApp\Views\SharedKeyStateless\Index.cshtml for a complete example.