Skip to content

ASP.NET Core Web API Tutorial with JWT Authentication (.NET 10, Step by Step)

Gulab Chand Tejwani

Gulab Chand Tejwani

PMP · PL-300 · Microsoft Certified Trainer

· 11 min read

ASP.NET CoreWeb API.NETC#JWTAuthentication
ASP.NET Core Web API Tutorial with JWT Authentication (.NET 10, Step by Step)

To build an ASP.NET Core Web API with JWT authentication, create a project with dotnet new webapi, add the Microsoft.AspNetCore.Authentication.JwtBearer package, register AddAuthentication().AddJwtBearer() and AddAuthorization() in Program.cs, and call .RequireAuthorization() on the endpoints you want to protect. Clients then send a token in the Authorization: Bearer <token> header. For local testing, the dotnet user-jwts tool creates valid tokens for you, so you can secure the API before you build a login system.

I'm Gulab Chand Tejwani, a Microsoft Certified Trainer. At TechNet Consultancy we teach ASP.NET Core and build APIs for client projects, and this is the shape most of them start from. The code below targets .NET 10, the current Long Term Support release.

What will you build?

A small Courses API with:

  • GET /courses (public) to list courses
  • GET /courses/{id} (public) to fetch one
  • POST /courses (requires a logged-in user with the admin role) to add a course
  • OpenAPI documentation generated by ASP.NET Core
  • JWT bearer authentication

Data lives in memory to keep the focus on the API. Swap in a database afterwards with our EF Core basics guide.

Step 1: Create the project

dotnet new webapi -o CoursesApi
cd CoursesApi
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

The webapi template uses minimal APIs by default. Add --use-controllers if you prefer controllers; the authentication setup is the same. Since .NET 9, the template uses the built-in Microsoft.AspNetCore.OpenApi package to generate an OpenAPI document instead of shipping Swashbuckle, which is why you'll see AddOpenApi() and MapOpenApi() in Program.cs.

Step 2: Define the model

Create Course.cs:

using System.ComponentModel.DataAnnotations;

namespace CoursesApi;

public record Course(int Id, string Title, int DurationHours);

public class CreateCourseRequest
{
    [Required, StringLength(100, MinimumLength = 3)]
    public string Title { get; set; } = "";

    [Range(1, 200)]
    public int DurationHours { get; set; }
}

Keep request types separate from stored types. It stops clients setting fields they shouldn't, such as Id.

Step 3: Write the endpoints

Replace the weather sample in Program.cs with this:

using CoursesApi;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddSingleton<List<Course>>(_ =>
[
    new(1, "Blazor Training", 40),
    new(2, "ASP.NET Core Web API", 40),
]);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

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

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

var courses = app.MapGroup("/courses");

courses.MapGet("/", (List<Course> db) => db);

courses.MapGet("/{id:int}", (int id, List<Course> db) =>
    db.FirstOrDefault(c => c.Id == id) is { } course
        ? Results.Ok(course)
        : Results.NotFound());

courses.MapPost("/", (CreateCourseRequest req, List<Course> db) =>
{
    var course = new Course(db.Count + 1, req.Title.Trim(), req.DurationHours);
    db.Add(course);
    return Results.Created($"/courses/{course.Id}", course);
})
.RequireAuthorization(policy => policy.RequireRole("admin"));

app.Run();

A few things worth noticing:

  • AddValidation() is new in .NET 10. It makes minimal APIs check data annotations like [Required] and [Range] on incoming requests and return a 400 problem response automatically. Before .NET 10 you needed a filter or a library for that. Microsoft documents it under minimal API validation.
  • MapGroup keeps related routes together, so you can apply settings to the whole group later.
  • Results.Created returns a 201 with a Location header, which is what REST clients expect.
  • RequireAuthorization with RequireRole("admin") protects only the POST. The GETs stay public.
  • The in-memory List<Course> is a singleton and isn't thread-safe. Fine for learning, not for production.

Run it with dotnet run. GET /courses works. POST /courses returns 401 Unauthorized, because you haven't sent a token yet. That's the result you want.

Step 4: How does JWT authentication work here?

A JSON Web Token is a signed string with three parts: header, payload (the "claims", such as user name and roles) and signature. The API doesn't store sessions. On each request, the JWT bearer handler:

  1. Reads the token from the Authorization: Bearer header
  2. Checks the signature with a key it trusts
  3. Checks the issuer, audience and expiry time
  4. Turns the claims into a ClaimsPrincipal that authorization policies can check
Five steps of a JWT-protected request: get token, send it, validate, build the user, authorize
The JWT bearer handler validates; authorization policies decide. Two separate steps, two separate failure codes.

If any check fails, the request is treated as anonymous, and protected endpoints return 401. A valid token without the admin role gets 403 Forbidden instead.

Step 5: Create a test token with dotnet user-jwts

You don't need a login endpoint to test. The .NET SDK includes a tool that issues development tokens and configures your app to trust them:

dotnet user-jwts create --name gulab --role admin

It prints a token and adds the matching JWT settings to appsettings.Development.json, with the signing key kept in user secrets rather than in your project. Now call the protected endpoint:

curl -X POST https://localhost:7001/courses \
  -H "Authorization: Bearer <paste-token-here>" \
  -H "Content-Type: application/json" \
  -d "{\"title\":\"Entity Framework Core\",\"durationHours\":24}"

Use the port your app printed at start-up. You should get 201 Created. Try a token without the role (dotnet user-jwts create --name student) and you'll get 403. Try "title": "" and the validation from Step 3 returns 400.

HTTP status codes the Courses API returns: 200, 201, 204, 400, 401, 403, 404, 500
Return the status code that tells the client what to do next. 401 and 403 are not interchangeable.

Here's what each test call should return, so you can tick them off:

RequestTokenExpected
GET /coursesNone200 OK with the list
GET /courses/99None404 Not Found
POST /coursesNone401 Unauthorized
POST /coursesValid, role student403 Forbidden
POST /courses, empty titleValid, role admin400 with validation errors
POST /courses, valid bodyValid, role admin201 Created + Location header
Expected results for the Courses API

Microsoft's guide to managing JWTs in development with dotnet user-jwts has the full option list.

Step 6: What changes in production?

Development tokens are for your machine only. In a real system, tokens come from an identity provider, and your API only validates them. Common choices:

  • Microsoft Entra ID (formerly Azure AD) for company staff and B2B apps
  • ASP.NET Core Identity with its built-in API endpoints, when you want to store users yourself
  • Third-party providers such as Auth0 or Keycloak

The configuration looks like this, in appsettings.json:

{
  "Authentication": {
    "Schemes": {
      "Bearer": {
        "Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
        "ValidAudiences": [ "api://courses-api" ]
      }
    }
  }
}

AddJwtBearer() reads the Authentication:Schemes:Bearer section by default, so Program.cs doesn't change. With an Authority, the handler downloads the provider's signing keys itself.

Differences between JWT setup in development and in production
In production your API stops issuing anything. It only validates tokens from a trusted identity provider.

A short production checklist:

  • Always use HTTPS. A bearer token is like a password while it's valid.
  • Keep tokens short-lived (minutes to an hour) and use refresh tokens in the client.
  • Never put secrets or personal data in token claims; anyone holding the token can decode the payload.
  • Use policies (AddAuthorizationBuilder().AddPolicy(...)) rather than scattering role names around.
  • Add rate limiting to login and other sensitive endpoints with the built-in rate limiting middleware.
  • Log authentication failures, but never log the tokens.

Here's what rate limiting looks like with the built-in middleware, applied to the POST endpoint:

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddFixedWindowLimiter("writes", limiter =>
    {
        limiter.PermitLimit = 10;          // 10 requests...
        limiter.Window = TimeSpan.FromMinutes(1); // ...per minute
    });
});

// after UseAuthentication/UseAuthorization:
app.UseRateLimiter();

courses.MapPost("/", /* handler */)
    .RequireAuthorization(policy => policy.RequireRole("admin"))
    .RequireRateLimiting("writes");

The numbers are examples. Choose limits from real traffic, and partition by user or IP address for public endpoints so one noisy client can't block everyone.

How do you test the API automatically?

Manual curl calls are fine for learning. For anything you'll keep, write integration tests. ASP.NET Core's WebApplicationFactory starts your real app in memory, so tests exercise routing, validation and auth together.

Add a test project:

dotnet new xunit -o CoursesApi.Tests
dotnet add CoursesApi.Tests reference CoursesApi
dotnet add CoursesApi.Tests package Microsoft.AspNetCore.Mvc.Testing

Add public partial class Program { } at the bottom of Program.cs so the test project can see it, then:

using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;

public class CoursesApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task List_is_public()
    {
        var client = factory.CreateClient();
        var response = await client.GetAsync("/courses");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task Create_without_token_is_401()
    {
        var client = factory.CreateClient();
        var response = await client.PostAsJsonAsync("/courses",
            new { title = "Test course", durationHours = 10 });
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
}

Run dotnet test. Two tests is a start; aim for one test per row of the expected-results table above. For the authenticated cases, Microsoft Learn's integration testing guide shows how to plug in a test authentication handler so you don't need real tokens.

How do you call this API from Blazor?

In a Blazor app, register a typed HttpClient and attach the user's token with a DelegatingHandler, or use the Microsoft Identity Web libraries if you sign in with Entra ID. Start with our Blazor tutorial for beginners, then point its service at this API instead of the in-memory list.

Minimal APIs or controllers: which should you learn?

Learn both. Minimal APIs are concise and fast to start with; Microsoft's newer templates and docs lean toward them. Controllers group actions into classes and are still common in existing enterprise code. Everything in this tutorial (DI, validation, auth, OpenAPI) works with both. If you want guided practice, our Web API Development course goes deeper on REST design, JWT and OAuth, rate limiting, OpenAPI and testing. Its examples use Node.js and TypeScript, and the principles apply directly to ASP.NET Core. For C# end to end, the .NET Blazor training covers calling and securing APIs from a Blazor front end.

Minimal APIsControllers
StyleLambdas or methods mapped in Program.cs or extension methodsClasses deriving from ControllerBase with action methods
BoilerplateVery littleMore, but very familiar to most teams
ValidationAddValidation() in .NET 10Built in via [ApiController]
Where you'll see itNew services, templates, Microsoft docsExisting enterprise code bases
Auth, DI, OpenAPIYesYes
Minimal APIs vs controllers in ASP.NET Core

If your business needs an API built and maintained rather than learned, TechNet's team builds ASP.NET Core back ends for clients. See software and web development. And for the bigger learning picture, read the .NET developer roadmap 2026.

FAQ

What is the difference between authentication and authorization in ASP.NET Core?

Authentication works out who the caller is, for example by validating a JWT. Authorization decides what that caller may do, for example "only admins can create courses." In ASP.NET Core, UseAuthentication runs first, then UseAuthorization.

Why does my API return 401 even with a token?

Common causes: the token has expired, the audience or issuer doesn't match the API's configuration, the signing key differs, or the header is missing the Bearer prefix. Paste the token into a JWT decoder to check its claims and expiry, and check your app's logs.

Should I store JWTs in localStorage in the browser?

For browser apps, Microsoft and OWASP guidance leans toward cookies with HttpOnly and SameSite settings, or a backend-for-frontend pattern, because script-readable storage is exposed to cross-site scripting. Native and server-to-server clients commonly hold bearer tokens directly.

Is Swagger removed in .NET 10?

The templates no longer include Swashbuckle by default, since .NET 9. ASP.NET Core generates the OpenAPI document itself. You can still add Swagger UI, Scalar or another viewer on top of that document if you want an interactive page.

Is this tutorial enough to get an ASP.NET Core job?

It's a solid start, not the finish. Add EF Core with a real database, tests, logging and a deployment to Azure, and put the project on GitHub with a clear README. That combination is what interviewers want to see.

Choose optional cookies

We use necessary cookies for account security. With your permission, we also use analytics and advertising cookies on marketing pages. See our Privacy Policy.