Authentication and Authorization in .NET: JWT and Identity

14 min readFebruary 9, 2026Updated: Mar 9, 2026
.NET authenticationJWT .NETASP.NET Core IdentityOAuth2 .NET.NET authorizationBearer token C#.NET securityClaims-based auth

# 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`:

csharp
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:

csharp
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.

csharp
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:

csharp
[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:

csharp
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`:

csharp
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:

csharp
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:

csharp
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:

csharp
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:

  • **HTTPS everywhere** -- enforce via `app.UseHsts()` and `RequireHttpsMetadata = true` in JWT config
  • **Token storage** -- access tokens in memory only (never localStorage); refresh tokens in httpOnly, Secure, SameSite=Strict cookies
  • **Key management** -- signing keys stored in Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault; never in appsettings.json
  • **Token lifetime** -- access tokens 15 minutes max; refresh tokens 7 days with rotation on every use
  • **CORS configuration** -- explicit allowed origins, never `AllowAnyOrigin()` with credentials
  • **Rate limiting on auth endpoints** -- login, register, and token refresh endpoints must have aggressive rate limits
  • **Audit logging** -- log every login, failed attempt, token refresh, and permission escalation with correlation IDs
  • **Password policy** -- minimum 12 characters, breached password check against HaveIBeenPwned API
  • **Account lockout** -- lock after 5 failed attempts, 15-minute cooldown
  • **Security headers** -- `X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`
  • csharp
    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.

    csharp
    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

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch