JWT Token Format with Cookie Authentication in ASP.NET Core

With the advent of Single Page Applications (SPAs) using client side technologies like Angular or React, we can totally have separate projects for our client side and server side logic. Our server side can be a Web API that is being accessed by our Angular application via HTTP get and post requests.

When securing calls between our Angular app and our Web API, we either use JWT Token Authentication or Cookie Authentication. Both have their own advantages and vulnerabilities. In any case, Cookie Authentication is more natural to use when calls are coming from a web application while JWT Token Authentication when we expose an API for use by third party applications.

So, it’s settled then – we use Cookie Authentication for our Angular app. However, we all have heard that using a JWT Token is far more secure than what the default token format that the Cookie Authentication has to offer. If you take a look at the structure of the JWT Token, you would see that it contains a signature that can be verified based on the security algorithm being used by your application. Thus, tampering a JWT Token is would be a bit of a challenge.

Let us then customize our Cookie Authentication to use JWT Token format. First, we need to configure our JWT Token in our appsettings.json file:

{
  "TokenAuthentication": {
    "SecretKey": "TestJWTTokenSecretKey",
    "Issuer": "TestIssuer",
    "Audience": "TestAudience",
    "Expiration": 20
  }
}

Then, we create our CustomJwtDataFormat (code originally taken from this blog post):

public class CustomJwtDataFormat : ISecureDataFormat<AuthenticationTicket>
{
    private readonly string algorithm;
    private readonly TokenValidationParameters validationParameters;

    public CustomJwtDataFormat(string algorithm, TokenValidationParameters validationParameters)
    {
        this.algorithm = algorithm;
        this.validationParameters = validationParameters;
    }

    public AuthenticationTicket Unprotect(string protectedText)
        => Unprotect(protectedText, null);

    public AuthenticationTicket Unprotect(string protectedText, string purpose)
    {
        var handler = new JwtSecurityTokenHandler();
        ClaimsPrincipal principal = null;
        SecurityToken validToken = null;

        try
        {
            principal = handler.ValidateToken(protectedText, this.validationParameters, out validToken);

            var validJwt = validToken as JwtSecurityToken;

            if (validJwt == null)
            {
                throw new ArgumentException("Invalid JWT");
            }

            if (!validJwt.Header.Alg.Equals(algorithm, StringComparison.Ordinal))
            {
                throw new ArgumentException($"Algorithm must be '{algorithm}'");
            }

        }
        catch (SecurityTokenValidationException)
        {
            return null;
        }
        catch (ArgumentException)
        {
            return null;
        }

        // Token validation passed
        return new AuthenticationTicket(principal, new Microsoft.AspNetCore.Authentication.AuthenticationProperties(), CookieAuthenticationDefaults.AuthenticationScheme);
    }

    public string Protect(AuthenticationTicket data) => Protect(data, null);

    public string Protect(AuthenticationTicket data, string purpose)
    {
        IConfiguration configuration = HttpContextHelper.Current.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;
        int expiration = Convert.ToInt32(configuration.GetSection("TokenAuthentication:Expiration").Value);

        var jwt = new JwtSecurityToken(
            issuer: validationParameters.ValidIssuer,
            audience: validationParameters.ValidAudience,
            claims: data.Principal.Claims,
            notBefore: DateTime.Now,
            expires: DateTime.Now.AddMinutes(expiration),
            signingCredentials: new SigningCredentials(validationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256));
        var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);

        return encodedJwt;
    }
}

The token format must implement the ISecureDataFormat<AuthenticationTicket> requiring Protect and Unprotect methods in order to create and validate the token, respectively.

To create the token (Protect method), we simply pass the necessary parameters including the issuer, audience, signing key, and expiration – all of which are in our configuration. Note that we use the HttpContextHelper that we have created in the previous blog.

To validate the token (Unprotect method), we utilize the JwtSecurityTokenHandler in order to validate the token for us. If it throws an exception or if it didn’t return a valid token, then, the token is invalid.

Now, in order for us to use this CustomJwtDataFormat, we have to configure that in our Startup.cs. In our ConfigureServices method, we add the following:

public void ConfigureServices(IServiceCollection services)
{
    // Other settings goes here
    
    // Configure our custom JWT format
    var tokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration.GetSection("TokenAuthentication:SecretKey").Value)),
        ValidateIssuer = true,
        ValidIssuer = Configuration.GetSection("TokenAuthentication:Issuer").Value,
        ValidateAudience = true,
        ValidAudience = Configuration.GetSection("TokenAuthentication:Audience").Value,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options =>
        {
            options.TicketDataFormat = new CustomJwtDataFormat(SecurityAlgorithms.HmacSha256, tokenValidationParameters);
            options.Events.OnRedirectToLogin = (context) =>
            {
                context.Response.StatusCode = 401;
                return Task.CompletedTask;
            };
            options.Events.OnValidatePrincipal = async (context) =>
            {
                // This refreshes the token everytime a validated request comes in.
                // This assumes sliding expiration token.
                var now = DateTime.UtcNow;
                var claims = context.Principal.Claims.Where(claim => claim.Type != JwtRegisteredClaimNames.Jti && claim.Type != JwtRegisteredClaimNames.Iat && claim.Type != JwtRegisteredClaimNames.Aud).ToList();
                claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));
                claims.Add(new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUniversalTime().ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64));

                var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
                var principal = new ClaimsPrincipal(identity);

                // We use the static extension methods because of name clash
                await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.SignOutAsync(context.HttpContext);
                await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.SignInAsync(context.HttpContext, CookieAuthenticationDefaults.AuthenticationScheme, principal);
            };
        });
}

There are a lot of things going on here but allow me to explain the important parts:

  1. The TokenValidationParameters is our repository for our secret key, audience, and issuer in our configuration.
  2. We set the TicketDataFormat of the CookieAuthenticationOptions to our CustomJwtDataFormat passing the TokenValidationParameters.
  3. We handle the OnRedirectToLogin event so that it returns 401 response code rather than redirecting to the login page since we know that our Angular app will be calling our API asynchronously and responses will be handled via TypeScript or JavaScript.
  4. We handle the OnValidatePrincipal event so that we refresh our token every time the client makes an authenticated call and the token has not yet expired. This assumes that you will be using sliding expiration authentication. However, if you prefer to use absolute expiration, this step is not necessary.

After applying these code to your application, you would now be able to see that during login, your authentication cookie being returned in the response headers will now have a JWT Token Format.