Transitioning an ASP.NET Web API from Identity Cookies to JWT
This article is a chronicle of a two-year journey planning and implementing a low-priority full stack web application project to retire an ASP.NET Razor login form and incrementally phase out Identity web cookies with JWT.
TLDR: Role based JWT Tokens in ASP.NET Core APIs
In The Modern Web Developer, I quickly reviewed modernizing an existing web application by replacing server-side page rendering with single-page application client-side solutions like React, Angular, and Vue. Then, in a separate article series, Automated Testing of the Modern Web Application, I explored some popular technologies that replace manual unit testing, integration testing, and end-to-end functional testing with powerful automated test suites.
But with all these improvements I kept running into a technology integration sticking point, namely, web cookie user authentication and authorization. My existing web application was originally built in 2017 using the Microsoft .NET Core v1.0 framework, server-side Razor MVC pages, and the Identity framework using web cookies for user authentication and authorization.
By 2021 my web application had been upgraded to .NET 3.1, but I was still using Razor MVC pages and Identity web cookies. My first goal was to replace the server-side Razor pages with the Angular v12 SPA framework. The learning curve was steep, but in 9 months I had 95% of the front-end running in production with Angular.
I still had one nagging work-around in place though: I was unable to get my new Angular user login form to retrieve an Identity web cookie from my ASP.NET web server’s authentication API. My Angular application was successfully sending the user credentials to the endpoint, and the Identify framework was successfully authenticating the user, but ASP.NET wasn’t returning a web cookie back to Angular. My old ASP.NET Razor web page did that automatically when it redirected the user from the login page to the home page. After several days of research and troubleshooting, it looked like the only way to return an Identity cookie from an API endpoint was to write some custom low-level Identity code to create the cookie manually. This would include require writing code to iterate though of all the user’s group memberships and include them in the cookie as user claims (for example, see Create an authentication cookie).
I already knew I wanted to eventually replace cookies with a more modern and flexible approach like web tokens (more about that later), so I was reluctant to invest additional development time in cookies. Instead, I opted for a work-around. I would keep the existing server-side Razor login page, but added some JavaScript code to it to redirect to the Angular app’s home page instead of the old Razor home page. I disliked the architecture smell, but I had higher priority development tasks in the queue, and the work-around was reliable, so I’d have to leave it in place for the foreseeable future. I would later use the same work-around with a React app and a Vue app, but I really disliked the need to keep the Razor login form.
About 18 months later, I undertook a three-month pilot project to add automated testing to my production web application using Jest and Cypress. During that project I ran into similar authentication and authorization friction. Jest component and integration testing is a headless process, so there is no good way to interface with a browser-based HTML login form. I sidestepped that entire problem by creating Jest mocks for the API endpoints, which is the best practice for Jest testing. Still, it would have been helpful during the preliminary proof of concept to directly fetch data from the web server. The authentication friction was experienced again during the implementation of end-to-end Cypress testing. The best practice with Cypress is to establish a reusable authenticated user session for an entire suite of tests. A Cypress session can reliably work with a browser-based HTML login form, but a much better and more performant solution is to call an authentication API endpoint using HTTP POST. I still only had a browser-based HTML login form, so I used that option to successfully complete the Cypress pilot project.
Finally in August of 2023, I had a block of time available to dig into adding an authentication API endpoint to my web application. In combination with that, I wanted to begin transitioning away from cookies by adding support for web tokens. .NET 6 has good support for JSON Web Tokens, commonly referred to as JWT (pronounced “jot”), which is a modern web standard used to communicate user identity and user claims between a server process and a client process. For example, a JWT might contain the following claims: my user ID is 123456 and I am a system administrator. In 2022 I had upgraded my web application from .NET Core 3.1 to .NET 6, so I was cautiously optimistic that I could now implement JWT in 2–3 weeks.
I started researching adding JWT to an existing .NET web application and found two excellent articles from a trusted expert in the Microsoft development community, Rick Strahl. Rick is well known in the community for his writing, knowledge sharing, conference sessions, and innovative developer products.
Role based JWT Tokens in ASP.NET Core APIs
Combining Bearer Token and Cookie Authentication in ASP.NET
Those two articles were precisely the jump-start I had hoped for! Probably saved me days of time having to figure out all of that myself by trial-and-error. Following the code samples, I integrated the same logic into my .NET 6 web application. Next, I modified my Cypress custom command for logging in to call my new ASP.NET authentication API endpoint to get a JWT token. After a couple days of debugging, code refactoring, and code cleanup, I had JWT reliably working with my Cypress tests. I then spent an additional day adapting the ASP.NET side to use an X.509 certificate to sign the JWT instead of having to manage a JWT passphrase secret.
The next step was to modify my Vue front-end app. I rewrote my existing Razor login view in Vue, which then invoked the same ASP.NET authentication API I used for my Cypress project. It worked and my Vue app received a JWT but the authentication API didn’t return an Identity web cookie. It should have returned both. Since my Vue app was already designed to work with an Identity web cookie, I had planned to have the new API return a cookie, and following CI/CD best practices, I would implement JWT logic in my Vue app as a separate project. From a reader comment in Rick’s second article, I already had a heads up that some minor code tweaks might be needed to get the sample code working for Identity cookies. I set up the same scenario in Postman to quickly replicate the problem of not getting an Identity cookie from my API endpoint. A little more Googling, and I found that I just needed to change one line of Identity configuration code in my web server. Now my Postman test was receiving an Identity cookie and a JWT token. Tested my Vue app again and the new login form was now receiving an Identity cookie as expected. After that, a few additional changes in the Vue app were needed in order to fetch an XSRF token from the web server, and that was it. I subsequently made similar upgrades to my React app and my Angular app.
The final step was to modify my Vue app to use the JWT token it received on user login. I added a few lines of code to save the JWT, user name, and JWT expiration timestamp to browser Local Storage. My fetch API logic was already encapsulated in a custom Vue component so it was very simple to add a few lines of codes to add the JWT to all fetch requests. Then, some new logic to monitor the JWT expiration and automatically request a new token before the current one expired. A few more lines of code to delete the JWT Local Storage items when a user logged out. And finally, an optional custom HTTP header for the authentication API to tell the web server that the client does not need an Identity cookie, only a JWT. In the meantime, my existing React and Angular apps would continue to use an Identity cookie without any change. When time permitted, I would also modify those apps to use JWT.
Thanks for taking this journey with me. I hope you found this article helpful. Today’s full stack development environment is complex, with a lot of moving parts, and change is constant. Open-source frameworks, Internet standards, software engineering best practices, and a knowledgeable and helpful development community are an amazing foundation for building great software products. Best wishes as you make the world a better place with your technology contributions.