In this article, we'll walk through how to secure a .NET API using JSON Web Tokens (JWTs).
JWTs solve a classic problem in web development: how to let an API verify who a caller is without maintaining server-side session state. Each token carries its own proof of authenticity in the form of a digital signature, so the API can trust it at face value once validation succeeds.
For a quick review of JWT fundamentals, check out Duende Software's articles, What Is a JSON Web Token (JWT) and How Does It Work in Modern Web Apps? and Best Practices When Using JWTs With Web and Mobile Apps.
Here we'll build a minimal but complete API for .NET 6 and later that implements JWT-based authentication. The goal isn't to produce a full identity system, but to see how these basic building blocks come together in a working example.
Let's get started! The complete API code is provided at the end of this article.
Step #1: Create a Minimal API Project
Minimal APIs are Microsoft's streamlined model for small services in .NET. They expose the same ASP.NET Core middleware pipeline used in larger projects, but eliminate the need for more formal conventions such as controllers and attribute-based routing.
Go ahead and create a new ASP.NET Core Web API project that uses Minimal APIs. Here is a reminder of how to do it using one of the many CLI options:
dotnet new webapi -n JwtDemo --use-minimal-apis
cd JwtDemo
Once complete, this gives us a single Program.cs file that defines the application's entire surface. You will use calls to MapGet, MapPost, and similar methods to create endpoints directly in the code. Nothing fancy here — just focusing on the mission of creating a JWT tutorial.
NOTE: This tutorial uses self-issuing of JWTs purely to keep the example self-contained and easy to follow. In real applications, JWT issuance should be handled by a dedicated Identity Provider (IdP) such as Duende Software's IdentityServer, which manages signing keys, token lifetimes, and the entire authentication workflow. Using an IdP adds architectural and deployment complexity that is outside the scope of this tutorial, but it is the highly preferred approach for production. As a teaching tool, however, it is helpful to see how .NET constructs and signs JWTs under the hood.
Identity Providers are recommended for many reasons, some of which are:
- Centralized authentication: Users authenticate in one place rather than across multiple APIs.
- Proper key management: IdPs handle private key protection, rotation, and signing algorithms.
- Standards compliance: IdPs support OAuth 2.0, OpenID Connect, 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 #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, battle-tested code that evolves with .NET's own security updates — no need to maintain a custom solution that could fall behind current standards and become a security vulnerability.
Step #3: Configure Authentication Services
Before an API can implement JWT authentication, it needs to know how to validate incoming tokens — what 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. In the Minimal API model, 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 so that any request with a valid token is accepted while all others are rejected. Think of this as teaching the API how to tell a genuine token from a forged or expired one. To keep the example straightforward, the sample code uses a PEM file to store the private key used by this tutorial for self-issuing of JWTs.
var builder = WebApplication.CreateBuilder(args);
// IMPORTANT: This sample issues its own JWTs and loads a private RSA key
// from a PEM file strictly for demonstration purposes.
// 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.
// Load RSA private key for asymmetric signing
var privateKeyPem = File.ReadAllText("private-key#1.pem");
var rsa = RSA.Create(2048);
rsa.ImportFromPem(privateKeyPem);
// PRIVATE KEY: used only for issuing JWTs in this demo.
// In real production systems, private keys remain with a
// dedicated IdP.
var privateKey = new RsaSecurityKey(rsa);
// PUBLIC KEY: used by the API to validate incoming JWTs.
// APIs validate tokens; they do not issue them.
var publicKey =
new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: false));
// 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 would be based on your IdP
ValidIssuer = "https://yourissuer.example",
// ValidAudience would be based on your IdP
ValidAudience = "https://youraudience.example",
// IssuerSigningKey would not be specified if using an IdP
IssuerSigningKey = publicKey
};
});
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 rulebook for what qualifies as 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 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 when they should no longer be accepted. Enabling lifetime validation means any request arriving after that 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 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.
Here in this tutorial, the signing key is the RSA private key (RS256). The API loads a PEM file that contains only the private key, and the public key parameters are derived from it in code. This allows the sample to both issue and validate tokens locally for demonstration purposes. In production, these responsibilities are separated: the issuer holds the private key, while APIs validate tokens using only the public key, as is standard with OAuth 2.0, OpenID Connect, and Duende Software's IdentityServer.
Step #4: Add Authentication and Authorization Handlers
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 with the following calls 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();
// Validates tokens and populates HttpContext.User
app.UseAuthentication();
// Enforces policies like [Authorize]
app.UseAuthorization();
When an HTTP request reaches UseAuthentication, the authentication handler examines the Authorization header, 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 carries its own credentials in the JWT, 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 Token-Issuing Endpoint
It's important to note that the JWT Bearer handler in .NET doesn't issue tokens; it only validates existing ones. Because of that, in conjunction with this tutorial's self-issuing approach, an endpoint for JWT generation is needed.
Below is such an endpoint, called /token, that authenticates a request and issues a signed JWT directly. In an actual production scenario, there would be no hardcoding of user/password, and these credentials would be validated against a secure user store with stronger policies applied, such as hashing, rate limiting, and HTTPS enforcement. The authentication handler configured earlier then validates that token on subsequent requests.
// ENDPOINT: Demo-only token issuance endpoint to show end-to-end flow
app.MapPost("/token", (LoginRequest request) =>
{
// Authentication would be handled by your IdP outside the scope of this demo
if (request.Username != "testUser" || request.Password != "P@ssw0rd!")
return Results.Unauthorized();
var creds = 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,
//Typical short lifetime used with JWTs
expires: DateTime.UtcNow.AddMinutes(TokenLifetimeMinutes),
signingCredentials: creds);
return Results.Ok(new
{
access_token = new JwtSecurityTokenHandler().WriteToken(jwt),
expires_in = TokenLifetimeMinutes * 60
});
});
Let's look at some highlights of the JWT-issuing /token endpoint:
- The
/tokenendpoint uses the RS256 (RSA SHA-256) signing algorithm. 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. In this tutorial, the private key on the issuing side is in the PEM file. - For any readers wondering why you don't need to manually specify
"alg": "RS256"in the JWT header, this is done automatically by .NET. - 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.
Testing the API
Congratulations! 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 and protect a simple endpoint in the Program.cs, called /secure, decorated with the [Authorize] attribute.
Minimal APIs make this straightforward by letting us apply [Authorize] directly to an endpoint delegate. If you prefer a controller-based architecture, the same attribute can decorate controller actions.
// Simple protected endpoint for verifying JWT authentication
app.MapGet("/secure",
[Authorize]() => "You now have access to a protected resource");
This minimal route requires a valid JWT in the Authorization header. Requests without a token — or with an invalid or expired one — should immediately receive a 401 Unauthorized response. On success, you should see "You now have access to a protected resource" in the 200 response.
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, go ahead and 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 all goes well, you should see a response such as the following, which includes the JWT content as well as the expiration information:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 600
}
For the final test in this tutorial, call the /secure endpoint again, but this time with the JWT just obtained.
curl -i https://localhost:5001/secure \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
The API should return 200 OK and display the success message, confirming that your JWT was validated successfully. If you're curious, you can also change something in the JWT or wait until it expires, then try the call again to verify you receive a 401 in the 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, rejecting unauthenticated requests, and successfully granting 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 factors to consider when it comes to 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 window of 5 – 15 minutes is generally acceptable; this tutorial uses 10 minutes.
- Asymmetric signing: Asymmetric signing keys are preferred over symmetric signing keys (as mentioned in this article) to avoid shared-secret risk.
- Secure storage: Private keys and configuration secrets should be protected via secure storage.
- 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 to handle the various 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 demo's self-issuing approach, while others apply more broadly when working with Identity Providers. They are included here as general security guidance for JWT-based systems.
For a deeper discussion of JWT best practices, check out Duende Software's article Best Practices When Using JWTs With Web and Mobile Apps.
Final Thoughts
In this tutorial, you built a working foundation for securing a .NET 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 Software's IdentityServer fits in. It is an IdP that extends this model into a full OAuth 2.0 and OpenID Connect platform, handling authentication flows, key management, and revocation securely and consistently.
For client-facing architectures, Duende's Backend for Frontend (BFF) framework goes further, centralizing token handling on the server side and reducing the risk of tokens being exposed to the browser in SPAs or native apps.
The Complete API Source Code
Here is the entire Program.cs from the API example…
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
var builder = WebApplication.CreateBuilder(args);
// IMPORTANT: This sample issues its own JWTs and loads a private RSA key
// from a PEM file strictly for demonstration purposes.
// 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.
// Load RSA private key for asymmetric signing
var privateKeyPem = File.ReadAllText("private-key#1.pem");
var rsa = RSA.Create(2048);
rsa.ImportFromPem(privateKeyPem);
// PRIVATE KEY: used only for issuing JWTs in this demo.
// In real production systems, private keys remain with a
// dedicated IdP.
var privateKey = new RsaSecurityKey(rsa);
// PUBLIC KEY: used by the API to validate incoming JWTs.
// APIs validate tokens; they do not issue them.
var publicKey =
new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: false));
// 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 would be based on your IdP
ValidIssuer = "https://yourissuer.example",
// ValidAudience would be based on your IdP
ValidAudience = "https://youraudience.example",
// IssuerSigningKey would not be specified if using an IdP
IssuerSigningKey = publicKey
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // Validates tokens and populates HttpContext.User
app.UseAuthorization(); // Enforces policies like [Authorize]
// ENDPOINT: Demo-only token issuance endpoint to show end-to-end flow
app.MapPost("/token", (LoginRequest request) =>
{
// Authentication would be handled by your IdP outside the scope of this demo
if (request.Username != "testUser" || request.Password != "P@ssw0rd!")
return Results.Unauthorized();
var creds = 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,
//Typical short lifetime used with JWTs
expires: DateTime.UtcNow.AddMinutes(TokenLifetimeMinutes),
signingCredentials: creds);
return Results.Ok(new
{
access_token = new JwtSecurityTokenHandler().WriteToken(jwt),
expires_in = TokenLifetimeMinutes * 60
});
});
// ENDPOINT: Simple protected endpoint for verifying JWT authentication
app.MapGet("/secure",
[Authorize] () => "You now have access to a protected resource");
app.Run();
public record LoginRequest(string Username, string Password);