Almost every real-world API eventually needs to answer one question on every request: who is calling, and are they allowed to? JWT (JSON Web Token) authentication is the most common answer in the .NET world, but most tutorials stop at "here is how to validate a token" and skip the parts that actually bite you in production: claims design, token expiry, and refresh tokens.
By the end of this post you will have a .NET 8 Web API that issues signed access tokens, embeds meaningful claims, validates them on protected endpoints, and hands out refresh tokens so users are not forced to log in every fifteen minutes.
Before starting, make sure you have:
- .NET 8 SDK installed (verify with
dotnet --version) - Visual Studio 2022 (17.8 or later) or VS Code with the C# Dev Kit
- A working ASP.NET Core Web API project and basic familiarity with dependency injection and middleware ordering
appsettings.json committed to source control.
The first step is wiring the JWT bearer handler into the request pipeline so that the framework knows how to read, validate, and reject tokens. We start by defining the token settings, then registering the authentication scheme.
Define your token settings
Keeping the issuer, audience, key, and lifetimes in a strongly typed options object avoids magic strings scattered across the codebase.
// JwtSettings.cs — bound from configuration
public sealed class JwtSettings
{
public string Issuer { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string SigningKey { get; init; } = string.Empty;
public int AccessTokenMinutes { get; init; } = 15;
public int RefreshTokenDays { get; init; } = 7;
}
// appsettings.json
{
"Jwt": {
"Issuer": "https://api.yourapp.com",
"Audience": "https://yourapp.com",
"SigningKey": "DEV-ONLY-replace-with-a-32+-char-secret-from-vault",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 7
}
}
Register the bearer handler
This is where the validation rules live. Notice that every Validate* flag is explicitly set to true; relying on defaults is how subtle security holes slip in.
// Program.cs
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
builder.Services.AddSingleton(jwt);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwt.SigningKey)),
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
The key detail most people miss is ClockSkew. It defaults to five minutes, which means a "15 minute" token actually lives for up to 20. Tightening it to 30 seconds keeps short-lived tokens genuinely short-lived. Also remember that UseAuthentication() must come before UseAuthorization() in the middleware pipeline, or every request will be treated as anonymous.
A token is only as useful as the claims it carries. Claims are the key-value statements the API trusts because the token is signed. Good claim design means you rarely have to hit the database again just to know who the caller is or what they can do.
// TokenService.cs
public sealed class TokenService(JwtSettings settings)
{
public string CreateAccessToken(Guid userId, string email, IEnumerable<string> roles)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.Email, email),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: settings.Issuer,
audience: settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(settings.AccessTokenMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
The sub claim holds the stable user identifier, jti gives each token a unique ID (useful if you ever need to blocklist a specific token), and the role claims drive authorization. Keep tokens lean; do not stuff a user's entire profile in here, because the token travels on every request and large tokens hurt both bandwidth and header size limits.
Access tokens are deliberately short-lived, so without a refresh mechanism your users would be logged out constantly. A refresh token is a long-lived, single-use, server-tracked secret that can be exchanged for a fresh access token. Because it is stored and revocable, it gives you a way to invalidate sessions that a stateless JWT alone cannot.
// RefreshToken.cs — persisted entity
public sealed class RefreshToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public DateTime ExpiresUtc { get; set; }
public DateTime? RevokedUtc { get; set; }
public bool IsActive => RevokedUtc is null && DateTime.UtcNow < ExpiresUtc;
}
// AuthController.cs — the refresh endpoint
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var hash = TokenHasher.Hash(request.RefreshToken);
var stored = await _db.RefreshTokens
.FirstOrDefaultAsync(t => t.TokenHash == hash);
if (stored is null || !stored.IsActive)
return Unauthorized("Invalid or expired refresh token.");
// Rotate: revoke the old token and issue a new pair.
stored.RevokedUtc = DateTime.UtcNow;
var user = await _db.Users.FindAsync(stored.UserId);
var (newRefresh, newHash) = TokenHasher.Generate();
_db.RefreshTokens.Add(new RefreshToken
{
UserId = user!.Id,
TokenHash = newHash,
ExpiresUtc = DateTime.UtcNow.AddDays(_settings.RefreshTokenDays)
});
await _db.SaveChangesAsync();
var accessToken = _tokenService.CreateAccessToken(
user.Id, user.Email, user.Roles);
return Ok(new { accessToken, refreshToken = newRefresh });
}
Two non-negotiable practices appear here. First, store only a hash of the refresh token, never the raw value, so a database leak does not hand attackers live sessions. Second, rotate on every use: each refresh revokes the old token and issues a new one, which lets you detect token theft (a revoked token being replayed is a strong signal something is wrong).
With issuance and refresh in place, protecting an endpoint is a single attribute. The framework reads the bearer token, validates the signature and claims, and populates User for you.
[Authorize]
[HttpGet("me")]
public IActionResult Me()
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
var email = User.FindFirstValue(JwtRegisteredClaimNames.Email);
return Ok(new { userId, email });
}
To verify the full loop, call your login endpoint to receive an access and refresh token, then call a protected endpoint with the access token in the header:
GET /api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
A valid token returns 200 with your claims. Wait for the access token to expire and the same call returns 401; hit the refresh endpoint with your refresh token and you get a fresh pair, confirming the rotation works end to end.
You now have a .NET 8 API that issues signed, claim-bearing access tokens, validates them strictly, and supports rotating refresh tokens backed by hashed, revocable storage. That covers the authentication half of the security story: proving who the caller is.
The natural next step is authorization, deciding what an authenticated caller is allowed to do. That is exactly what the next post in this series tackles with role-based authorization using EF Core and PostgreSQL.
No comments: