Securing a Web API with JWT Bearer
Give the categories API users. Register and login endpoints check passwords with PasswordHasher and hand back a signed JSON Web Token; AddJwtBearer checks the token on every request, and [Authorize] with roles decides who may add, change and delete. The signing key lives in user secrets.
Journeyman · ASP.NET Core · REST API · Updated
After this lesson you can
- Issue a JSON Web Token from a login endpoint after checking the password
- Check tokens with AddJwtBearer, and protect endpoints with [Authorize] and roles
Key takeaways
- Login checks the password with
PasswordHasherand hands back a signed token. A wrong user name and a wrong password get the same401. - The signing key lives in user secrets, never in code or Git: anyone with the key can make tokens your API trusts.
AddJwtBearerchecks theAuthorization: Bearertoken on every request, andUseAuthenticationmust come beforeUseAuthorization.[Authorize]needs a signed-in user, and[Authorize(Roles = "Admin")]an admin. No good token gets401; the wrong role gets403.- The role is written into the token at login, so after a role changes, the user must log in again.
Right now, anyone can delete a category from your API. In this lesson, the API gets users: anyone may read the categories, a signed-in user may add and change them, and only an admin may delete them. It's the same plan as Cookie Authentication and [Authorize], with a token in place of the cookie:
- Register and log in: the API checks the password and hands back a JWT.
- Every request after that sends the token in the
Authorization: Bearerheader. - The API checks the token, and
[Authorize]decides what the user may do.
1. A Users table
Add a table for the users to your Northwind database, in SSMS:
CREATE TABLE Users (
UserId int IDENTITY PRIMARY KEY,
UserName nvarchar(50) NOT NULL UNIQUE,
PasswordHash nvarchar(100) NOT NULL,
Role nvarchar(20) NOT NULL DEFAULT 'User'
);
The table never holds a password, only its hash from PasswordHasher, which is 84 characters long.
2. Models and a DAO
In Models, a user, what a visitor sends to sign in, and what the API sends back:
namespace NorthwindApi.Models;
public class AppUser
{
public int UserId { get; set; }
public string UserName { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string Role { get; set; } = "User";
}
public record Credentials(string UserName, string Password);
public record AuthResult(string Token, string UserName, string Role);
And a DAO in Data, with Dapper, like CategoryDao:
using Dapper;
using Microsoft.Data.SqlClient;
using NorthwindApi.Models;
namespace NorthwindApi.Data;
public interface IUserDao
{
Task<AppUser?> GetByUserNameAsync(string userName);
Task AddAsync(AppUser user);
}
public class UserDao : IUserDao
{
private readonly string connectionString;
public UserDao(string connectionString)
{
this.connectionString = connectionString;
}
public async Task<AppUser?> GetByUserNameAsync(string userName)
{
using SqlConnection connection = new SqlConnection(connectionString);
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"SELECT UserId, UserName, PasswordHash, Role FROM Users WHERE UserName = @UserName;",
new { UserName = userName });
}
public async Task AddAsync(AppUser user)
{
string sql = """
INSERT INTO Users (UserName, PasswordHash, Role)
OUTPUT INSERTED.UserId
VALUES (@UserName, @PasswordHash, @Role);
""";
using SqlConnection connection = new SqlConnection(connectionString);
user.UserId = await connection.ExecuteScalarAsync<int>(sql, user);
}
}
3. The settings, and a secret key
The token's issuer and audience go in appsettings.json:
"Jwt": {
"Issuer": "northwind-api",
"Audience": "northwind-app",
"Minutes": 60
}
The signing key doesn't. Anyone with the key can make tokens that your API trusts, for any user and any role, so it must never be in your code or in Git. While developing, keep it in user secrets: a settings file in your user profile, outside the project, that ASP.NET Core reads as if it were part of appsettings.json. (In Azure, the same setting comes from the app's configuration, or from Key Vault, Azure's locked store for secrets.)
Make a random key with a one-file program, dotnet run key.cs:
using System.Security.Cryptography;
Console.WriteLine(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
Then, in a terminal in the project's folder, store it (paste your key in place of <your key>):
dotnet user-secrets init
dotnet user-secrets set "Jwt:Key" "<your key>"
In Visual Studio, you can instead right-click the project and choose Manage User Secrets, which opens the same file, secrets.json:
{
"Jwt:Key": "<your key>"
}
Now builder.Configuration["Jwt:Key"] finds it, like any other setting.
4. Making tokens
Add the package that checks tokens; it brings the one that makes them too:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
A small service makes a token for a user, as in JSON Web Tokens:
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using NorthwindApi.Models;
namespace NorthwindApi.Security;
public class TokenService
{
private readonly IConfiguration configuration;
public TokenService(IConfiguration configuration)
{
this.configuration = configuration;
}
public string CreateToken(AppUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity([new Claim("sub", user.UserName), new Claim("role", user.Role)]),
Issuer = configuration["Jwt:Issuer"],
Audience = configuration["Jwt:Audience"],
Expires = DateTime.UtcNow.AddMinutes(configuration.GetValue<int>("Jwt:Minutes")),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
};
return new JsonWebTokenHandler().CreateToken(descriptor);
}
}
IConfiguration is the app's settings, appsettings.json and user secrets together. ASP.NET Core injects it into any class that asks.
5. Register and log in
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NorthwindApi.Data;
using NorthwindApi.Models;
using NorthwindApi.Security;
namespace NorthwindApi.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IUserDao userDao;
private readonly TokenService tokenService;
private readonly PasswordHasher<AppUser> hasher = new PasswordHasher<AppUser>();
public AuthController(IUserDao userDao, TokenService tokenService)
{
this.userDao = userDao;
this.tokenService = tokenService;
}
[HttpPost("register")]
public async Task<ActionResult<AuthResult>> Register(Credentials credentials)
{
if (string.IsNullOrWhiteSpace(credentials.UserName) || credentials.Password.Length < 8)
{
return BadRequest("Choose a user name, and a password of at least 8 characters.");
}
if (await userDao.GetByUserNameAsync(credentials.UserName) != null)
{
return Conflict( quot;The user name {credentials.UserName} is taken.");
}
var user = new AppUser { UserName = credentials.UserName, Role = "User" };
user.PasswordHash = hasher.HashPassword(user, credentials.Password);
await userDao.AddAsync(user);
return new AuthResult(tokenService.CreateToken(user), user.UserName, user.Role);
}
[HttpPost("login")]
public async Task<ActionResult<AuthResult>> Login(Credentials credentials)
{
AppUser? user = await userDao.GetByUserNameAsync(credentials.UserName);
if (user == null ||
hasher.VerifyHashedPassword(user, user.PasswordHash, credentials.Password) == PasswordVerificationResult.Failed)
{
return Unauthorized("That user name and password don't match.");
}
return new AuthResult(tokenService.CreateToken(user), user.UserName, user.Role);
}
[Authorize]
[HttpGet("me")]
public ActionResult<object> Me()
{
return new { userName = User.Identity!.Name, role = User.FindFirstValue("role") };
}
}
- Register checks the input, refuses a name that's taken, stores the hash, and signs the new user straight in by sending a token back.
- Login looks the user up and checks the password. A wrong user name and a wrong password get the same answer, so nobody can find out which user names exist.
GET /api/auth/mesays who the token belongs to.Useris the signed-in user, read from the token, as in MVC.
6. Checking tokens
In Program.cs, register the DAO and the service, and turn on JWT bearer authentication:
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using NorthwindApi.Data;
using NorthwindApi.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
string connectionString = builder.Configuration.GetConnectionString("Northwind")!;
builder.Services.AddScoped<ICategoryDao>(_ => new CategoryDao(connectionString));
builder.Services.AddScoped<IUserDao>(_ => new UserDao(connectionString));
builder.Services.AddSingleton<TokenService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
NameClaimType = "sub",
RoleClaimType = "role"
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
AddJwtBearerreads theAuthorization: Bearer ...header on every request, and checks the token: the signature with the key, the issuer, the audience and the expiry. If it passes,Useris the token's user. If there's no token, or it fails, the request carries on anonymous.MapInboundClaims = falsekeeps the claims' short names,subandrole, as they are in the token.NameClaimTypeandRoleClaimTypesay which claims are the user's name, forUser.Identity.Name, and roles, for[Authorize(Roles = ...)].UseAuthenticationmust come beforeUseAuthorization, as in MVC: first find out who it is, then check what they may do.
7. Protecting the endpoints
Authentication works out who is calling; [Authorize] decides what they may do. On the CategoriesController's actions:
[Authorize]
[HttpPost]
public async Task<ActionResult<Category>> Add(Category category)
[Authorize]
[HttpPut("{id}")]
public async Task<ActionResult<Category>> Update(int id, Category category)
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
The GET actions have no attribute, so anyone can read. (If most of a controller is protected, put [Authorize] on the class, and [AllowAnonymous] on the few actions that are open.)
When [Authorize] says no, the API answers with one of two status codes:
| Status | Means | When |
|---|---|---|
| 401 Unauthorized | "Who are you?" | No token, or a token that's expired, changed or signed with another key |
| 403 Forbidden | "I know who you are, and you can't do this" | A good token, but not the right role |
Try it
Run the API, and send these requests with the .http file (or Insomnia). @token holds the token from the login, so later requests can use it:
POST http://localhost:5179/api/auth/register
Content-Type: application/json
{ "userName": "ada", "password": "summer2026" }
###
# @name login
POST http://localhost:5179/api/auth/login
Content-Type: application/json
{ "userName": "ada", "password": "summer2026" }
###
@token = {{login.response.body.$.token}}
POST http://localhost:5179/api/categories
Authorization: Bearer {{token}}
Content-Type: application/json
{ "categoryName": "Snacks", "description": "Chips and nuts" }
| Request | Answer |
|---|---|
| Register ada | 200, with a token, "userName": "ada" and "role": "User" |
| Register ada again | 409 Conflict: the name is taken |
| Log in with the wrong password | 401 |
| Log in | 200, with a token |
POST /api/categories without the Authorization line |
401 |
POST /api/categories with the token |
201 Created |
GET /api/auth/me with the token |
200: {"userName":"ada","role":"User"} |
DELETE /api/categories/{the new id} with ada's token |
403: ada isn't an admin |
To make ada an admin, change her role in SSMS, UPDATE Users SET Role = 'Admin' WHERE UserName = 'ada';, then log in again: her old token still says "role":"User", because the role was written into it at login. With the new token, the DELETE answers 204.
Check yourself
2 questions to try on the lesson page.
So what
Login checks the password and hands out a signed token; AddJwtBearer checks the token on every request; [Authorize] and roles say who may do what. The key stays in user secrets, and the API trusts only tokens it signed itself.
Key terms
- JWT Bearer Authentication
- Bearer Token
- User Secrets