JWT Authentication in ASP.NET Core 10 Web API Using C# – Complete Guide (2026)

A login endpoint is only the beginning of API security. Your application also needs to verify who issued a token, whether it was changed, which API it belongs to, and whether the caller has permission to use an endpoint.

This guide explains JWT authentication in ASP.NET Core 10 Web API using C# through a complete local example: a login endpoint, a protected profile endpoint, and an administrator-only endpoint.

How does JWT authentication work in ASP.NET Core 10?

The client sends an access token in the Authorization: Bearer header. ASP.NET Core's JWT bearer handler validates the token and builds the caller's identity. Authorization policies then decide whether that identity can access the requested endpoint. An invalid or missing token normally produces 401; an authenticated caller without permission receives 403.

Important scope: the custom token issuer below is for local development and testing only. It refuses to start outside Development. Microsoft recommends using established OAuth/OpenID Connect solutions for production access-token issuance, rather than turning a password-checking sample into an authorization server. See Microsoft's JWT bearer guidance.

JWT generation, validation, authentication, and authorization

  • Generation: the trusted issuer creates claims, sets an expiry, and signs the token.
  • Validation: the API checks the signature, accepted algorithm, issuer, audience, and validity period.
  • Authentication: the validated claims identify the caller.
  • Authorization: a policy determines what that caller may do.

This example produces a signed JWT, not an encrypted token. Its payload can be decoded, so never put passwords, signing keys, or private application data into claims. Decoding a token is not validation.

Prerequisites and required NuGet package

You need the .NET 10 SDK, a C# editor, PowerShell for the configuration commands below, and Postman for requests. The demonstration stores one local account in configuration, hashes its password in memory, and has no user database.

Install Microsoft.AspNetCore.Authentication.JwtBearer from the 10.0 release line. The wildcard command selects an available 10.0 patch; keep the resolved version in your project file. Its dependency graph supplies the IdentityModel token libraries used below. ASP.NET Core's Web SDK supplies the framework services for authorization, password hashing, and rate limiting. Inspect the resolved dependencies before upgrading packages.

Step 1: Create the ASP.NET Core 10 project

Terminal
dotnet --version
dotnet new web -n JwtDemoApi -f net10.0
cd JwtDemoApi
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version "10.0.*"
dotnet list package --include-transitive
dotnet user-secrets init

The SDK command should report a 10.x version. We use the empty web template so every endpoint and middleware registration is visible, rather than relying on generated sample code.

Step 2: Configure a random signing key and a local demo account

Run these PowerShell commands from the project directory. Replace the password placeholder with your own local password of 12–128 characters. The example username is a test identity, not a real account.

PowerShell
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
$rng.Dispose()
$key = [Convert]::ToBase64String($bytes)

dotnet user-secrets set "Jwt:SigningKey" $key
dotnet user-secrets set "Jwt:Issuer" "https://localhost:7081"
dotnet user-secrets set "Jwt:Audience" "jwt-demo-api"
dotnet user-secrets set "Demo:Username" "reader@example.test"
dotnet user-secrets set "Demo:Password" "REPLACE_WITH_YOUR_OWN_LOCAL_PASSWORD"

The key is 32 cryptographically random bytes, encoded as Base64 for storage. The application decodes it before creating an HS256 signing key. Do not substitute a memorable sentence or publish your generated key.

User Secrets keeps these values outside the project tree but does not encrypt them. It is a development convenience, not a production vault. The demo password exists in local configuration and is hashed at startup; this is not a durable identity store. Production systems should persist password hashes through a supported identity solution and protect signing material with an appropriate secret or key-management service. Read Microsoft's development-secret guidance.

Step 3: Configure authentication and the API endpoints

Replace Program.cs with this complete file:

Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

// This custom issuer is a local learning example, not a production IdP.
if (!builder.Environment.IsDevelopment())
    throw new InvalidOperationException("Run this demo in Development only.");

string Required(string key) =>
    builder.Configuration[key] is { Length: > 0 } value
        ? value
        : throw new InvalidOperationException($"Missing setting: {key}");

string issuer = Required("Jwt:Issuer");
string audience = Required("Jwt:Audience");
byte[] keyBytes = Convert.FromBase64String(Required("Jwt:SigningKey"));
if (keyBytes.Length < 32)
    throw new InvalidOperationException("Use at least 32 random key bytes.");

var signingKey = new SymmetricSecurityKey(keyBytes);
var user = new DemoUser("demo-reader", Required("Demo:Username"), "Reader");
string demoPassword = Required("Demo:Password");
if (demoPassword.Length < 12 || demoPassword.Length > 128)
    throw new InvalidOperationException("Use a demo password of 12–128 characters.");

var hasher = new PasswordHasher<DemoUser>();
string passwordHash = hasher.HashPassword(user, demoPassword);

builder.Services.AddProblemDetails();
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 16_384);
builder.Services.AddSingleton(new JwtIssuer(issuer, audience, signingKey));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.IncludeErrorDetails = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = issuer,
            ValidateAudience = true,
            ValidAudience = audience,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = signingKey,
            RequireSignedTokens = true,
            RequireExpirationTime = true,
            ValidateLifetime = true,
            ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
            ClockSkew = TimeSpan.Zero,
            NameClaimType = "name",
            RoleClaimType = "role"
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser().Build();
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
});

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddFixedWindowLimiter("login", limiter =>
    {
        limiter.PermitLimit = 5;
        limiter.Window = TimeSpan.FromMinutes(1);
        limiter.QueueLimit = 0;
        limiter.AutoReplenishment = true;
    });
});

var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();

app.MapPost("/auth/login", IResult (LoginRequest input, JwtIssuer tokens) =>
{
    if (string.IsNullOrWhiteSpace(input.Username) ||
        string.IsNullOrEmpty(input.Password) ||
        input.Username.Length > 128 || input.Password.Length > 128)
        return Results.BadRequest(new { error = "Invalid login input." });

    // Verify the hash even if the supplied username is wrong.
    var result = hasher.VerifyHashedPassword(user, passwordHash, input.Password);
    bool validName = string.Equals(
        input.Username, user.Username, StringComparison.Ordinal);

    if (!validName || result == PasswordVerificationResult.Failed)
        return Results.Unauthorized();

    TokenResult token = tokens.Create(user);
    return Results.Ok(new
    {
        access_token = token.AccessToken,
        token_type = "Bearer",
        expires_in = 600,
        expires_at_utc = token.ExpiresAtUtc
    });
}).AllowAnonymous().RequireRateLimiting("login");

app.MapGet("/api/profile", (ClaimsPrincipal principal) =>
    Results.Ok(new
    {
        id = principal.FindFirst("sub")?.Value,
        name = principal.Identity?.Name,
        role = principal.FindFirst("role")?.Value
    })).RequireAuthorization();

app.MapGet("/api/admin", () =>
    Results.Ok(new { message = "Admin access granted." }))
    .RequireAuthorization("AdminOnly");

app.Run();

The login request contains only a username and password. It cannot request an administrator role. The trusted, server-side demo account always has the Reader role. Both invalid usernames and invalid passwords receive a generic 401 response.

The login limiter allows five requests per minute across this endpoint in one process. It is a small demonstration guard, not distributed abuse protection, account lockout, or a production password-attack defense. Microsoft documents the middleware in its rate-limiting guide.

The fallback authorization policy requires authentication for endpoints unless they explicitly allow anonymous access. The login endpoint uses AllowAnonymous; profile requires an authenticated caller; admin requires the AdminOnly policy.

Step 4: Add the token-generation service

Create JwtIssuer.cs in the same project directory:

JwtIssuer.cs
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

public sealed class JwtIssuer
{
    private readonly string _issuer;
    private readonly string _audience;
    private readonly SigningCredentials _credentials;

    public JwtIssuer(string issuer, string audience, SecurityKey key)
    {
        _issuer = issuer;
        _audience = audience;
        _credentials = new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256);
    }

    public TokenResult Create(DemoUser user)
    {
        DateTime now = DateTime.UtcNow;
        DateTime expires = now.AddMinutes(10);

        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = _issuer,
            Audience = _audience,
            IssuedAt = now,
            NotBefore = now,
            Expires = expires,
            SigningCredentials = _credentials,
            Claims = new Dictionary<string, object>
            {
                ["sub"] = user.Id,
                ["name"] = user.Username,
                ["role"] = user.Role,
                ["jti"] = Guid.NewGuid().ToString("N")
            }
        };

        string jwt = new JsonWebTokenHandler().CreateToken(descriptor);
        return new TokenResult(jwt, expires);
    }
}

The issuer supplies sub (subject), name, role, and jti (token identifier) claims. IssuedAt, NotBefore, and Expires use UTC. A new identifier is useful for tracking, but adding jti alone does not prevent replay or revoke a token.

This service uses Microsoft's JsonWebTokenHandler.CreateToken and SecurityTokenDescriptor. HS256 is a teaching choice for this single-process example: any service holding the shared secret can also mint tokens. A production identity provider can use asymmetric keys so resource APIs validate tokens without holding the issuer's private signing key.

Step 5: Add the request and response models

Create Models.cs beside the other two files:

Models.cs
public sealed record DemoUser(string Id, string Username, string Role);
public sealed record LoginRequest(string? Username, string? Password);
public sealed record TokenResult(string AccessToken, DateTime ExpiresAtUtc);

All three files belong to the same project and use no custom namespace. Password verification uses ASP.NET Core's PasswordHasher, rather than comparing stored plaintext or writing a custom password-hashing algorithm. A persistent identity system must also handle password rehashing, lockout, recovery, and account lifecycle.

Step 6: Build and run over local HTTPS

Terminal
dotnet dev-certs https --trust
dotnet build
dotnet run --no-launch-profile -- --environment Development --urls https://localhost:7081

Trust the development certificate only on your own development machine. If certificate trust fails on your platform, use Microsoft's development-certificate instructions rather than disabling certificate validation. If port 7081 is occupied, choose another port and update the issuer configuration and request URLs consistently.

Verification status: the APIs and security concepts were checked against official Microsoft documentation on November 3, 2026. This authoring environment has no .NET SDK, so the complete application has not been compiled or executed here. The responses below describe expected behavior, not captured test results.

Step 7: Test the login endpoint with Postman

  1. Create a POST request to https://localhost:7081/auth/login.
  2. Select Body → raw → JSON, and send the configured username and your local password.
  3. Expect HTTP 200 and an access_token value. Copy only that value for the next request.
Login request
POST https://localhost:7081/auth/login
Content-Type: application/json

{
  "username": "reader@example.test",
  "password": "YOUR_LOCAL_PASSWORD_FROM_USER_SECRETS"
}
Illustrative login response
{
  "access_token": "<signed-jwt>",
  "token_type": "Bearer",
  "expires_in": 600,
  "expires_at_utc": "<UTC expiration timestamp>"
}

The token and timestamp vary for each successful login. expires_in is 600 seconds, matching the service's ten-minute lifetime. Do not paste real access tokens into online decoders, screenshots, logs, or blog comments.

Step 8: Call the protected endpoint

In Postman, create a GET request to https://localhost:7081/api/profile. Under Authorization, choose Bearer Token and paste the token without adding the word Bearer yourself. Postman constructs the header shown below.

Protected request
GET https://localhost:7081/api/profile
Authorization: Bearer <paste-the-access_token-value>
Expected profile shape
{
  "id": "demo-reader",
  "name": "reader@example.test",
  "role": "Reader"
}

Expect HTTP 200 while the token is valid. Then remove the token and repeat the request: expect HTTP 401. A 401 response need not contain a JSON message.

Step 9: Verify authorization, tampering, and expiration

TestExpected result
Valid Reader token → GET /api/profile200 and the profile JSON
Missing, malformed, or invalid-signature token → /api/profile401
Valid Reader token → GET /api/admin403 because Reader is not Admin
Expired token → /api/profile401
Wrong username or password → /auth/login401
Malformed login input400, depending on binding or validation failure
Login request over the local fixed-window limit429

To test tampering locally, alter a character in the token's signature and resend it. The token must fail validation. Do not turn off signature validation to make a test pass.

For expiry, wait beyond ten minutes and retry the same token. This sample sets ClockSkew to zero, so it intentionally accepts no expiry grace period. Another option is to temporarily shorten the issuer's lifetime and keep expires_in consistent for your local test. Synchronize server clocks in real deployments; changing the client's clock is not an expiry test.

Why the validation settings matter

Issuer validation accepts only the configured token issuer. Audience validation prevents accepting a token intended for a different API. Signature validation checks that claims have not been changed. ValidAlgorithms restricts verification to HS256, and lifetime validation rejects expired or not-yet-valid tokens.

MapInboundClaims is false, so this example uses its literal name and role claim names. NameClaimType and RoleClaimType tell ASP.NET Core which claims represent those values. Mismatched mappings can produce an authenticated user with an empty name or unexpected role failures. See Microsoft's claims-mapping guidance.

Common JWT security mistakes

  • Shipping a sample issuer: replace this local exercise with a supported OAuth/OIDC token-issuance design for production.
  • Trusting client-supplied roles: permissions must come from trusted server-side identity data.
  • Skipping issuer, audience, signature, or expiry checks: a readable payload is not proof of authenticity.
  • Using long-lived access tokens: short expiry limits exposure, but refresh and revocation still need a separate design.
  • Exposing credentials or tokens: keep them out of source control, URL query strings, telemetry, and public examples.
  • Assuming JWT means encryption: signed claims remain readable.
  • Confusing CORS with authorization: CORS is a browser access policy, not protection against unauthorized API clients.

Expiration, refresh tokens, and logout

This example issues access tokens only. It has no refresh endpoint, token store, logout endpoint, or revocation list. After expiry, log in again for the local test. Deleting a token in the client does not invalidate copies that remain elsewhere.

A production refresh-token design should address protected storage, rotation, reuse detection, expiration, and revocation. Do not extend this sample by issuing a never-expiring JWT. Removing a user's role also does not rewrite claims in an already-issued token; sensitive operations may need current server-side permission checks.

Troubleshooting

  • App refuses to start: use Development, run secret commands in the correct project, and check that the signing key is valid Base64 containing at least 32 bytes.
  • Login fails: send the username with matching case and the password you configured, not the placeholders.
  • Profile returns 401: check the Authorization header, expiry, issuer, audience, and signing key. Do not return raw validation details to callers.
  • Admin returns 403: this is expected for the demo Reader account. Do not edit the JWT payload to grant yourself a role.
  • Name or roles are missing: check claim names and mappings, including case.
  • Unexpected 429: wait for the fixed-window login allowance; avoid repeated rapid login tests.
  • Compilation fails: check net10.0 and resolved 10.0 JwtBearer dependencies before mixing package versions.

If Visual Studio never reaches your handler, our breakpoint troubleshooting guide can help separate debugging issues from authentication failures. For an example of a capability you might later protect behind an API boundary, see our C# MCP server tutorial.

Frequently asked questions

What is the difference between 401 and 403?

401 means the request lacks acceptable authentication. 403 means the authenticated caller fails an authorization requirement. In this guide, a missing token produces 401, while a valid Reader token on the admin endpoint produces 403.

Is JWT authentication the same as OAuth?

No. JWT is a token format. OAuth defines authorization flows; OpenID Connect adds an identity layer. Using a signed JWT does not make a custom login endpoint a standards-compliant authorization server.

Can I store JWTs in localStorage?

JavaScript-accessible storage exposes tokens to successful XSS attacks. Choose storage based on the client architecture and threat model. Server-side sessions or an appropriate backend-for-frontend design may be preferable for browser applications; cookie-based designs also need CSRF defenses.

Does ASP.NET Core Identity automatically issue JWTs?

Do not assume every bearer token is a JWT. ASP.NET Core Identity's built-in API token option uses its own token format. Choose an explicit standards-based solution when another API must validate interoperable JWT access tokens.

Can I use this with controllers?

Yes. The authentication services and middleware remain relevant. A controller can use Authorize and a role or policy requirement instead of the Minimal API's RequireAuthorization calls.

Official Microsoft references

The most useful first milestone is not merely receiving a token. It is observing all three outcomes: a valid caller succeeds, an invalid token is rejected, and a valid caller without permission is forbidden.

Popular Posts