Skip to content

Authentication (JWT) ​

In the previous chapter, anyone could change Todos. This chapter adds authentication: validate the caller's credentials, establish their identity, and require callers to authenticate before accessing Todo endpoints.

This example adds JWT Bearer authentication to the code from Chapter 17. The models and context are unchanged and are included with this chapter's project so it can run independently:

18-authentication/Program.cs
cs
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
    ?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

// For standalone learning samples only; EnsureCreatedAsync does not update existing tables.
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
    await db.Database.EnsureCreatedAsync();
    if (!await db.Categories.AnyAsync())
    {
        db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
        await db.SaveChangesAsync();
    }
}

app.MapGet("/me", (ClaimsPrincipal user) => new { Name = user.Identity?.Name }).RequireAuthorization();

var todos = app.MapGroup("/todos").WithTags("Todos").RequireAuthorization();

todos.MapGet("/", async (TodoDbContext db) =>
    await db.Todos.AsNoTracking().OrderBy(t => t.Id)
        .Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync());

todos.MapGet("/{id:int}", async Task<Results<Ok<TodoResponse>, NotFound>> (int id, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null ? TypedResults.NotFound()
        : TypedResults.Ok(new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
});

todos.MapPost("/", async Task<Results<Created<TodoResponse>, ProblemHttpResult>> (CreateTodo input, TodoDbContext db) =>
{
    if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
    {
        return TypedResults.Problem(statusCode: 400, title: "Category not found");
    }
    var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
    db.Todos.Add(todo);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/todos/{todo.Id}",
        new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId));
}).ProducesProblem(400);

todos.MapPut("/{id:int}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (int id, ReplaceTodo input, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return TypedResults.NotFound();
    if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId))
    {
        return TypedResults.Problem(statusCode: 400, title: "Category not found");
    }
    todo.Title = input.Title;
    todo.Done = input.Done;
    todo.CategoryId = input.CategoryId;
    await db.SaveChangesAsync();
    return TypedResults.NoContent();
}).ProducesProblem(400);

todos.MapDelete("/{id:int}", async Task<Results<NoContent, NotFound>> (int id, TodoDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return TypedResults.NotFound();
    db.Todos.Remove(todo);
    await db.SaveChangesAsync();
    return TypedResults.NoContent();
});

app.Run();
18-authentication/Models.cs
cs
using System.ComponentModel.DataAnnotations;

public class Todo
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public bool Done { get; set; }
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Todo> Todos { get; set; } = [];
}

public record CreateTodo(
    [Required, StringLength(100)] string Title,
    [Range(1, int.MaxValue)] int CategoryId);

public record ReplaceTodo(
    [Required, StringLength(100)] string Title,
    bool Done,
    [Range(1, int.MaxValue)] int CategoryId);

public record TodoResponse(int Id, string Title, bool Done, int CategoryId);
18-authentication/TodoDbContext.cs
cs
using Microsoft.EntityFrameworkCore;

public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    public DbSet<Category> Categories => Set<Category>();
}

Prepare a local test token ​

A JWT (JSON Web Token) is a token format. In this example, a client submits it in Authorization: Bearer <token>. “Bearer” means that whoever holds the credential can use it, so do not put a token in a URL or a log.

We will use the SDK's dotnet user-jwts tool to generate a development token and have the API validate it. This lets us learn how to protect endpoints before getting into sign-in and user registration.

Stop the previous chapter's service, then run these commands from the repository root:

bash
cd samples/18-authentication
dotnet user-jwts create --name alice --valid-for 1h --output token

The terminal prints a token with three dot-separated parts. Each generated token differs, so copy the complete value for the next step.

The project declares both the package for validating tokens and a UserSecretsId for the development tool:

18-authentication/Authentication.csproj
xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnetcore-first-steps-18-authentication</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
  </ItemGroup>
</Project>

user-jwts stores a test signing key in the local user's configuration directory and updates the development environment's Bearer settings. This example listens only on http://localhost:5080, matching this configuration:

18-authentication/appsettings.Development.json
json
{
  "Authentication": {
    "Schemes": {
      "Bearer": {
        "ValidAudiences": [
          "http://localhost:5080"
        ],
        "ValidIssuer": "dotnet-user-jwts"
      }
    }
  }
}

ValidIssuer is an accepted issuer, and ValidAudiences are accepted audiences, or API identifiers that the token is intended for. The configuration and test key are read through AddJwtBearer()'s configuration mechanism; the source code does not contain a fixed signing key. Local JWT tool documentation

Note

Use these tokens and the signing key only for local development. User Secrets are not encrypted, and they must not be committed to the repository. See Chapter 12. For deployment, use a trusted identity provider, configure token validation according to its documentation, and send tokens over HTTPS.

Run and verify ​

Start the API in the project directory you just entered:

bash
dotnet run

Open another terminal and make a request without a token:

bash
curl -i http://localhost:5080/todos

Response excerpt; traceId represents a dynamic value for this request:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"traceId":"request-trace-id"}

In the terminal where you will send requests, put the complete token you copied into a variable. Choose the command for your shell:

powershell
$TOKEN = "paste-the-complete-token-here"
bash
TOKEN="paste-the-complete-token-here"

Add the header, then request the current user and the list:

bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/me
json
{"name":"alice"}
bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/todos

The new database todos-18.db starts with no Todos, so the response is []. Create one:

bash
curl -i -X POST http://localhost:5080/todos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"title":"Write report","categoryId":1}'

The response is 201 with Location: /todos/1 and this JSON body:

json
{"id":1,"title":"Write report","done":false,"categoryId":1}

Replace the token in the request header with not-a-valid-token and request /todos again; the result is 401. When a token is invalid, the handler does not run and no data is written to the database.

Registering authentication does not automatically protect every endpoint ​

These settings each have a separate responsibility:

SettingResponsibility
AddAuthentication("Bearer").AddJwtBearer()Register the default Bearer authentication scheme and the JWT validation handler
UseAuthentication()Validate the request's token and establish a user identity on success
AddAuthorization() / UseAuthorization()Register and apply access rules; in this chapter, only authentication is required
RequireAuthorization()Add a requirement to an endpoint or group that it must be authenticated

Why configure these separately? Authentication answers “are these credentials valid, and who do they represent?” An endpoint's access rule answers “is an identity required here?” Registering JWT services alone and adding no protection requirement to an endpoint does not make every API private.

This example applies the requirement to the whole /todos group, protecting all CRUD endpoints, and adds the same requirement to /me individually. UseAuthentication() comes before UseAuthorization() so the identity is established before the rule is checked. Documentation endpoints remain available only in development.

How the server decides whether a token is valid ​

The JWT payload in this example is not encrypted. Being able to read the username in it does not prove the token is authentic. The server must validate the signature, issuer, audience, and expiration; it cannot simply Base64-decode the token and trust it.

A modified token, an incorrect issuer, or an incorrect audience will fail validation. Expiration checks allow some clock skew, so there may be a short grace period after a token expires. JWT Bearer validation guidance

ClaimsPrincipal is the user object established after validation. Its claims are key-value information describing the identity. The username generated by the local tool is mapped by default, so it can be read through user.Identity?.Name. Other identity providers may use different claim names and mapping rules; do not assume that every JWT has the same username field.

This chapter does not distinguish permissions yet ​

Anyone with a valid token can currently read and write the same Todo list. The next chapter will distinguish “readers” from “editors.” Isolating Todos by user also requires a separate check of data ownership.

FastAPI comparison

FastAPI's HTTPBearer or OAuth2PasswordBearer can extract a Bearer credential from a request, but token validation logic is still needed. Here, AddJwtBearer validates tokens, and RequireAuthorization requires endpoints to pass access checks.

Summary ​

  • JWT Bearer authentication validates a token and establishes an identity; do not trust a token just because its payload can be decoded.
  • dotnet user-jwts provides local test tokens only. Use a trusted identity provider in production and send tokens over HTTPS.
  • Registering authentication does not protect endpoints by itself. An endpoint or group must also declare RequireAuthorization().
  • A missing token or a token that fails validation gets a 401 when requesting a protected endpoint.
  • ClaimsPrincipal provides the validated identity. The next chapter's authorization rules determine whether it can change data.

Next: Authorization—a valid identity does not always have permission to write. Previous: Complete CRUD.

Built with .NET 10 and Minimal APIs · Runnable examples in every chapter