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:
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();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);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:
cd samples/18-authentication
dotnet user-jwts create --name alice --valid-for 1h --output tokenThe 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:
<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:
{
"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:
dotnet runOpen another terminal and make a request without a token:
curl -i http://localhost:5080/todosResponse excerpt; traceId represents a dynamic value for this request:
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:
$TOKEN = "paste-the-complete-token-here"TOKEN="paste-the-complete-token-here"Add the header, then request the current user and the list:
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/me{"name":"alice"}curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/todosThe new database todos-18.db starts with no Todos, so the response is []. Create one:
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:
{"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:
| Setting | Responsibility |
|---|---|
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-jwtsprovides 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.
ClaimsPrincipalprovides 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.
