# 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 November 3, 2026

## 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 `PasswordHasher` and hands back a signed token. A wrong user name and a wrong password get the same `401`.
- The signing key lives in user secrets, never in code or Git: anyone with the key can make tokens your API trusts.
- `AddJwtBearer` checks the `Authorization: Bearer` token on every request, and `UseAuthentication` must come before `UseAuthorization`.
- `[Authorize]` needs a signed-in user, and `[Authorize(Roles = "Admin")]` an admin. No good token gets `401`; the wrong role gets `403`.
- 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:

1. **Register** and **log in**: the API checks the password and hands back a JWT.
2. Every request after that sends the token in the **`Authorization: Bearer`** header.
3. 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:

```sql
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:

```csharp
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`:

```csharp
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`:

```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`:

```csharp
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>`):

```bash
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`:

```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:

```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

A small service makes a token for a user, as in *JSON Web Tokens*:

```csharp
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

```csharp
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($"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/me`** says who the token belongs to. `User` is 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**:

```csharp
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();
```

- **`AddJwtBearer`** reads the `Authorization: Bearer ...` header on every request, and checks the token: the signature with the key, the issuer, the audience and the expiry. If it passes, `User` is the token's user. If there's no token, or it fails, the request carries on **anonymous**.
- **`MapInboundClaims = false`** keeps the claims' short names, `sub` and `role`, as they are in the token.
- **`NameClaimType`** and **`RoleClaimType`** say which claims are the user's name, for `User.Identity.Name`, and roles, for `[Authorize(Roles = ...)]`.
- **`UseAuthentication`** must come **before** `UseAuthorization`, 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:

```csharp
[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:

```text
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](https://www.ask-codey.com/glossary/bearer-token)
- User Secrets

## Cheat sheets

- [Securing a Web API with JWT Bearer](https://www.ask-codey.com/cheat-sheets/securing-the-web-api-securing-a-web-api-with-jwt-bearer-cheat-sheet)

Source: https://www.ask-codey.com/lessons/securing-the-web-api-securing-a-web-api-with-jwt-bearer
