UPDATE 8/29/2023 TLDR: For those who don't want to learn all the details but just want to use captcha right away, the code in this post has been updated to support latest .NET 6.0, 7.0 as well as Linux. Please use my captcha library https://github.com/EdiWang/Edi.Captcha.AspNetCore now you can skip the entire blog post.

If you want to use captcha code to protect your website from spam messages, there are a few options such as Google ReCaptcha and captcha.com. Both of them can be integrated into ASP.NET Core applications. However, you may still want to generate the captcha code yourself for some reason, such as your website may be used in mainland China... This post will show you how to generate and use captcha code in the latest version of ASP.NET Core.

The method I use is from Microsoft official code sample here https://code.msdn.microsoft.com/How-to-make-and-use-d0d1752a but with a few modifications so that it can work with .NET Core 2.x as well as some improvements.

How does captcha work


A simple captcha is generating a random code (numbers or alphabets), save the code to the session, and also make an image to show on the webpage. When a user posts the content back to the server, the server compares the user input captcha with the value stored in session to check if the user has entered a correct captcha code. The workflow is explained in the figure below:

This is a sample in the next version of my blog system:

Implement captcha code in ASP.NET Core 2.1


After understand how captcha code works, let's dig into the implementation. 

1. Preparation

First, you need to set your project to allow unsafe code for both Debug and Release configuration.

We need to use classes in System.Drawing.Imaging namespace, so we also need to install a NuGet package:

Install-Package System.Drawing.Common -Version 4.5.1

Because captcha relies on Session storage, so we also have to enable Session support in ASP.NET Core in Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // other code...
    
    // add session support
    services.AddSession(options =>
    {
        options.IdleTimeout = TimeSpan.FromMinutes(20);
        options.Cookie.HttpOnly = true;
    });
    // other code...
}

And also here

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    // other code...
    // add session support
    app.UseSession();
    // other code...
}

Note: Session relies on Cookies, so make sure you accept the GDPR cookie policy first, which is added into ASP.NET Core 2.1 default template.

2. Generate Captcha Image

Create a CaptchaResult class to represent the captcha information:

public class CaptchaResult
{
    public string CaptchaCode { get; set; }
    public byte[] CaptchaByteData { get; set; }
    public string CaptchBase64Data => Convert.ToBase64String(CaptchaByteData);
    public DateTime Timestamp { get; set; }
}

And a Captcha class for generating and validating the captcha

public static class Captcha
{
    const string Letters = "2346789ABCDEFGHJKLMNPRTUVWXYZ";

    public static string GenerateCaptchaCode()
    {
        Random rand = new Random();
        int maxRand = Letters.Length - 1;

        StringBuilder sb = new StringBuilder();

        for (int i = 0; i < 4; i++)
        {
            int index = rand.Next(maxRand);
            sb.Append(Letters[index]);
        }

        return sb.ToString();
    }

    public static bool ValidateCaptchaCode(string userInputCaptcha, HttpContext context)
    {
        var isValid = userInputCaptcha == context.Session.GetString("CaptchaCode");
        context.Session.Remove("CaptchaCode");
        return isValid;
    }

    public static CaptchaResult GenerateCaptchaImage(int width, int height, string captchaCode)
    {
        using (Bitmap baseMap = new Bitmap(width, height))
        using (Graphics graph = Graphics.FromImage(baseMap))
        {
            Random rand = new Random();

            graph.Clear(GetRandomLightColor());

            DrawCaptchaCode();
            DrawDisorderLine();
            AdjustRippleEffect();

            MemoryStream ms = new MemoryStream();

            baseMap.Save(ms, ImageFormat.Png);

            return new CaptchaResult { CaptchaCode = captchaCode, CaptchaByteData = ms.ToArray(), Timestamp = DateTime.Now };

            int GetFontSize(int imageWidth, int captchCodeCount)
            {
                var averageSize = imageWidth / captchCodeCount;

                return Convert.ToInt32(averageSize);
            }

            Color GetRandomDeepColor()
            {
                int redlow = 160, greenLow = 100, blueLow = 160;
                return Color.FromArgb(rand.Next(redlow), rand.Next(greenLow), rand.Next(blueLow));
            }

            Color GetRandomLightColor()
            {
                int low = 180, high = 255;

                int nRend = rand.Next(high) % (high - low) + low;
                int nGreen = rand.Next(high) % (high - low) + low;
                int nBlue = rand.Next(high) % (high - low) + low;

                return Color.FromArgb(nRend, nGreen, nBlue);
            }

            void DrawCaptchaCode()
            {
                SolidBrush fontBrush = new SolidBrush(Color.Black);
                int fontSize = GetFontSize(width, captchaCode.Length);
                Font font = new Font(FontFamily.GenericSerif, fontSize, FontStyle.Bold, GraphicsUnit.Pixel);
                for (int i = 0; i < captchaCode.Length; i++)
                {
                    fontBrush.Color = GetRandomDeepColor();

                    int shiftPx = fontSize / 6;

                    float x = i * fontSize + rand.Next(-shiftPx, shiftPx) + rand.Next(-shiftPx, shiftPx);
                    int maxY = height - fontSize;
                    if (maxY < 0) maxY = 0;
                    float y = rand.Next(0, maxY);

                    graph.DrawString(captchaCode[i].ToString(), font, fontBrush, x, y);
                }
            }

            void DrawDisorderLine()
            {
                Pen linePen = new Pen(new SolidBrush(Color.Black), 3);
                for (int i = 0; i < rand.Next(3, 5); i++)
                {
                    linePen.Color = GetRandomDeepColor();

                    Point startPoint = new Point(rand.Next(0, width), rand.Next(0, height));
                    Point endPoint = new Point(rand.Next(0, width), rand.Next(0, height));
                    graph.DrawLine(linePen, startPoint, endPoint);

                    //Point bezierPoint1 = new Point(rand.Next(0, width), rand.Next(0, height));
                    //Point bezierPoint2 = new Point(rand.Next(0, width), rand.Next(0, height));

                    //graph.DrawBezier(linePen, startPoint, bezierPoint1, bezierPoint2, endPoint);
                }
            }

            void AdjustRippleEffect()
            {
                short nWave = 6;
                int nWidth = baseMap.Width;
                int nHeight = baseMap.Height;

                Point[,] pt = new Point[nWidth, nHeight];

                for (int x = 0; x < nWidth; ++x)
                {
                    for (int y = 0; y < nHeight; ++y)
                    {
                        var xo = nWave * Math.Sin(2.0 * 3.1415 * y / 128.0);
                        var yo = nWave * Math.Cos(2.0 * 3.1415 * x / 128.0);

                        var newX = x + xo;
                        var newY = y + yo;

                        if (newX > 0 && newX < nWidth)
                        {
                            pt[x, y].X = (int)newX;
                        }
                        else
                        {
                            pt[x, y].X = 0;
                        }


                        if (newY > 0 && newY < nHeight)
                        {
                            pt[x, y].Y = (int)newY;
                        }
                        else
                        {
                            pt[x, y].Y = 0;
                        }
                    }
                }

                Bitmap bSrc = (Bitmap)baseMap.Clone();

                BitmapData bitmapData = baseMap.LockBits(new Rectangle(0, 0, baseMap.Width, baseMap.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
                BitmapData bmSrc = bSrc.LockBits(new Rectangle(0, 0, bSrc.Width, bSrc.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);

                int scanline = bitmapData.Stride;

                IntPtr scan0 = bitmapData.Scan0;
                IntPtr srcScan0 = bmSrc.Scan0;

                unsafe
                {
                    byte* p = (byte*)(void*)scan0;
                    byte* pSrc = (byte*)(void*)srcScan0;

                    int nOffset = bitmapData.Stride - baseMap.Width * 3;

                    for (int y = 0; y < nHeight; ++y)
                    {
                        for (int x = 0; x < nWidth; ++x)
                        {
                            var xOffset = pt[x, y].X;
                            var yOffset = pt[x, y].Y;

                            if (yOffset >= 0 && yOffset < nHeight && xOffset >= 0 && xOffset < nWidth)
                            {
                                if (pSrc != null)
                                {
                                    p[0] = pSrc[yOffset * scanline + xOffset * 3];
                                    p[1] = pSrc[yOffset * scanline + xOffset * 3 + 1];
                                    p[2] = pSrc[yOffset * scanline + xOffset * 3 + 2];
                                }
                            }

                            p += 3;
                        }
                        p += nOffset;
                    }
                }

                baseMap.UnlockBits(bitmapData);
                bSrc.UnlockBits(bmSrc);
                bSrc.Dispose();
            }
        }
    }
}

There are a few things to point out.

1. The letters do not contain all numbers and English alphabets, because some characters are difficult to distinguish, for example:

  • Number 0 and English O
  • Number 5 and English S
  • Number 1 and English I

2. I comment out Bezier code in function DrawDisorderLine() because when the image is very small, it would be very hard to see the characters if there are Bezier lines on it.

Now, in your MVC controller, create an Action for return the captcha image

[Route("get-captcha-image")]
public IActionResult GetCaptchaImage()
{
    int width = 100;
    int height = 36;
    var captchaCode = Captcha.GenerateCaptchaCode();
    var result = Captcha.GenerateCaptchaImage(width, height, captchaCode);
    HttpContext.Session.SetString("CaptchaCode", result.CaptchaCode);
    Stream s = new MemoryStream(result.CaptchaByteData);
    return new FileStreamResult(s, "image/png");
}

Now, try to access this Action, you should get a captcha image like this:

3. Use Captcha Image

Add a new property called CaptchaCode to your model that is used for posting content to the server.

[Required]
[StringLength(4)]
public string CaptchaCode { get; set; }

Add a new input field for the CaptchaCode and an image which invokes the "GetCaptchaImage" Action in above section in your view.

<div class="input-group">
    <div class="input-group-prepend">
        <img id="img-captcha" src="~/get-captcha-image" />
    </div>
    <input type="text" class="form-control" placeholder="Captcha Code" asp-for="CaptchaCode" maxlength="4" />
    <span asp-validation-for="CaptchaCode" class="text-danger"></span>
</div>

In your Action that deal with user post content, add a logic to check captcha code:

if (ModelState.IsValid)
{
    // Validate Captcha Code
    if (!Captcha.ValidateCaptchaCode(model.CaptchaCode, HttpContext))
    {
        // return error
    }
    // continue business logic
}

4. Additional Enhancements

You can let the user click and load a new captcha image via jQuery.

$("#img-captcha").click(function () {
    resetCaptchaImage();
});

function resetCaptchaImage() {
    d = new Date();
    $("#img-captcha").attr("src", "/get-captcha-image?" + d.getTime());
}