
Securing ASP.NET Web API with JWT Bearer Token Authentication
Author - Abdul Rahman (Bhai)
Web API
29 Articles
Table of Contents
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.