IConfiguration vs IOptions NET
Synchronous and Asynchronous in .NET Core
Model Binding and Validation in ASP.NET Core
ControllerBase vs Controller in ASP.NET Core
ConfigureServices and Configure methods
IHostedService interface in .NET Core
ASP.NET Core request processing
| Domain-Driven Design (DDD) | RBAC vs claims-based in ASP.NET Core | |
Authentication, Authorization, and Claims-Based Security in ASP.NET Core |
This article offers a practical, end-to-end walkthrough of security in ASP.NET Core. You will learn how to authenticate users, authorize their actions, and leverage claims-based security for fine-grained access control. We will cover cookies, JWTs, OpenID Connect, ASP.NET Core Identity, role-based and policy-based authorization, custom authorization requirements, resource-based checks, multi-tenant considerations, testing, and hardening tips. Examples are provided in simple C#.
Security in ASP.NET Core is layered. Authentication answers “who are you?”; authorization answers “what are you allowed to do?”; claims express structured facts about the identity. Robust applications separate these concerns: authenticate using reliable identity providers, flow identity as claims, and express access rules using roles, policies, or custom requirements.
Authentication and authorization are middleware-driven in ASP.NET Core. The order of registration matters. You add services for authentication schemes and policies, then use middleware to apply them on each request.
// Program.cs (.NET 6+)
var builder = WebApplication.CreateBuilder(args);
// 1) Register authentication schemes and authorization policies
builder.Services.AddAuthentication()
.AddCookie("AppCookie", opt =>
{
opt.LoginPath = "/account/login";
opt.AccessDeniedPath = "/account/denied";
opt.SlidingExpiration = true;
opt.Cookie.Name = ".MyApp.Auth";
opt.Cookie.HttpOnly = true;
opt.Cookie.SecurePolicy = CookieSecurePolicy.Always;
opt.Cookie.SameSite = SameSiteMode.Lax; // Consider Strict for non-SPA
})
.AddJwtBearer("ApiJwt", opt =>
{
opt.RequireHttpsMetadata = true;
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://issuer.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]))
};
});
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.AddPolicy("AdminsOnly", p => p.RequireRole("Admin"));
options.AddPolicy("CanEditUser", p => p.RequireClaim("permission", "user.edit"));
});
var app = builder.Build();
// 2) Order: Authentication BEFORE Authorization
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // or MapGet/MapPost handlers for minimal APIs
app.Run();
The fallback policy is an optional safe default that requires authentication for all endpoints unless explicitly annotated with [AllowAnonymous].
ASP.NET Core supports multiple authentication approaches. The choice depends on app type, deployment topology, client capabilities, and security posture.
JWTs are compact, signed tokens containing claims. APIs validate the token signature and claims without server-side state. JWTs are ideal for SPAs, mobile apps, and microservices.
builder.Services.AddAuthentication("ApiJwt")
.AddJwtBearer("ApiJwt", options =>
{
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://issuer.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]))
};
});
[AllowAnonymous]
[HttpPost("token")]
public async Task<IActionResult> CreateToken(LoginInput input)
{
var user = await _users.ValidateAsync(input.Username, input.Password);
if (user is null) return Unauthorized();
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim("name", user.DisplayName),
new Claim("role", user.Role),
new Claim("tenant", user.TenantId),
new Claim("permission", "user.edit") // example
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:SigningKey"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: "https://issuer.example.com",
audience: "my-api",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(30),
signingCredentials: creds);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
return Ok(new { access_token = jwt, token_type = "Bearer", expires_in = 1800 });
}
app.MapGet("/me", (ClaimsPrincipal user) => new
{
sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue(JwtRegisteredClaimNames.Sub),
name = user.FindFirstValue("name"),
email = user.FindFirstValue(ClaimTypes.Email),
roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray()
})
.RequireAuthorization(); // fallback policy requires authentication
app.MapDelete("/users/{id}", (string id) => Results.Ok())
.RequireAuthorization("CanEditUser");
JWTs should typically be stored in memory on the client (not in localStorage) and sent via the Authorization: Bearer <token> header. For long-lived sessions, use refresh tokens via the auth server, not the API.
OpenID Connect (OIDC) builds on OAuth 2.0 for delegated authentication. Your app redirects users to an identity provider (IdP) for login. On success, the IdP returns an ID token (and often an access token) that your app validates.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "AppCookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("AppCookie")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://login.example-idp.com";
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = "code"; // Authorization Code Flow
options.SaveTokens = true; // persist tokens in auth properties
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("offline_access"); // if refresh tokens are needed
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
});
public IActionResult SignIn(string returnUrl = "/")
=> Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, "oidc");
public IActionResult SignOutUser()
=> SignOut(new AuthenticationProperties { RedirectUri = "/" }, "AppCookie", "oidc");
Use OIDC when you want SSO, MFA, centralized user management, compliance, or federation across multiple apps and APIs.
ASP.NET Core Identity is a membership system that provides user registration, password hashing, account lockout, 2FA, and UI scaffolding. It can be used alone (with cookies) or combined with OIDC (Identity acts as an IdP) or with JWT issuance via IdentityServer-equivalents.
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
var result = await _signInManager.PasswordSignInAsync(input.Email, input.Password, input.RememberMe, lockoutOnFailure: true);
if (result.Succeeded) return Redirect("/");
if (result.RequiresTwoFactor) return Redirect("/account/2fa");
if (result.IsLockedOut) return View("Lockout");
return View("Login", new { error = "Invalid login" });
Identity is convenient for greenfield apps needing local accounts and out-of-the-box security flows. For enterprise SSO or B2C, prefer OIDC with a managed IdP.
Claims-based authorization focuses on statements about the user rather than static roles. This model scales well for dynamic permissions, multi-tenant apps, and external IdPs.
Claims-based authorization evaluates access based on user attributes (claims) rather than only roles. A claim is a key-value pair that expresses something about the user, such as Permission = EditUser or Department = Finance. Policies tie these claims to application rules that are evaluated at runtime.
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanEditUser", p => p.RequireClaim("permission", "user.edit"));
});
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanManageUsers", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim("permission", "user.edit")
|| ctx.User.IsInRole("Admin")));
});
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditUser", policy =>
policy.RequireClaim("Permission", "EditUser"));
});
// Controller action
[Authorize(Policy = "CanEditUser")]
public IActionResult EditUser(Guid id) => View();
// After validating credentials or receiving tokens from an IdP
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim("Permission", "EditUser"),
new Claim("Department", "Engineering"),
new Claim("TenantId", "acme") // see multi-tenant section
};
var identity = new ClaimsIdentity(claims, "Cookies");
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync("Cookies", principal);
When policies need logic beyond simple role-or-claim checks, create custom authorization requirements and handlers. A requirement encapsulates the policy rule; a handler enforces it at runtime.
public sealed class MinimumAgeRequirement : IAuthorizationRequirement
{
public int Age { get; }
public MinimumAgeRequirement(int age) => Age = age;
}
public sealed class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
var dobClaim = context.User.FindFirst("DateOfBirth");
if (dobClaim is null) return Task.CompletedTask;
if (DateTime.TryParse(dobClaim.Value, out var dob))
{
var age = (int)((DateTime.UtcNow - dob).TotalDays / 365.2425);
if (age >= requirement.Age)
context.Succeed(new MinimumAgeRequirement(requirement.Age));
}
return Task.CompletedTask;
}
}
// Program.cs
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Over18", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
// Controller action
[Authorize(Policy = "Over18")]
public IActionResult BuyRestrictedItem() => View();
Resource-based authorization decides access with knowledge of a specific resource instance, such as a document or record. Instead of decorating actions with attributes, you inject and call IAuthorizationService, passing the resource for contextual evaluation.
public sealed class Document
{
public Guid Id { get; init; }
public Guid OwnerId { get; init; }
public bool IsConfidential { get; init; }
public string Department { get; init; } = "";
}
public sealed class DocumentOperationRequirement : IAuthorizationRequirement
{
public string OperationName { get; }
public DocumentOperationRequirement(string operationName) => OperationName = operationName;
}
public sealed class DocumentAuthorizationHandler
: AuthorizationHandler<DocumentOperationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DocumentOperationRequirement requirement,
Document resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var dept = context.User.FindFirstValue("Department");
// Owner can do anything
if (resource.OwnerId.ToString() == userId)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
// Confidential docs require same department and only "Read"
if (resource.IsConfidential && requirement.OperationName == "Read" &&
string.Equals(resource.Department, dept, StringComparison.OrdinalIgnoreCase))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public sealed class DocumentsController : Controller
{
private readonly IAuthorizationService _auth;
private readonly IDocumentRepository _repo;
public DocumentsController(IAuthorizationService auth, IDocumentRepository repo)
{ _auth = auth; _repo = repo; }
public async Task<IActionResult> View(Guid id)
{
var doc = await _repo.GetAsync(id);
if (doc is null) return NotFound();
var result = await _auth.AuthorizeAsync(User, doc, new DocumentOperationRequirement("Read"));
if (!result.Succeeded) return Forbid();
return View(doc);
}
}
In multi-tenant systems, authorization must isolate data and permissions by tenant. In multi-environment deployments (dev, test, prod), token validation and policies often vary per environment.
// Example claims
new Claim("TenantId", "acme");
new Claim("TenantRole", "acme:Admin"); // role scoped to a tenant
new Claim("Permission", "acme:Invoices.Read");
// Policy requiring tenant-scoped permission
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("InvoiceRead", p => p.RequireAssertion(ctx =>
{
var tenant = ctx.User.FindFirst("TenantId")?.Value;
return tenant is not null &&
ctx.User.HasClaim("Permission", $"{tenant}:Invoices.Read");
}));
});
// Resolve TenantId from current user
public interface ICurrentTenant { string TenantId { get; } }
public class AppDbContext : DbContext
{
private readonly ICurrentTenant _tenant;
public DbSet<Invoice> Invoices => Set<Invoice>();
public AppDbContext(DbContextOptions options, ICurrentTenant tenant) : base(options)
=> _tenant = tenant;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Invoice>()
.HasQueryFilter(i => i.TenantId == _tenant.TenantId);
}
}
// appsettings.Development.json vs appsettings.Production.json configure different authorities/audiences
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Jwt:Authority"];
options.Audience = builder.Configuration["Jwt:Audience"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(2)
};
});
public sealed class PermissionPolicyProvider : DefaultAuthorizationPolicyProvider
{
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options) : base(options) { }
public override Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
{
// Expect policies like "perm:Invoices.Read"
if (policyName.StartsWith("perm:", StringComparison.OrdinalIgnoreCase))
{
var perm = policyName.Substring("perm:".Length);
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionRequirement(perm))
.Build();
return Task.FromResult(policy);
}
return base.GetPolicyAsync(policyName);
}
}
public sealed class PermissionRequirement : IAuthorizationRequirement
{
public string Permission { get; }
public PermissionRequirement(string permission) => Permission = permission;
}
With a custom provider and requirement, you can express per-tenant checks like [Authorize("perm:Invoices.Read")] and resolve the current tenant inside the handler to require {tenant}:{permission}.
[Fact]
public async Task Owner_can_read_document()
{
var doc = new Document { Id = Guid.NewGuid(), OwnerId = Guid.NewGuid(), IsConfidential = true, Department = "HR" };
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, doc.OwnerId.ToString()),
new Claim("Department", "Finance")
}, "Test"));
var requirement = new DocumentOperationRequirement("Read");
var context = new AuthorizationHandlerContext(new[] { requirement }, user, doc);
var handler = new DocumentAuthorizationHandler();
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
var appFactory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Optionally stub ICurrentTenant or token validation
});
});
var client = appFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TestTokens.WithClaim("Permission", "acme:Invoices.Read"));
var response = await client.GetAsync("/invoices"); // endpoint protected by "InvoiceRead"
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = config["Jwt:Authority"];
o.Audience = config["Jwt:Audience"];
o.RequireHttpsMetadata = true;
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
o.Events = new JwtBearerEvents
{
OnAuthenticationFailed = ctx => { ctx.NoResult(); return Task.CompletedTask; }
};
});
| Domain-Driven Design (DDD) | RBAC vs claims-based in ASP.NET Core | |