JWT stands for JSON Web Token, and it is an authorization mechanism, not authentication. So let’s figure out what the difference between those two.
Authentication is the mechanism that allows verifying that the user is exactly the one he claims to be. It is a login process where a user provides a username and password, and the system verifies them. So authentication answers the question: who is the user?
Authorization is the mechanism that allows verification of which access rights the user has to a certain resource. It is a process of granting users some roles and a set of permissions a particular role has. So, authorization answers that question: what rights does the user have in the system?
It is important to understand that Authentication always comes first and Authorization is second. In other words, you can’t get permission before you verify your identity. But what are the most popular authorization methods? There are two main approaches to handling authorization for the web application.
Sessions
A traditional approach on the web for authorization users is a cookie-based server-side session. The process starts when a user logs in and a server authenticates him. After that, the server creates a session with a Session ID and stores it somewhere in the server’s memory. The server sends back Session ID to the client and the client stores the Session ID in cookies. For every request, the client sends a Session ID as a part of the request, and the server verifies the Session ID in its memory and the user’s permissions related to this session.
Tokens
Another popular approach is using tokens for authorization. The process starts similarly when a user enters login, and passwords and a client sends a login request to a server. Instead of creating a session, the server generates a token signed with the secret token. Then, the server sends the token back to the client, and the client has to store it in a local storage. Similar to the session-based approach, the client has to send a token to the server for every request. However, the server does not store any additional information about the user session. The server has to validate that the token has not changed since it was created and signed with the secret key.
Session vs Token
Session-based authorization approach can be vulnerable to an attack known as Cross-Site Request Forgery (CSRF). It is a kind of attack when the attacker points to a site they are logged into to perform actions they didn’t intend to, like submitting a payment or changing a password.
Another thing is that when using a session-based authorization approach creates a stateful session between a client and server. The problem is if a client wants to access different servers in the scope of the same application, those servers have to share a session state. In another case, the client will need to be authorized on each server since the session is going to be different.
On the other hand, the token-based authorization approach does not require storing session data on the server side and may simplify authorization between multiple servers.
However, tokens can still be stolen by an attacker and it also can be difficult to invalidate tokens. We will see the details and how to handle invalidation further in this article.
JWT
JSON Web Token (JWT) is an open standard that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
JWT structure
JSON Web Tokens consist of three parts separated by dots .
- Header
{
"alg": "HS256",
"typ": "JWT"
}
The header usually consists of two parts: the type of token, and the signing algorithm being used.
- Payload
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
The payload contains the claims, which are statements about the user. The payload is then Base64Url encoded to form the second part of the JSON Web Token. You can find a description of standard fields that are used as claims here.
- Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
To create the signature part, you have to take the encoded header, the encoded payload, a secret, and the algorithm specified in the header and sign that.
The token typically looks like the following:
xxxxx.yyyyy.zzzzz
You can navigate to jwt.io and debug a sample token or your own. Just paste your token into the Encoded field and select the Algorithm of the token signature.
.NET project
Now that we have theoretical knowledge of how JWT works, we can apply it to the real-life project. Let’s assume we have a simple API that represents CRUD operations for the coffee entity. We are going to create an ASP.NET Core API project that represents Coffee API. After that, we will create another ASP.NET Core API project that would represent an Identity API that could generate JWT. In real life, you would probably use Identity Server or Okta, or Auth0 for Authentication/Authorization purposes. However, we would create our own Identity API to demonstrate how to generate JWT. When Identity API is done, we can call its controller and generate JWT based on the user’s data. Also, we can protect the Coffee API with an authorization configuration that requires passing JWT with each request.
Coffee API
First, we are going to create a simple ASP.NET Core API project that represents Coffee API. Here is the structure of this project:
Let’s start with the Coffee.cs
in the Model
folder. It is a simple entity with an Id
and a Name
properties.
namespace Hackernoon.Coffee.API.Model;
public class Coffee
{
public int Id { get; set; }
public string Name { get; set; }
}
We need to store our entities while working with the API. So, let’s introduce a simple in-memory storage. It is located in the Storage.cs
file in the Data
folder.
namespace Hackernoon.Coffee.API.Data;
public static class Storage
{
private static readonly List<Model.Coffee> Data = new();
public static List<Model.Coffee> GetAll()
{
return Data;
}
public static bool Create(Model.Coffee model)
{
if (Data.Any(c => c.Id == model.Id || c.Name == model.Name))
return false;
Data.Add(new Model.Coffee
{
Id = model.Id,
Name = model.Name
});
return true;
}
public static bool Delete(int id)
{
if (Data.All(c => c.Id != id))
return false;
Data.Remove(Storage.Data.First(c => c.Id == id));
return true;
}
public static bool Update(Model.Coffee model)
{
if (Data.All(c => c.Id != model.Id))
return false;
Data.First(c => c.Id == model.Id).Name = model.Name;
return true;
}
}
We need a class that would represent requests to the Coffee API. So, let’s create CoffeeRequest.cs
in the Contracts
folder.
namespace Hackernoon.Coffee.API.Contracts;
public class CoffeeRequest
{
public int Id { get; set; }
public string Name { get; set; }
}
When it is done, we can implement CoffeeController.cs
in the Controller
folder that represents CRUD operations for the coffee entity.
using Hackernoon.Coffee.API.Contracts;
using Hackernoon.Coffee.API.Data;
using Microsoft.AspNetCore.Mvc;
namespace Hackernoon.Coffee.API.Controllers;
[Route("coffee")]
[ApiController]
public class CoffeeController : ControllerBase
{
[HttpGet]
public IList<Model.Coffee> GetAll()
{
return Storage.GetAll();
}
[HttpPost]
public IActionResult Create([FromBody]CoffeeRequest request)
{
var model = new Model.Coffee
{
Id = request.Id,
Name = request.Name
};
if (!Storage.Create(model))
return new BadRequestResult();
return new OkResult();
}
[HttpDelete]
public IActionResult Delete(int id)
{
if (!Storage.Delete(id))
return new BadRequestResult();
return new OkResult();
}
[HttpPut]
public IActionResult Update([FromBody] CoffeeRequest request)
{
var model = new Model.Coffee()
{
Id = request.Id,
Name = request.Name
};
if (!Storage.Update(model))
return new BadRequestResult();
return new OkResult();
}
}
Coffee API is done, and we can run the project and see Swagger UI as follows:
Identity API
Let’s create another ASP.NET Core API project that represents Identity API. Here is the structure of this project:
Let’s start with the TokenGenerationRequest.cs
in Contracts
folder, which represents the request for the generation of a new JWT with Email
and Password
properties.
namespace Hackernoon.Identity.API.Contracts;
public class TokenGenerationRequest
{
public string Email { get; set; }
public string Password { get; set; }
}
We need to implement only TokenController.cs
that represents the logic of generation JWT. But before we do that Microsoft.AspNetCore.Authentication.JwtBearer
NuGet package needs to be installed.
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Hackernoon.Identity.API.Contracts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace Hackernoon.Identity.API.Controllers;
[Route("token")]
public class TokenController : ControllerBase
{
private const string SecretKey = "VerySecretAndLongKey-NeedMoreSymbolsHere-123";
private const string Issuer = "IdentityServerIssuer";
private const string Audience = "IdentityServerClient";
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(20);
[HttpPost]
public string Create([FromBody]TokenGenerationRequest request)
{
var claims = new List<Claim> {new Claim(ClaimTypes.Email, request.Email) };
var jwt = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.Add(Lifetime),
signingCredentials: CreateSigningCredentials());
return new JwtSecurityTokenHandler().WriteToken(jwt);
}
private static SigningCredentials CreateSigningCredentials()
{
return new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey)),
SecurityAlgorithms.HmacSha256);
}
}
Note that sensitive const such as SecretKey
, Issuer
, and Audience
have to be put somewhere in the configuration. They are hardcoded just for simplifying this test project. The Lifetime
field is set to 20 minutes, which means that the token will be valid for that time. You also might configure this parameter.
Now we can run the project and see Swagger UI as follows:
Let’s make a call to the /token
endpoint and generate a new JWT. Try the following payload:
{
"email": "[email protected]",
"password": "password"
}
Identity API will generate the corresponding JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJqb2huLmRvZUBnbWFpbC5jb20iLCJJc0dvdXJtZXQiOiJmYWxzZSIsImV4cCI6MTcwNzc4Mzk4MCwiaXNzIjoiSWRlbnRpdHlTZXJ2ZXJJc3N1ZXIiLCJhdWQiOiJJZGVudGl0eVNlcnZlckNsaWVudCJ9.4odXsbWak1C0uK3Ux-n7f58icYQQwlHjM54OjgMCVPM
Enabling Authorization in Coffee API
Now, when Identity API is ready and provides us with tokens, we can guard Coffee API with authorization. Again Microsoft.AspNetCore.Authentication.JwtBearer
NuGet package needs to be installed.
We need to register the required services by authentication services. Add the following code to the Program.cs
file right after creating a builder.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "IdentityServerIssuer",
ValidateAudience = true,
ValidAudience = "IdentityServerClient",
ValidateLifetime = true,
IssuerSigningKey =
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("VerySecretAndLongKey-NeedMoreSymbolsHere-123")),
ValidateIssuerSigningKey = true,
};
});
builder.Services.AddAuthorization();
It is important to remember that order in middleware is important. We enable authentication by calling AddAuthentication()
method and specifying JwtBearerDefaults.AuthenticationScheme
as an authentication schema. It is a constant that contains a Bearer
value.
namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
/// <summary>Default values used by bearer authentication.</summary>
public static class JwtBearerDefaults
{
/// <summary>
/// Default value for AuthenticationScheme property in the JwtBearerAuthenticationOptions
/// </summary>
public const string AuthenticationScheme = "Bearer";
}
}
We need to specify TokenValidationParameters
that describes which parameters of JWT will be validated during the authorization. We also specify IssuerSigningKey
similar to the signingCredentials
in Identity API to verify the JWT signature. Check more details about TokenValidationParameters
here.
The next piece of code adds middleware to the builder that enables authentication and authorization capabilities. It should be added between the UseHttpsRedirection()
and MapControllers()
methods.
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Now, we can use the Authorize
attribute over the controller or its actions. By applying this code, now all the actions in CoffeeController
are protected with an authorization mechanism, and JWT has to be sent as a part of the request.
[Route("coffee")]
[ApiController]
[Authorize]
public class CoffeeController : ControllerBase
{
..
If we make a call to any endpoint of the Coffee API, we can debug HttpContext.User
and see that it is populated and has an Identity
with claims we have specified in JWT. It is an important thing in understanding how ASP.NET Core handles Authorization under the hood.
Add Authorization to Swagger UI
We did great work to protect Coffee API with the authorization. But if you run the Coffee API project and open Swagger UI, you won’t be able to send JWT as a part of the request. To fix that, we need to update the Program.cs
file with the following code:
builder.Services.AddSwaggerGen(option =>
{
option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
BearerFormat = "JWT",
Scheme = "Bearer"
});
option.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type=ReferenceType.SecurityScheme,
Id="Bearer"
}
},
new string[]{}
}
});
});
After that, we will be able to see the Authorize button at the right top corner:
When you click on the Authorize button you will be able to enter JWT as follows:
Use Postman for testing
You can not limit yourself to using Swagger UI and can perform testing of the API through the Postman tool. Let’s call /token
endpoint of the Identity API first. We need to specify Content-Type
header with the value application/json
in the Headers section since we are going to use JSON as a payload.
After that, we can call /token
endpoint and get a new JWT.
Now, we can copy JWT and use it to call Coffee API. We need to specify Content-Type
header similar to the Identity API if we want to test, create, and update endpoints. Authorization
header also has to be set with the value Bearer [your JWT value]
. After that, just hit Send button and see the result.
Role-based authorization
As you remember, the payload part of JWT is a set of claims with values that are exactly key-value pairs. Role-based authorization allows you to differentiate access to application resources depending on the role to which the user belongs.
If we update the Create()
method in the TokenController.cs
file in Identity API with the code that adds a new claim for the role; we can handle role-based authentication in the Coffee API. ClaimTypes.Role
is a predefined name of the role claim.
var claims = new List<Claim>
{
new Claim(ClaimTypes.Email, request.Email),
new Claim(ClaimTypes.Role, "Barista")
};
Update the Authorize
attribute in the CoffeeController.cs
file specifying the role name:
[Authorize(Roles = "Barista")]
Now, all users who make a call to Coffee API have to have the role claim with the Barista
value. Otherwise, they will get 403 Forbidden
status code.
Claim-based authorization
An Authorize
attribute can easily handle role-based authentication. But what if it is not enough, and we want to differentiate access based on some user properties like age or any other? You have probably already guessed that you can add your claims to JWT and use them to build authorization logic. Role-based authorization itself is a special case of claims-based authorization, just as a role is the same claim object of a predefined type.
Let’s update the Create()
method in the TokenController.cs
file in Identity API with the code that adds a new claim IsGourmet
.
var claims = new List<Claim>
{
new Claim(ClaimTypes.Email, request.Email),
new Claim("IsGourmet", "true")
};
In the Program.cs file in Coffee API, we need to create a policy that verifies a claim and can be used in the Authorize
attribute. The following code has to be added right after the AddAuthentication()
method call.
builder.Services.AddAuthorization(opts => {
opts.AddPolicy("OnlyForGourmet", policy => {
policy.RequireClaim("IsGourmet", "true");
});
});
Update the Authorize
attribute in the CoffeeController.cs
file specifying the policy name:
[Authorize(Policy = "OnlyForGourmet")]
Summary
Congratulations! You made a great effort in learning JWT in .NET. Now, you have to have a solid understanding of JWT principles and why it is important to use it to perform authorization in .NET applications. But we just scratched the surface in the area of authentication and authorization in ASP.NET Core applications.
I suggest looking into Microsoft documentation regarding the topics we discussed in this article. There are also a lot of built-in capabilities for authorization and role management in the .NET platform. A good addition to this article could be Microsoft documentation about authorization.