👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Securing ASP.NET Web API with JWT Bearer Token Authentication

Securing ASP.NET Web API with JWT Bearer Token Authentication

Author - Abdul Rahman (Bhai)

Web API

29 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Most APIs need some form of security. Sending the username and password on every request is a non-starter — it's slow because credentials must be validated on each call, and it is a massive security risk if the connection isn't encrypted. Token-based authentication solves this elegantly.

In this article, we'll implement JWT Bearer token authentication in an ASP.NET Core Web API step by step — creating a login endpoint that issues tokens, validating those tokens with middleware, locking down controllers with [Authorize], and using the dotnet user-jwts tool to generate development tokens without changing any code.

Why we gonna do?

Here's the fundamental problem with credentials-per-request:

  • Performance. Verifying a username/password means a database or LDAP lookup on every single request. At scale, that is expensive.
  • Attack surface. Every request carries the plaintext password. One intercepted packet (if HTTPS is misconfigured) exposes the credentials permanently.
  • Modern client diversity. Single-page apps, mobile apps, and server-to-server calls all need a single, interoperable mechanism. Bearer tokens work for all of them.

With JWT (JSON Web Token) Bearer authentication the flow is simple:


┌────────────────────────────────────────────────────────────────────────┐
│  1. Client POSTs { username, password } to /api/auth/login             │
│  2. API validates credentials and returns a signed JWT token           │
│  3. Client stores the token (memory, localStorage, etc.)               │
│  4. Client sends  Authorization: Bearer <token>  on every request│
│  5. API validates the token signature and claims on every request      │
│  6. If valid → handler executes. If not → 401 Unauthorized             │
└────────────────────────────────────────────────────────────────────────┘
            

Tokens are signed, not encrypted. The payload is Base64-encoded JSON that anyone can decode — but the signature ensures it cannot be tampered with without the signing key. That is why HTTPS is mandatory: tokens rely on transport-layer encryption for confidentiality.

How we gonna do?

Step 1: Create the Login Endpoint

Add the System.IdentityModel.Tokens.Jwt and Microsoft.AspNetCore.Authentication.JwtBearer NuGet packages:


dotnet add package System.IdentityModel.Tokens.Jwt
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
            

Store the signing secret and metadata in appsettings.Development.json (never commit production secrets to source control):


{
  "Authentication": {
    "SecretForKey": "supersecretkey-replace-in-production-min32chars!",
    "Issuer":   "https://localhost:7001",
    "Audience": "ilovedotnetapi"
  }
}
            

Create a login endpoint. In a real application you would look up credentials in a database; here we hard-code a user for illustration:


using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

app.MapPost("/api/auth/login", (LoginRequest request, IConfiguration config) =>
{
    // In production: validate credentials against your user store
    if (request.UserName != "author" || request.Password != "Pa$$w0rd")
        return Results.Unauthorized();

    // Build the claims that will live inside the token
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub,        "user-001"),        // unique user ID
        new(JwtRegisteredClaimNames.GivenName,  "Abdul"),
        new(JwtRegisteredClaimNames.FamilyName, "Rahman"),
        new("channel", "WebAPI"),                                    // custom claim
    };

    // Create signing credentials from the configured secret
    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(config["Authentication:SecretForKey"]!));

    var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer:             config["Authentication:Issuer"],
        audience:           config["Authentication:Audience"],
        claims:             claims,
        notBefore:          DateTime.UtcNow,
        expires:            DateTime.UtcNow.AddHours(1),
        signingCredentials: credentials);

    var tokenString = new JwtSecurityTokenHandler().WriteToken(token);

    return Results.Ok(new { token = tokenString });
});

record LoginRequest(string UserName, string Password);
            

A JWT consists of three Base64url-encoded parts separated by dots: a header (algorithm and token type), a payload (claims), and a signature. Paste the token at jwt.io to inspect the decoded payload — you'll see all the claims we added, the issuer, audience, and expiry.

Step 2: Configure JWT Bearer Middleware

Tell ASP.NET Core to validate incoming Bearer tokens before reaching any handler. Register this in Program.cs before app.Build():


using System.Text;

builder.Services
    .AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidIssuer              = builder.Configuration["Authentication:Issuer"],

            ValidateAudience         = true,
            ValidAudience            = builder.Configuration["Authentication:Audience"],

            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(
                    builder.Configuration["Authentication:SecretForKey"]!)),
        };
        // Token expiry is validated automatically
    });
            

Then add the middleware to the request pipeline — order matters:


var app = builder.Build();

app.UseAuthentication();   // must come before UseAuthorization
app.UseAuthorization();

// ... map endpoints
app.Run();
            

Step 3: Require Authentication on Endpoints

Apply RequireAuthorization() on individual endpoints, or call app.MapGroup() and add it to the entire group:


// Without a valid token → 401 Unauthorized
app.MapGet("/api/articles", (ILogger<Program> logger) =>
{
    logger.LogInformation("Fetching all articles");
    return Results.Ok(ArticleStore.All);
})
.RequireAuthorization();

// The login endpoint must remain publicly accessible
app.MapPost("/api/auth/login", ...)
   .AllowAnonymous();
            

Now sending a request without a token returns:


HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
            

After authenticating and passing the token as a Bearer header:


GET /api/articles HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

HTTP/1.1 200 OK
[ { "id": 1, "title": "..." }, ... ]
            

Step 4: Generating Development Tokens with dotnet user-jwts

In real-world projects, tokens are issued by a central identity provider. During development you often need tokens with different claims to test permissions. dotnet user-jwts is a built-in CLI tool that acts as a local, per-project token factory:


# Generate a token with matching issuer and audience
dotnet user-jwts create \
    --issuer "https://localhost:7001" \
    --audience "ilovedotnetapi"

# Add custom claims for testing different user scenarios
dotnet user-jwts create \
    --issuer "https://localhost:7001" \
    --audience "ilovedotnetapi" \
    --claim channel=Blazor \
    --claim given_name=TestUser

# Retrieve the signing key that user-jwts uses (sync it with appsettings)
dotnet user-jwts key

# List all generated tokens for this project
dotnet user-jwts list

# Print a specific token by ID
dotnet user-jwts print <token-id>

# Remove a token / clear all tokens
dotnet user-jwts remove <token-id>
dotnet user-jwts clear
            

Copy the key from dotnet user-jwts key into appsettings.Development.json as Authentication:SecretForKey. From that point, tokens generated with user-jwts are accepted by your API — no code changes required.

This is much safer than it sounds: user-jwts stores keys in the user's secret store (not the repo). In production, the identity provider uses a different private key that user-jwts can never replicate, so your production API remains secure.

Full Program.cs Skeleton


using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();

builder.Services
    .AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidIssuer              = builder.Configuration["Authentication:Issuer"],
            ValidateAudience         = true,
            ValidAudience            = builder.Configuration["Authentication:Audience"],
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(
                    builder.Configuration["Authentication:SecretForKey"]!))
        };
    });

var app = builder.Build();

if (!app.Environment.IsDevelopment())
    app.UseExceptionHandler();

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

// Public login endpoint
app.MapPost("/api/auth/login", (LoginRequest req, IConfiguration cfg) =>
{
    if (req.UserName != "author" || req.Password != "Pa$$w0rd")
        return Results.Unauthorized();

    var claims = new[]
    {
        new Claim(JwtRegisteredClaimNames.Sub,        "user-001"),
        new Claim(JwtRegisteredClaimNames.GivenName,  "Abdul"),
        new Claim(JwtRegisteredClaimNames.FamilyName, "Rahman"),
        new Claim("channel", "WebAPI"),
    };

    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(cfg["Authentication:SecretForKey"]!));

    var token = new JwtSecurityToken(
        issuer:             cfg["Authentication:Issuer"],
        audience:           cfg["Authentication:Audience"],
        claims:             claims,
        notBefore:          DateTime.UtcNow,
        expires:            DateTime.UtcNow.AddHours(1),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return Results.Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) });
})
.AllowAnonymous();

// Protected article endpoint
app.MapGet("/api/articles", () => Results.Ok(ArticleStore.All))
   .RequireAuthorization();

app.Run();

record LoginRequest(string UserName, string Password);
record Article(int Id, string Title, string Author, string Channel);

static class ArticleStore
{
    public static readonly List<Article> All =
    [
        new(1, "Logging in ASP.NET Web API",          "Abdul Rahman", "WebAPI"),
        new(2, "JWT Authentication in ASP.NET Web API", "Abdul Rahman", "WebAPI"),
    ];
}
            

Summary

  • JWT Bearer tokens replace per-request credentials — authenticate once to get a token, then include it as Authorization: Bearer <token> on every subsequent request.
  • A JWT has three parts: header (algorithm), payload (claims), and signature — it is Base64-encoded, not encrypted, so HTTPS is mandatory.
  • Use AddAuthentication("Bearer").AddJwtBearer() to register the validation middleware, and call UseAuthentication() before UseAuthorization() in the pipeline.
  • Protect endpoints with .RequireAuthorization(); keep the login endpoint public with .AllowAnonymous().
  • Use dotnet user-jwts during development to generate tokens with arbitrary claims — no code changes required, and it does not compromise production security.
  • Never store signing secrets in the repository — use appsettings.Development.json locally, and a Key Vault or environment variable in production.
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Web API
  • JWT
  • Authentication
  • Bearer Token
  • JwtBearer
  • dotnet user-jwts
  • ASP.NET Core