Introduction
Token-based authentication is a security mechanism where users authenticate once and receive an access token, which is then used to access protected resources without needing to re-enter credentials. Access tokens have a limited lifespan, and when they expire, a refresh token can be used to obtain a new access token without requiring the user to log in again.
Tokens should be securely stored on the client—typically in HTTP-only cookies for better security or local storage (though this carries risks). If a token is compromised or a user logs out, the system can revoke the token, preventing further unauthorized access and enhancing security and session control.
In the previous session, we configured a basic .NET Core web API to use .NET Core Identity and generated the default Identity tables in PostgreSQL. In this session, we will build on that setup to illustrate how to implement token-based authentication.
This guide will contain the following sections:
- Initial configuration
- User registration
- Tokens management
- User login
- Refresh tokens lifecycle
Initial configuration
Step 1 - Add JwtBearer nuget package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Step 2 - Add configuration
- If you are running the service locally, then add the following section to your
appsettings.json
{
//other setttings
"Authentication": {
"TokenSecret": "thisisthesecretforgeneratingakey(mustbeatleast32bitlong)",
"RefreshTokenSecret": "thisisthesecretforgeneratingakey(mustbeatleast32bitlong)",
"Issuer": "http://localhost:8080",
"Audience": "my-web-api-client"
}
}
- If you are running the service with Docker Compose, add the following configuration to the environment sections of you API:
#api configuration
environment:
- Authentication__TokenSecret=thisisthesecretforgeneratingakey(mustbeatleast32bitlong)
- Authentication__RefreshTokenSecret=thisisthesecretforgeneratingakey(mustbeatleast32bitlong)
- Authentication__Issuer=http://localhost:8080
- Authentication__Audience=my-web-api-client
#other environment variables
Step 3 - Register the JwtConfiguration in the code
- Settings record - the values for this record are specified above. The TokenSecret and RefreshTokenSecret are secret keys used to generate and validate access tokens and refresh tokens, respectively, ensuring security. The Issuer specifies the server that generates the tokens, while the Audience defines the intended recipient, ensuring only authorized clients can use the tokens
namespace DemoBackend.Configuration
{
public record AuthenticationSettings
{
public required string TokenSecret { get; init; }
public required string RefreshTokenSecret { get; init; }
public required string Issuer { get; init; }
public required string Audience { get; init; }
}
}
- Extension method for adding authorization
using DemoBackend.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace DemoBackend.Extensions
{
public static class AuthenticationExtensions
{
public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var authSettings = configuration.GetSection("Authentication").Get<AuthenticationSettings>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ClockSkew = TimeSpan.Zero,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = authSettings.Issuer,
ValidAudience = authSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(authSettings.TokenSecret))
};
});
}
}
}
- Adding the configuration to
Program.cs
var builder = WebApplication.CreateBuilder(args);
//register the authentication settings
builder.Services.Configure<AuthenticationSettings>(builder.Configuration.GetSection("Authentication"));
//register the authentication settings
builder.Services.AddJwtAuthentication(builder.Configuration);
//other registrations
var app = builder.Build();
//other registrations
app.UseAuthentication();
app.UseAuthorization();
//other registrations
Step 4 - UserManagementController
Now that we have everything set up, we can create a new controller to handle the user account workflow. UserManager
is a class that can be found in .NET Core Identity that offers most of the methods you will need to operate on an UserEntity
.
using DemoBackend.Configuration;
using DemoBackend.Constants;
using DemoBackend.Data;
using DemoBackend.Helpers;
using DemoBackend.Requests;
using DemoBackend.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using LoginRequest = DemoBackend.Requests.LoginRequest;
using RegisterRequest = DemoBackend.Requests.RegisterRequest;
namespace DemoBackend.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class UserManagementController : ControllerBase
{
private readonly UserManager<UserEntity> _userManager;
private readonly AuthenticationSettings _authSettings;
public UserManagementController(UserManager<UserEntity> userManager, IOptions<AuthenticationSettings> authSettings)
{
_userManager = userManager;
_authSettings = authSettings.Value;
}
//implementation
}
}
User registration
Step 1 - registration request
namespace DemoBackend.Requests
{
public record RegisterRequest
{
public required string Email { get; set; }
public required string Password { get; set; }
public required string ConfirmPassword { get; set; }
public required string Role { get; set; }
public required int Age { get; set; }
}
}
Step 2 - registration method
Register functionality implemented inside the UserManagementController
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest registerRequest)
{
try
{
if (!registerRequest.Password.Equals(registerRequest.ConfirmPassword))
{
return BadRequest("Passwords do not match.");
}
if (!registerRequest.Role.Equals(ApplicationRoles.Admin) && !registerRequest.Role.Equals(ApplicationRoles.User))
{
return BadRequest("You must provide either ADMIN or USER roles");
}
var user = new UserEntity
{
UserName = registerRequest.Email,
Email = registerRequest.Email,
Age = registerRequest.Age
};
//create user
var result = await _userManager.CreateAsync(user, registerRequest.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return BadRequest(ModelState);
}
//assign proper rolo to the user
if (!string.IsNullOrEmpty(registerRequest.Role))
{
await _userManager.AddToRoleAsync(user, registerRequest.Role);
}
return Ok("User registered successfully.");
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex.Message}");
}
}
Possible roles
for a user:
namespace DemoBackend.Constants
{
public static class ApplicationRoles
{
public const string Admin = "ADMIN";
public const string User = "USER";
}
}
Step 3 - testing registration
You can register a new user using the following curl
:
curl --location 'http://localhost:8080/api/usermanagement/register' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "testuser@example.com",
"password": "Test@1234",
"confirmPassword": "Test@1234",
"role": "USER",
"age": 25
}'
If successful, the new user will be added into AspNetUsers
table:
Tokens management
Before diving into other methods, we first need to focus on token generation. As mentioned in the introduction, we need two tokens: a short-lived access token and a long-lived refresh token.
The access token is used to grant users access to restricted resources. Since it expires fairly quickly, we need a refresh token—this token will be sent to a specialized refresh method that issues a new valid access token.
The refresh token has a longer lifespan and is stored in the database. Below is a helper class containing the three essential methods for this workflow: GenerateAccessToken
, GenerateRefreshToken
and ValidateRefreshToken
.
using DemoBackend.Configuration;
using DemoBackend.Constants;
using DemoBackend.Data;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace DemoBackend.Helpers
{
public static class TokenProviderHelper
{
public static (string, DateTime) GenerateAccessToken(AuthenticationSettings authenticationSettings, UserEntity user, string role)
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(authenticationSettings.TokenSecret));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claimsForToken = new List<Claim>
{
new(ApplicationClaims.Id, user.Id),
new(ApplicationClaims.Email,!string.IsNullOrEmpty(user.Email) ? user.Email : string.Empty),
new(ApplicationClaims.Age, user.Age.ToString()),
new(ApplicationClaims.Role, role.ToUpper()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var expirationDate = DateTime.UtcNow.AddMinutes(15);
var jwtSecurityToken = new JwtSecurityToken(
authenticationSettings.Issuer,
authenticationSettings.Audience,
claimsForToken,
DateTime.UtcNow,
expirationDate,
signingCredentials);
var tokenToReturn = new JwtSecurityTokenHandler()
.WriteToken(jwtSecurityToken);
return (tokenToReturn, expirationDate);
}
public static string GenerateRefreshToken(AuthenticationSettings authenticationSettings)
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(authenticationSettings.RefreshTokenSecret));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expirationDate = DateTime.UtcNow.AddHours(12);
var jwtSecurityToken = new JwtSecurityToken(
authenticationSettings.Issuer,
authenticationSettings.Audience,
null,
DateTime.UtcNow,
expirationDate,
signingCredentials);
var tokenToReturn = new JwtSecurityTokenHandler()
.WriteToken(jwtSecurityToken);
return tokenToReturn;
}
public static bool ValidateRefreshToken(AuthenticationSettings authenticationSettings, string refreshToken)
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(authenticationSettings.RefreshTokenSecret));
var validationParameters = new TokenValidationParameters()
{
IssuerSigningKey = securityKey,
ValidIssuer = authenticationSettings.Issuer,
ValidAudience = authenticationSettings.Audience,
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ClockSkew = TimeSpan.Zero
};
var tokenHandler = new JwtSecurityTokenHandler();
var result = tokenHandler.ValidateToken(refreshToken, validationParameters, out _);
return result != null;
}
}
}
Notice that when we generate a new access token, we add a list of claims
to the token. These claims
are pieces of data about the user embedded in the access token. They can be used by the issuer of the login request (e.g., a web application may use the email and ID for subsequent requests).
In this demo app, the available claims are as follows:
namespace DemoBackend.Constants
{
public static class ApplicationClaims
{
public const string Id = "userId";
public const string Email = "userEmail";
public const string Age = "userAge";
public const string Role = "userRole";
}
}
User login
Step 1 - login request
namespace DemoBackend.Requests
{
public record LoginRequest
{
public required string Email { get; set; }
public required string Password { get; set; }
}
}
Step 2 - login method
If the login process is successful, this method will return the access token
and the refresh token
for that user.
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest loginRequest)
{
try
{
var user = await _userManager.FindByEmailAsync(loginRequest.Email);
if (user == null)
{
return Unauthorized("Invalid email or password.");
}
var result = await _userManager.CheckPasswordAsync(user, loginRequest.Password);
if (!result)
{
return Unauthorized("Invalid email or password.");
}
//fetch the user role so it can be addded on claims list inside the toke
var userRoles = await _userManager.GetRolesAsync(user);
var role = userRoles.FirstOrDefault() ?? string.Empty;
//generate access token and refresh token
var (token, expiration) = TokenProviderHelper.GenerateAccessToken(_authSettings, user, role.ToUpperInvariant());
var refreshToken = TokenProviderHelper.GenerateRefreshToken(_authSettings);
//save refresh token in the database
//we will cover this in the refresh token section bellow
var success = await _tokensRepository.UpsertUserRefreshTokenAsync(user.Id, refreshToken);
if (!success)
{
return Unauthorized("Invalid refresh token.");
}
return Ok(new AuthenticationResponse
{
AccessToken = token,
AccessTokenExpirationTime = expiration,
RefreshToken = refreshToken
});
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex.Message}");
}
}
Step 3 - testing login
You can login with an existing user using the following curl
:
curl --location 'http://localhost:8080/api/usermanagement/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "testuser@example.com",
"password": "Test@1234"
}'
If the login is successful, you will get a response that looks like the following:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjZDEyNWVlZi1jMDUwLTQ3MDktYTZjZi0yNmYxNDRkMmMzNDUiLCJ1c2VyRW1haWwiOiJ0ZXN0dXNlckBleGFtcGxlLmNvbSIsInVzZXJBZ2UiOiIyNSIsInVzZXJSb2xlIjoiVVNFUiIsImp0aSI6ImFhZGUxN2MxLTU3MzYtNDE5ZS1iZjJmLTdlNGNmMjhkMDM2ZCIsIm5iZiI6MTczODE3MzM1NCwiZXhwIjoxNzM4MTc2OTU0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJhdWQiOiJteS13ZWItYXBpLWNsaWVudCJ9.GAdBDFYvXD59reeTFg5KTfcmF0gB3WnQOvwbCjkuxg0",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MzgxNzMzNTQsImV4cCI6MTczODIxNjU1NCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiYXVkIjoibXktd2ViLWFwaS1jbGllbnQifQ.Iu176n8uCwzKOVmMdg4_8pMukbV0GqmkHGrVBXoVVm4",
"accessTokenExpirationTime": "2025-01-29T18:55:54.3401878Z"
}
Step 4 - decoding an access token
Now that we have a valid access token we can go to https://jwt.io/ and decode it:
The access token consists of 3 parts: a header
, payload
(which contains the claims) and a signature
(signed using our super secret key provided in the configuration).
Refresh tokens lifecycle
Great, we now have an access token. If you take a look at the TokenProviderHelper
class above, you’ll notice that the validity of this token is only 15 minutes. This means you can only use this token for 15 minutes to access restricted resources. The short validity helps minimize the damage in case the token is stolen, as it’s stored on the client side.
This is where refresh tokens come in. Refresh tokens are stored in the database after a successful login. These tokens are then passed to a separate method to issue a new access token. If we want to log out and delete the refresh token, we need to implement another method to clean up the token. These 2 methods can only be accessed by a logged in users (they are marked with the [Authorize]
attribute and the access token needs to passed in the Authorization
header when making the request).
Step 1 - Implement a TokenRepository
for these operations
using DemoBackend.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace DemoBackend.Data
{
public interface ITokensRepository
{
Task<IdentityUserToken<string>?> GetUserRefreshTokenAsync(string refreshToken);
Task<bool> UpsertUserRefreshTokenAsync(string userId, string refreshToken);
Task<bool> RemoveUserRefreshTokenAsync(string userId);
}
public class TokensRepository(ApplicationDbContext applicationDbContext) : ITokensRepository
{
public async Task<IdentityUserToken<string>?> GetUserRefreshTokenAsync(string refreshToken)
{
return await applicationDbContext.UserTokens.SingleOrDefaultAsync(x => x.Value == refreshToken);
}
public async Task<bool> UpsertUserRefreshTokenAsync(string userId, string refreshToken)
{
var existingUserToken = await applicationDbContext.UserTokens
.FindAsync(userId, UserTokenConstants.LocalProvider, UserTokenConstants.Refresh);
if (existingUserToken == null)
{
applicationDbContext.UserTokens.Add(new IdentityUserToken<string>
{
UserId = userId,
LoginProvider = UserTokenConstants.LocalProvider,
Name = UserTokenConstants.Refresh,
Value = refreshToken
});
}
else
{
existingUserToken.Value = refreshToken;
applicationDbContext.UserTokens.Update(existingUserToken);
}
return (await applicationDbContext.SaveChangesAsync()) == 1;
}
public async Task<bool> RemoveUserRefreshTokenAsync(string userId)
{
var userToken = await applicationDbContext.UserTokens
.FindAsync(userId, UserTokenConstants.LocalProvider, UserTokenConstants.Refresh);
if (userToken == null)
{
throw new ArgumentNullException(nameof(userId));
}
applicationDbContext.UserTokens.Remove(userToken);
return (await applicationDbContext.SaveChangesAsync()) == 1;
}
}
}
We also need some custom constants to work with the table provided by Identity (AspNetUserTokens
):
namespace DemoBackend.Constants
{
public static class UserTokenConstants
{
public const string Refresh = "refresh";
public const string LocalProvider = "local";
}
}
Don't forget to register this Repository inside the DI container inProgram.cs
file:
var builder = WebApplication.CreateBuilder(args);
//other registrations
//register TokensRepository
builder.Services.TryAddScoped<ITokensRepository, TokensRepository>();
//other registrations
Also, add the dependency to the UserManagementController
:
[Route("api/[controller]")]
[ApiController]
public class UserManagementController : ControllerBase
{
private readonly UserManager<UserEntity> _userManager;
private readonly AuthenticationSettings _authSettings;
private readonly ITokensRepository _tokensRepository;
public UserManagementController(UserManager<UserEntity> userManager, IOptions<AuthenticationSettings> authSettings,
ITokensRepository tokensRepository)
{
_userManager = userManager;
_authSettings = authSettings.Value;
_tokensRepository = tokensRepository;
}
//implementation
}
The login
method mentioned above already integrates refresh token generation and saves it into the database. If you take a look at the AspNetUserTokens
table, you will see the token generated during login:
Step 2 - Refresh token method
- Method implementation:
[Authorize]
[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest refreshTokenRequest)
{
try
{
if (!TokenProviderHelper.ValidateRefreshToken(_authSettings, refreshTokenRequest.RefreshToken))
{
return Unauthorized("Invalid refresh token.");
}
var savedToken = await _tokensRepository.GetUserRefreshTokenAsync(refreshTokenRequest.RefreshToken);
if (savedToken == null)
{
return Unauthorized("Invalid refresh token.");
}
var user = await _userManager.FindByIdAsync(savedToken.UserId);
if (user == null)
{
return Unauthorized("Invalid refresh token.");
}
//since we are logged in we can get the role from the token
var role = HttpContext.User.FindFirstValue(ApplicationClaims.Role);
//generate access token and refresh token
var (token, expiration) = TokenProviderHelper.GenerateAccessToken(_authSettings, user, role);
return Ok(new RefreshTokenResponse
{
AccessToken = token,
AccessTokenExpirationTime = expiration,
});
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex.Message}");
}
}
- Method testing - you can use the following
curl
:
curl --location 'http://localhost:8080/api/usermanagement/refresh-token' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjZDEyNWVlZi1jMDUwLTQ3MDktYTZjZi0yNmYxNDRkMmMzNDUiLCJ1c2VyRW1haWwiOiJ0ZXN0dXNlckBleGFtcGxlLmNvbSIsInVzZXJBZ2UiOiIyNSIsInVzZXJSb2xlIjoiVVNFUiIsImp0aSI6IjgwOGIzNGNlLTg2NmYtNDM2YS04N2YyLWRjYmVkNDllYWRlOCIsIm5iZiI6MTczODE3NDkyNCwiZXhwIjoxNzM4MTc4NTI0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJhdWQiOiJteS13ZWItYXBpLWNsaWVudCJ9.fPhuRF5p3zWvVbXwqIn3RPK54WfuQn-8HIap3fS4wnE' \
--data '{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MzgxNzQ5MjQsImV4cCI6MTczODIxODEyNCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiYXVkIjoibXktd2ViLWFwaS1jbGllbnQifQ.uyfyRwLO9_Rfy_6U5s8Ymk8fXSnh4jEnk_9oD8qlYlg"
}'
If the refresh has been successful, we get a new access token:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjZDEyNWVlZi1jMDUwLTQ3MDktYTZjZi0yNmYxNDRkMmMzNDUiLCJ1c2VyRW1haWwiOiJ0ZXN0dXNlckBleGFtcGxlLmNvbSIsInVzZXJBZ2UiOiIyNSIsInVzZXJSb2xlIjoiVVNFUiIsImp0aSI6ImUwMmY2N2YzLTQxMzAtNGMxZC04NDkyLTEzOWUyNGUzZmQzMiIsIm5iZiI6MTczODE3NTAwMCwiZXhwIjoxNzM4MTc4NjAwLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJhdWQiOiJteS13ZWItYXBpLWNsaWVudCJ9.N28PvF8dXSzfusGvUloTo6xcayv_zHh0jpG9cTEGCww",
"accessTokenExpirationTime": "2025-01-29T19:23:20.2245074Z"
}
Step 3 - Revoke token method (logout)
- Method implementation:
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
try
{
//since we are logged in we can get the id from the token
var userId = HttpContext.User.FindFirstValue(ApplicationClaims.Id);
if (string.IsNullOrEmpty(userId))
{
return Unauthorized("Invalid user.");
}
var success = await _tokensRepository.RemoveUserRefreshTokenAsync(userId);
return success ? Ok("User logged out successfully.") : Unauthorized("Invalid user.");
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex.Message}");
}
}
- Method testing - you can use the following
curl
:
curl --location --request POST 'http://localhost:8080/api/usermanagement/logout' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjZDEyNWVlZi1jMDUwLTQ3MDktYTZjZi0yNmYxNDRkMmMzNDUiLCJ1c2VyRW1haWwiOiJ0ZXN0dXNlckBleGFtcGxlLmNvbSIsInVzZXJBZ2UiOiIyNSIsInVzZXJSb2xlIjoiVVNFUiIsImp0aSI6IjgwOGIzNGNlLTg2NmYtNDM2YS04N2YyLWRjYmVkNDllYWRlOCIsIm5iZiI6MTczODE3NDkyNCwiZXhwIjoxNzM4MTc4NTI0LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJhdWQiOiJteS13ZWItYXBpLWNsaWVudCJ9.fPhuRF5p3zWvVbXwqIn3RPK54WfuQn-8HIap3fS4wnE'
If the logout
has been successful, we get a confirmation and the user's refresh token will be removed from the database.
Conclusions
In this section, we implemented token-based authentication using .NET Core Identity, covering essential functionalities like login
, registration
, and logout
. We explored the two types of tokens used in this authentication system: access tokens
, which are used to access protected resources, and refresh tokens
, which allow users to obtain new access tokens without needing to log in again. In the next session, we will build upon this by adding authorization to this demo project.
Top comments (0)