Setting ASP.Net Cookies in Middleware

When working on an ASP.Net RazorPages project this week I had a scenario where we were tracking a user choice in a cookie. At one point in the application workflow a user would select an active item and we would light up some additional ctions.  Easy enough.

First Pass

To get warmed up I started setting the cookie in a post handler on my page.  

public async Task<IActionResult> OnPostAsync()
{   
    var cookieOptions = new CookieOptions()
    {
        Path = "/",
        IsEssential = true,
        HttpOnly = true,
        Secure = true
    };

    HttpContext.Response.Cookies.Append("SelectedValue", SelectedValue, cookieOptions);

    return RedirectToPage("./Index");
}

Simple enough but I think we can do better.

Second Pass

Instead of setting the value directly let's move the value to a scoped user context service.  This left my updated page looking something like this.

public class SelectModel : PageModel
{
    private readonly ApplicationContextService _applicationContext;

    public SelectModel(ApplicationContextService applicationContext)
    {
        _applicationContext = applicationContext;
    }

    [BindProperty]
    public string SelectedValue{get;set;}
    
    public void OnGet()
    {
        SelectedValue = _applicationContext.SelectedValue;
    }

    public IActionResult OnPost()
    {   
        _applicationContext.SelectedValue = SelectedValue;
        return RedirectToPage("./Select");
    }
}

In my application context middleware I wrote some before and after code for reading and setting the cookie.

public async Task InvokeAsync(HttpContext context, ApplicationContextService appContext)
{

    if (context.Request.Cookies.TryGetValue("SelectedValue", out var inValue))
    {
        appContext.SelectedValue = inValue;
    }
    else
    {
        appContext.SelectedValue = null;
    }

    await _next(context);
    
    if (!string.IsNullOrWhiteSpace(appContext.SelectedValue) && appContext.SelectedValue != inValue)
    {
        var cookieOptions = new CookieOptions()
        {
            Path = "/",
            IsEssential = true,
            HttpOnly = true,
            Secure = true
        };

        context.Response.Cookies.Append("SelectedValue", appContext.SelectedValue, cookieOptions);
    }
    else if (string.IsNullOrWhiteSpace(appContext.SelectedValue))
    {
        context.Response.Cookies.Delete("SelectedValue");
    }
    
}

I ran my code again and was rudely given this error in chrome dev tools.

net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)

The server side code had responded with this more helpful error message

Connection id "0HM4V57QBU3H5", Request id "0HM4V57QBU3H5:00000008": An unhandled exception was thrown by the application.
System.InvalidOperationException: Headers are read-only, response has already started.

"Kick Myself" Pass

Of course, I should have known better than to try this simple approach.  I know that I can' t set response headers after the response has already started.  In my rush to refactor I had failed to recall that await _next(context) was executing the code in my page and returning the result.  Now how do I set the cookie before the response starts?

  • It worked in the page post handler
  • I could add a 'Select' method to the context class that sets or clears the cookie (but I don't really want the context class to have a dependency on my http context)
  • Hmm... I could google and read the docs?

Final Pass

My searching led met to the Response.OnStarting method of the HttpResponse class. This method accepts a delegate to be invoked just before response headers are sent.

I updated my middleware to set the cookie inside of a lambda passed to the 'OnStarting' method and everything started to work like expected.

public async Task InvokeAsync(HttpContext context, ApplicationContextService appContext)
{

    if (context.Request.Cookies.TryGetValue("SelectedValue", out var inValue))
    {
        appContext.SelectedValue = inValue;
    }
    else
    {
        appContext.SelectedValue = null;
    }
    
    context.Response.OnStarting(() => {
        if (!string.IsNullOrWhiteSpace(appContext.SelectedValue) && appContext.SelectedValue != inValue)
        {
            var cookieOptions = new CookieOptions()
            {
                Path = "/",
                IsEssential = true,
                HttpOnly = true,
                Secure = true
            };

            context.Response.Cookies.Append("SelectedValue", appContext.SelectedValue, cookieOptions);
        }
        else if (string.IsNullOrWhiteSpace(appContext.SelectedValue))
        {
            context.Response.Cookies.Delete("SelectedValue");
        }
        return Task.FromResult(0);
    });

    await _next(context);
}

Notes from the docs:

Callbacks registered here run in reverse order. The last one registered is invoked first.  The reverse order is done to replicate the way middleware works, with the inner-most middleware looking at the response first.

Conclusion

The "HttpResponse.OnStarting" method is our friend and there is also a "OnComplete" method that may come in handy someday.

Other Scenarios

Example code is rarely complete. So, here are some ways we could expand or improve on this topic.

  • Prove that it works by adding injection IAppContext into a couple of different pages and changing the value in each page
  • Modify other parts of the response in addition to setting or deleting a cookie

Learn More

Cover image by Christina Branco on Unsplash