Authentication and Authorization in .NET: JWT and Identity
# Authentication and Authorization in .NET
Secure APIs require clear separation between authentication (who the user is) and authorization (what the user can do). ASP.NET Core provides robust middleware and policy-based controls for both. In this article, I walk through the practical setup of JWT authentication, ASP.NET Core Identity, OAuth2/OIDC integration, and the security patterns I rely on in production systems.
Authentication Options
JWT Bearer Tokens
JWT (JSON Web Token) is the go-to choice for stateless API authentication. The server issues a signed token containing user claims, and every subsequent request carries that token in the `Authorization` header. No session state on the server, no sticky sessions needed.
Here is a typical JWT configuration in `Program.cs`:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration[class="code-string">"Jwt:Issuer"],
ValidAudience = builder.Configuration[class="code-string">"Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration[class="code-string">"Jwt:Key"]!)),
ClockSkew = TimeSpan.FromSeconds(class="code-number">30)
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Append(class="code-string">"X-Token-Expired", class="code-string">"true");
}
return Task.CompletedTask;
}
};
});I always set `ClockSkew` to a small value. The default 5-minute skew is far too generous for most systems and creates a window where expired tokens are still accepted.
Token Generation
Generating tokens is straightforward, but the details matter. Here is a service I typically use:
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateAccessToken(ApplicationUser user, IList<string> roles)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(class="code-string">"username", user.UserName!)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config[class="code-string">"Jwt:Key"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config[class="code-string">"Jwt:Issuer"],
audience: _config[class="code-string">"Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(class="code-number">15),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken GenerateRefreshToken()
{
return new RefreshToken
{
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(class="code-number">64)),
ExpiresAt = DateTime.UtcNow.AddDays(class="code-number">7),
CreatedAt = DateTime.UtcNow
};
}
}In APIs I have secured for production, I keep access tokens short-lived (15 minutes or less) and pair them with refresh tokens stored server-side. This limits the damage window if a token is compromised.
ASP.NET Core Identity
When your application owns the full user lifecycle -- registration, password management, email confirmation, two-factor authentication -- ASP.NET Core Identity is the right foundation. It handles the heavy lifting of password hashing, lockout policies, and user store abstraction.
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
class=class="code-string">"code-comment">// Password rules
options.Password.RequireDigit = true;
options.Password.RequiredLength = class="code-number">12;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
class=class="code-string">"code-comment">// Lockout
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(class="code-number">15);
options.Lockout.MaxFailedAccessAttempts = class="code-number">5;
options.Lockout.AllowedForNewUsers = true;
class=class="code-string">"code-comment">// User
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();Refresh Token Flow
A proper refresh token flow is essential. The client sends an expired access token along with a valid refresh token, and receives a new pair:
[HttpPost(class="code-string">"refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] TokenRefreshRequest request)
{
var principal = GetPrincipalFromExpiredToken(request.AccessToken);
if (principal is null)
return Unauthorized(class="code-string">"Invalid access token.");
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
var user = await _userManager.FindByIdAsync(userId!);
if (user is null)
return Unauthorized();
var storedToken = await _tokenRepository.GetRefreshTokenAsync(user.Id);
if (storedToken is null ||
storedToken.Token != request.RefreshToken ||
storedToken.ExpiresAt <= DateTime.UtcNow ||
storedToken.IsRevoked)
{
class=class="code-string">"code-comment">// Potential token reuse attack -- revoke the entire family
if (storedToken?.IsRevoked == true)
{
await _tokenRepository.RevokeAllTokensForUserAsync(user.Id);
}
return Unauthorized(class="code-string">"Invalid or expired refresh token.");
}
class=class="code-string">"code-comment">// Rotate: revoke old, issue new
storedToken.IsRevoked = true;
var newRefreshToken = _tokenService.GenerateRefreshToken();
newRefreshToken.UserId = user.Id;
newRefreshToken.ReplacedByToken = newRefreshToken.Token;
await _tokenRepository.SaveRefreshTokenAsync(newRefreshToken);
var roles = await _userManager.GetRolesAsync(user);
var newAccessToken = _tokenService.GenerateAccessToken(user, roles);
return Ok(new TokenResponse
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken.Token
});
}The key detail here is token family tracking. If a revoked refresh token is reused, I revoke the entire chain. This detects replay attacks where an attacker intercepts a token that the legitimate client has already rotated.
Authorization Strategy
Policy-Based Authorization
Scattered `[Authorize(Roles = "Admin")]` attributes are a maintenance headache. Policy-based authorization centralizes the logic and makes it testable:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(class="code-string">"CanManageProducts", policy =>
policy.RequireRole(class="code-string">"Admin", class="code-string">"ProductManager"));
options.AddPolicy(class="code-string">"CanViewReports", policy =>
policy.RequireClaim(class="code-string">"permission", class="code-string">"reports:read"));
options.AddPolicy(class="code-string">"PremiumUser", policy =>
policy.Requirements.Add(new PremiumSubscriptionRequirement()));
class=class="code-string">"code-comment">// Deny-by-default: require authentication for all endpoints
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});For complex business rules, implement `IAuthorizationHandler`:
public class PremiumSubscriptionHandler
: AuthorizationHandler<PremiumSubscriptionRequirement>
{
private readonly ISubscriptionService _subscriptionService;
public PremiumSubscriptionHandler(ISubscriptionService subscriptionService)
{
_subscriptionService = subscriptionService;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PremiumSubscriptionRequirement requirement)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null) return;
var isActive = await _subscriptionService
.HasActiveSubscriptionAsync(userId);
if (isActive)
{
context.Succeed(requirement);
}
}
}Then apply it cleanly on your endpoints:
app.MapGet(class="code-string">"/api/reports", GetReports)
.RequireAuthorization(class="code-string">"CanViewReports");
app.MapDelete(class="code-string">"/api/products/{id}", DeleteProduct)
.RequireAuthorization(class="code-string">"CanManageProducts");This approach keeps controllers lean and authorization logic testable in isolation.
OAuth2/OIDC Integration
For enterprise SSO or social login, integrating with an external identity provider via OpenID Connect is the standard path. Here is a practical setup with a provider like Azure AD or Keycloak:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration[class="code-string">"Oidc:Authority"];
options.ClientId = builder.Configuration[class="code-string">"Oidc:ClientId"];
options.ClientSecret = builder.Configuration[class="code-string">"Oidc:ClientSecret"];
options.ResponseType = class="code-string">"code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add(class="code-string">"openid");
options.Scope.Add(class="code-string">"profile");
options.Scope.Add(class="code-string">"email");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = class="code-string">"name",
RoleClaimType = class="code-string">"role"
};
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = async context =>
{
class=class="code-string">"code-comment">// Sync external user to local database
var userService = context.HttpContext.RequestServices
.GetRequiredService<IUserSyncService>();
await userService.SyncExternalUserAsync(context.Principal!);
}
};
});For API-to-API scenarios where there is no browser, use the client credentials flow:
public class MachineToMachineTokenService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _config;
public async Task<string> GetAccessTokenAsync()
{
var disco = await _httpClient
.GetDiscoveryDocumentAsync(_config[class="code-string">"Oidc:Authority"]);
var tokenResponse = await _httpClient.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = _config[class="code-string">"Oidc:ServiceClientId"]!,
ClientSecret = _config[class="code-string">"Oidc:ServiceClientSecret"]!,
Scope = class="code-string">"api.read api.write"
});
if (tokenResponse.IsError)
throw new InvalidOperationException(
$class="code-string">"Token request failed: {tokenResponse.Error}");
return tokenResponse.AccessToken!;
}
}In APIs I have secured for production, I always sync external identity claims to a local user record on first login. This gives the application a local reference for audit trails and avoids repeated calls to the identity provider.
Security Hardening Checklist
These are the checks I run through before every production deployment:
class=class="code-string">"code-comment">// Rate limiting on auth endpoints
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(class="code-string">"AuthEndpoints", limiter =>
{
limiter.PermitLimit = class="code-number">10;
limiter.Window = TimeSpan.FromMinutes(class="code-number">1);
limiter.QueueLimit = class="code-number">0;
});
});
class=class="code-string">"code-comment">// Apply to auth routes
app.MapPost(class="code-string">"/api/auth/login", Login)
.RequireRateLimiting(class="code-string">"AuthEndpoints");
app.MapPost(class="code-string">"/api/auth/refresh", Refresh)
.RequireRateLimiting(class="code-string">"AuthEndpoints");Common Auth Mistakes
Over the years, I have seen the same mistakes repeated across projects. Here are the ones that cause real damage:
Storing tokens in localStorage
This is the single most common mistake. Any XSS vulnerability gives an attacker full access to the token. Access tokens should live in memory (a JavaScript variable, not persisted storage). Refresh tokens belong in httpOnly cookies that JavaScript cannot read.
No refresh token rotation
If a refresh token is valid for 30 days and never rotated, a single leak gives an attacker a month-long session. Rotate on every use and track the token family so reuse of an old token revokes the entire chain.
Validating only the signature
A valid signature does not mean a valid token. Always validate `iss`, `aud`, `exp`, and `nbf` claims. I have seen systems that checked the signature but ignored the audience, letting tokens minted for one service work on another.
Fat tokens with sensitive data
JWTs are encoded, not encrypted. Do not put email addresses, phone numbers, or internal IDs that reveal system architecture into tokens. Keep claims minimal -- a user ID and role list is usually enough. If you need more data, look it up server-side.
Symmetric keys shared across services
Using the same HMAC key across multiple services means any service can forge tokens for all others. In a microservices architecture, use asymmetric keys (RSA or ECDSA). The auth service holds the private key, and consuming services only need the public key.
class=class="code-string">"code-comment">// Asymmetric key validation for consuming services
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(rsaPublicKey),
ValidateIssuer = true,
ValidIssuer = class="code-string">"https:class="code-commentclass="code-string">">//auth.mycompany.com",
ValidateAudience = true,
ValidAudience = class="code-string">"https:class="code-commentclass="code-string">">//api.mycompany.com"
};Missing token revocation strategy
JWTs are stateless by design, which means you cannot revoke them without additional infrastructure. For production systems, I maintain a revocation list (short-lived cache in Redis) that middleware checks on every request. The overhead is minimal compared to the security it provides.
Not logging auth events
If you cannot answer "who logged in from where, when, and what did they access?" you are not ready for production. Every authentication and authorization decision should produce an audit event.
Conclusion
Effective .NET security architecture combines standards-based authentication with explicit, policy-driven authorization and strong operational hygiene. The framework gives you solid building blocks, but the difference between a secure system and a vulnerable one comes down to the details -- token lifetimes, rotation strategies, storage decisions, and consistent audit logging.
I can review your auth flows and hardening checklist for production.
Related Articles
What is .NET? A Modern Backend Development Guide
Learn what .NET is, how it works, and why enterprise teams choose it for backend development.
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Clean Architecture in .NET: Building Scalable Project Structure
Apply Clean Architecture in .NET projects. A guide to layers, dependency management, and testable code.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch