In this article, we'll walk through how to secure an ASP.NET Core Web API using JSON Web Tokens (JWTs).
JWTs solve a classic problem in API development: how to let an API verify the authenticity of incoming requests without maintaining server-side session state. Each token carries its own proof of authenticity in the form of a digital signature, allowing the API to validate requests independently on every call.
For a closely related walkthrough, see Duende’s tutorial Securing a .NET API With JWT Authentication: Step-by-Step Tutorial, which demonstrates the same core concepts using a Minimal API rather than a controller-based architecture.
In this tutorial, we’ll build a simple but complete controller-based Web API for .NET 10 and later that uses JWT bearer tokens to authenticate requests. The goal isn’t to create a full identity system, but to understand how the core pieces fit together in a working example.
Let's dive in! The complete API code is provided at the end of this article.
An Important Note on This Tutorial’s Use of Self-Issuing JWTs
To keep the scope of this tutorial manageable and self-contained, the API presented here issues its own JWT access tokens, typically referred to as self-issuing. This approach is useful for demonstration purposes because it allows the full token lifecycle — issuance, signing, validation, and authorization — to be examined in a single modest-sized project.
However, self-issuing JWTs is highly discouraged in real-world applications, regardless of how signing keys are stored or managed. Whether keys are loaded from PEM files, generated ephemerally at startup, or protected using other mechanisms, the underlying issue remains the same: APIs should validate existing tokens, not act as an identity provider (IdP) that generates them.
In production systems, token issuance and key management are handled by a dedicated IdP that implements OAuth and OpenID Connect (OIDC). This separation of responsibilities enables proper key rotation, centralized authentication, and consistent security controls across multiple APIs.
One such dedicated IdP is Duende IdentityServer, which manages signing keys, token lifetimes, and the entire authentication workflow.
IdPs are recommended for many reasons, some of which are:
- Centralized authentication: Users authenticate in one place rather than across multiple applications.
- Proper key management: IdPs handle private key protection, rotation, and signing algorithms.
- Standards compliance: IdPs support OAuth, OIDC, scope-based access, and federation.
- Cleaner API responsibilities: APIs validate tokens — they do not authenticate users or issue tokens.
- Security hardening: IdPs offer hardened login flows, MFA, session handling, and consent UX.
Step 1: Create a Controller-Based ASP.NET Core Web API Project
ASP.NET Core supports multiple ways to define API endpoints. In this tutorial, we’ll use a controller-based architecture that organizes endpoints into classes with attribute-based routing. This approach is common in larger or longer-lived APIs and remains widely used in production systems.
Create a new Web API project using the default controller template. This is where you will implement the token-based authentication explored in this article. One way to do this is via the .NET CLI:
dotnet new webapi -n JwtDemo --use-controllers
cd JwtDemo
This template generates a familiar structure with a Controllers folder and a Program.cs file for configuring the application pipeline, and support for attribute routing and filters.
Step 2: Add the JWT Authentication Packages
To handle JWTs in this new application, go ahead and install Microsoft's official JWT Bearer authentication handler from your CLI or Visual Studio, as follows:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
By relying on Microsoft's built-in JWT Bearer authentication handler, this tutorial follows the same — though simplified — path that real-world applications use. You get proven code that evolves with .NET's security updates, eliminating the need to maintain a custom solution that could fall behind current standards and become susceptible to a security vulnerability.
Step 3: Configure Authentication Services
Before an API can implement JWT authentication, it needs to know how to validate incoming tokens — which keys to trust, which issuers and audiences are legitimate, and which rules to apply when checking expiration or signature integrity. This configuration step defines that contract. You register these settings directly in Program.cs using the built-in authentication services.
The following code wires up the JWT Bearer handler, defines validation parameters, and prepares the pipeline to accept any request with a valid token and reject all others. Think of this as teaching the API how to tell a genuine token from a forged or expired one.
To generate self-issued JWTs in this tutorial, we store our keys as an ephemeral RSA key pair at startup. The private key is used only by the demo token endpoint to sign JWTs, while the public key is used by the API to validate them. Because ephemeral keys are stored in memory, restarting the application invalidates all issued tokens.
Add the following to Program.cs (at the top of the file, include the namespaces you need: Microsoft.AspNetCore.Authentication.JwtBearer, Microsoft.IdentityModel.Tokens, and System.Security.Cryptography):
var builder = WebApplication.CreateBuilder(args);
// IMPORTANT: This sample API issues its own JWTs and stores
// its signing keys ephemerally in memory, so they are invalidated
// when the application restarts.
// In production, APIs should not issue tokens or hold private signing keys.
// Instead, a dedicated Identity Provider — such as Duende IdentityServer —
// is responsible for token issuance, signing key protection,
// and key rotation. The API’s role is typically limited to validating
// existing tokens, not issuing them.
// PUBLIC KEY: used by the API to validate incoming JWTs.
// PRIVATE KEY: used only for issuing JWTs in this tutorial.
using var rsa = RSA.Create(2048);
var publicKey = new RsaSecurityKey(rsa.ExportParameters(false));
var privateKey = new RsaSecurityKey(rsa.ExportParameters(true));
// Register keys for DI
builder.Services.AddSingleton(publicKey);
builder.Services.AddSingleton(privateKey);
// Register authentication and authorization services.
// This tells the framework to expect and validate JWT bearer tokens.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://yourissuer.example", // ValidIssuer would be based on your IdP
ValidAudience = "https://youraudience.example", // ValidAudience would be based on your IdP
IssuerSigningKey = publicKey // IssuerSigningKey would not be specified if using an IdP
};
});
builder.Services.AddAuthorization();
Each setting in the TokenValidationParameters configuration object contributes to how rigorously the API examines incoming tokens. You can think of this object as the set of rules for what constitutes a trusted JWT. The four Validate… property assignments in the TokenValidationParameters instantiation are normally all set to true in most real-world API scenarios because the API's job is to validate IdP-issued tokens thoroughly.
- ValidateIssuer / ValidateAudience: These ensure that the token being validated originated from a known authority and was specifically issued for this API. If a token contains the wrong issuer or audience, the authentication handler rejects it. This prevents tokens intended for one service from being reused elsewhere.
- ValidateLifetime: Tokens carry an expiration (
exp) claim that defines at what point they should no longer be accepted. Enabling lifetime validation means any request arriving after that expiration timestamp is automatically denied. This guards against replay attacks with old or stolen tokens. - ValidateIssuerSigningKey: This property ensures the token was signed with the expected key. When this is enabled, the API checks the token's signature using the configured signing key and rejects tokens that don't match.
- ValidIssuer: This specifies the expected issuer (the authority that created the token). In a production setup, this value must match the issuer defined by your IdP.
- ValidAudience: This property identifies the intended recipient of the token — typically your API. In production, this value is also defined by your IdP.
- IssuerSigningKey: This attribute specifies the key used to verify the token's signature. When using an IdP, this is not configured manually. Instead, the API automatically retrieves the provider's signing keys.
Step 4: Add Authentication and Authorization Middleware
Once the authentication services are registered, they still need to be placed into the request-processing pipeline. In ASP.NET Core, this sequence of middleware components determines how a request is handled from start to finish.
Building on the source code in Step 3, this is accomplished by adding the following calls in our Program.cs file to UseAuthentication and UseAuthorization. The order here is crucial — authorization depends on authentication, so follow the order of these two calls used below.
// ...earlier code...
var app = builder.Build();
app.UseAuthentication(); // Validates tokens and populates ClaimsPrincipal
app.UseAuthorization(); // Enforces policies like [Authorize]
When an HTTP request reaches UseAuthentication, the authentication handler examines the Authorization header and then runs the registered JWT Bearer handler. If validation succeeds, it then creates a ClaimsPrincipal — an object that represents the authenticated user and contains the claims extracted from the token.
From that point on, any endpoint decorated with [Authorize] relies entirely on that principal. This design keeps the API stateless — no session data is stored on the server. Instead, each request includes a JWT that conveys the caller’s identity and claims, which the authentication handler validates independently.
Next, UseAuthorization checks whether the target endpoint has access requirements, such as those specified in an [Authorize] attribute. If the request lacks a valid identity or fails the policy check dictated by that [Authorize] attribute, then the request terminates with a 401 or 403 response.
Step 5: Create a Demo Token-Issuing Endpoint
The JWT bearer authentication handler validates tokens; it does not issue them. To implement the self-issuing of JWTs discussed earlier, go ahead and add a small demo token controller called TokenController.cs with a /token endpoint as shown below.
In a production scenario, there would be no hardcoding of user/password. These credentials would be validated against a secure user store with stronger policies applied, such as hashing, rate limiting, and HTTPS enforcement.
// ENDPOINT: Demo-only token issuance endpoint to show end-to-end flow
[ApiController]
[Route("token")]
public class TokenController : ControllerBase
{
private readonly RsaSecurityKey _privateKey;
public TokenController(RsaSecurityKey privateKey)
{
_privateKey = privateKey;
}
[HttpPost]
public IActionResult CreateToken(LoginRequest request)
{
// Authentication would be handled by your IdP outside the scope of this demo
if (request.Username != "testUser" || request.Password != "P@ssw0rd!")
return Unauthorized();
var signingCreds = new SigningCredentials(_privateKey,
SecurityAlgorithms.RsaSha256);
var claims = new[] // Populate standard claims
{
new Claim(JwtRegisteredClaimNames.Sub, request.Username),
new Claim(JwtRegisteredClaimNames.Email, "testUser@example.com")
};
const int tokenLifetimeMinutes = 10; // Hardcoded for demo purposes
var jwt = new JwtSecurityToken(
issuer: "https://yourissuer.example",
audience: "https://youraudience.example",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(tokenLifetimeMinutes),
signingCredentials: signingCreds);
return Ok(new
{
access_token = new JwtSecurityTokenHandler().WriteToken(jwt),
expires_in = tokenLifetimeMinutes * 60
});
}
}
As a finishing touch for the TokenController.cs contents, go ahead and also define a simple request model in the controller file:
public record LoginRequest(string Username, string Password); // simple request model
Some highlights of the JWT-issuing endpoint include:
- The
/tokenendpoint uses the RS256 (RSA SHA-256) signing algorithm according to theSigningCredentials. This method combines the strength of RSA's asymmetric key pair with the reliability of SHA-256 hashing, making it one of the most widely adopted algorithms for JWTs. It allows the API to validate tokens using only the public key while keeping the private key secure on the issuing side. - For readers wondering why manually specifying
"alg": "RS256"in the JWT header is unnecessary, note that .NET does this automatically. - Symmetric algorithms such as HS256 (HMAC with SHA-256) are simpler but require both parties to share the same secret key, which can become a security risk if the API and the token issuer are separate systems — one of the reasons IdPs favor asymmetric signing.
Step 6: Add a Simple Secured Endpoint
Now that this API's JWT infrastructure is in place, the next logical step is to confirm that the authentication handler is active and intercepting requests as expected. To do this, create a new controller called SecureController with a /secure endpoint as follows:
// ENDPOINT: Simple protected endpoint for verifying JWT authentication
[ApiController]
[Route("secure")]
public class SecureController : ControllerBase
{
[Authorize]
[HttpGet]
public IActionResult Get()
{
return Ok("You now have access to a protected resource.");
}
}
This basic route requires a valid JWT in the Authorization header. Requests without a token, or with an invalid or expired one, should immediately return a 401 Unauthorized response. On success, you should see "You now have access to a protected resource" in the 200 response.
Testing the API
Congratulations! At this point, you have a complete, albeit limited, API with a functional JWT implementation ready for testing. Let’s confirm that the authentication handler is active and intercepting requests as expected.
The following examples use curl to test the API and assume it's running on port 5001 using HTTPS. First, try calling the /secure endpoint without providing a JWT.
curl -i https://localhost:5001/secure
The response should look like the following:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Length: 0
Next, attempt to generate a JWT using the /token endpoint.
curl -X POST https://localhost:5001/token \
-H "Content-Type: application/json" \
-d "{\"username\":\"testUser\",\"password\":\"P@ssw0rd!\"}"
If the API functions as intended, you should see a response such as the following, which includes the JWT content as well as the expiration information:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi...",
"expires_in": 600
}
For the final test in this tutorial, call the /secure endpoint again, this time using the JWT you just obtained, using the Authorization header format shown in the call.
curl -i https://localhost:5001/secure \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIi..."
The API should return 200 OK and include a success message confirming that your JWT was validated successfully. If you're curious, you can also modify the JWT or wait until it expires, then retry the call to verify you receive a 401 response.
HTTP/1.1 200 OK
You now have access to a protected resource.
At this point, we know that the authentication handler is active, rejects unauthenticated requests, and successfully grants access when a valid JWT is provided.
Security Considerations and Beyond
While this tutorial focuses on the basic mechanics of JWT authentication, there are additional considerations for using JWTs securely and effectively. Some of these include:
- HTTPS: Always use HTTPS to prevent token interception.
- Access token lifetimes: Set reasonable JWT access token lifetimes. A 5 to 15 minute window is generally acceptable; this tutorial uses 10 minutes.
- Asymmetric signing: Asymmetric signing keys are preferred over symmetric signing keys (as mentioned earlier) to avoid shared-secret risk.
- Secure storage: Private keys and configuration secrets should be stored securely.
- Refresh tokens: When a JWT access token expires, most systems issue a longer-lived refresh token that can request a new JWT without reauthentication.
- Exception handling and logging: In production, there is typically a formal strategy for handling application exceptions. This tutorial looks at unsuccessful vs. successful HTTP responses, for example, 401 vs. 200, but this just scratches the surface.
Some of these considerations apply directly to our tutorial’s self-issuing approach, while others apply more broadly to working with IdPs. They are included here as general security guidance for JWT-based systems.
Final Thoughts
In this tutorial, you built a working foundation for securing a Web API using JSON Web Token authentication. Step by step, you did the following:
- Configured
TokenValidationParametersfor issuer, audience, and signature checks - Added authentication and authorization handlers in the correct order
- Created an endpoint that issues JWTs using asymmetric signing
- Protected an endpoint with
[Authorize]and verified access using curl
These are the same fundamentals used in production APIs, except that token issuance and management normally move to a dedicated IdP designed to issue, validate, and revoke tokens across multiple clients. As a reminder, a dedicated IdP is the recommended approach for production systems.
That's where Duende IdentityServer fits in. It is an IdP that extends this model into a full OAuth and OpenID Connect platform, handling token-based authentication flows, key management, and revocation securely and consistently.
For client-facing architectures, Duende's Backend for Frontend (BFF) framework goes further by centralizing token handling on the server side and reducing the risk of tokens being exposed to the browser in single-page applications (SPAs) or native apps.
The Complete API Source Code
Here is the entire source code for Program.cs, TokenController.cs, and SecureController.cs from the API example.
Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Security.Cryptography;
var builder = WebApplication.CreateBuilder(args);
// IMPORTANT: This sample API issues its own JWTs and stores
// its signing keys ephemerally in memory so they are invalidated
// when the application restarts.
// In production, APIs should not issue tokens or hold private signing keys.
// Instead, a dedicated Identity Provider — such as Duende IdentityServer —
// is responsible for token issuance, signing key protection,
// and key rotation. The API’s role is typically limited to validating
// existing tokens, not issuing them.
// PUBLIC KEY: used by the API to validate incoming JWTs.
// PRIVATE KEY: used only for issuing JWTs in this tutorial.
using var rsa = RSA.Create(2048);
var publicKey = new RsaSecurityKey(rsa.ExportParameters(false));
var privateKey = new RsaSecurityKey(rsa.ExportParameters(true));
// Register keys for DI
builder.Services.AddSingleton(publicKey);
builder.Services.AddSingleton(privateKey);
// Register authentication and authorization services.
// This tells the framework to expect and validate JWT bearer tokens.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://yourissuer.example", // ValidIssuer would be based on your IdP
ValidAudience = "https://youraudience.example", // ValidAudience would be based on your IdP
IssuerSigningKey = publicKey // IssuerSigningKey would not be specified if using an IdP
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication(); // Validates tokens and populates ClaimsPrincipal
app.UseAuthorization(); // Enforces policies like [Authorize]
app.MapControllers();
app.Run();
Controllers/TokenController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace JwtDemo.Controllers;
// ENDPOINT: Demo-only token issuance endpoint to show end-to-end flow
[ApiController]
[Route("token")]
public class TokenController : ControllerBase
{
private readonly RsaSecurityKey _privateKey;
public TokenController(RsaSecurityKey privateKey)
{
_privateKey = privateKey;
}
[HttpPost]
public IActionResult CreateToken(LoginRequest request)
{
// Authentication would be handled by your IdP outside the scope of this demo
if (request.Username != "testUser" || request.Password != "P@ssw0rd!")
return Unauthorized();
var signingCreds = new SigningCredentials(_privateKey,
SecurityAlgorithms.RsaSha256);
var claims = new[] // Populate standard claims
{
new Claim(JwtRegisteredClaimNames.Sub, request.Username),
new Claim(JwtRegisteredClaimNames.Email, "testUser@example.com")
};
const int tokenLifetimeMinutes = 10; // Hardcoded for demo purposes
var jwt = new JwtSecurityToken(
issuer: "https://yourissuer.example",
audience: "https://youraudience.example",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(tokenLifetimeMinutes),
signingCredentials: signingCreds);
return Ok(new
{
access_token = new JwtSecurityTokenHandler().WriteToken(jwt),
expires_in = tokenLifetimeMinutes * 60
});
}
}
// A simple request model used by the demo token endpoint to receive user credentials.
public record LoginRequest(string Username, string Password);
Controllers/SecureController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JwtDemo.Controllers;
// ENDPOINT: Simple protected endpoint for verifying JWT authentication
[ApiController]
[Route("secure")]
public class SecureController : ControllerBase
{
[Authorize]
[HttpGet]
public IActionResult Get()
{
return Ok("You now have access to a protected resource.");
}
}
Thanks for stopping by!
We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.
If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!