Testing Authentication and Authorisation in ASP.NET Core
Write integration tests for secured ASP.NET Core APIs: fake JWT authentication, custom test auth handlers, testing role-based and policy-based authorisation, and WebApplicationFactory patterns for clinical APIs.
The Authentication Testing Problem
Integration tests for a secured API need to:
→ Make authenticated requests as specific users (nurse, pharmacist, admin)
→ Test that unauthenticated requests return 401
→ Test that unauthorised users (wrong role) return 403
→ Not require a real identity provider (Entra ID, Keycloak) during testing
Options:
1. Disable authentication in tests — misses entire auth layer, dangerous
2. Issue real JWTs in tests — couples tests to identity provider, slow
3. Custom test authentication handler — fast, fully controlled, recommendedWebApplicationFactory Setup
// Test-specific factory that replaces the real auth scheme with a test one
public sealed class ClinicalApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace real JWT auth with test auth handler
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });
// Override database with test connection
services.RemoveAll<DbContextOptions<PrescriptionsDbContext>>();
services.AddDbContext<PrescriptionsDbContext>(opts =>
opts.UseSqlServer(TestDatabase.ConnectionString));
});
builder.UseEnvironment("IntegrationTest");
}
}Test Authentication Handler
// Reads claims from a request header — tests set the header to simulate any user
public sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestAuth";
public const string UserIdHeader = "X-Test-UserId";
public const string RolesHeader = "X-Test-Roles";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId))
return Task.FromResult(AuthenticateResult.NoResult());
var roles = Request.Headers.TryGetValue(RolesHeader, out var rolesHeader)
? rolesHeader.ToString().Split(',')
: Array.Empty<string>();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId.ToString()),
new(ClaimTypes.Name, userId.ToString()),
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r.Trim())));
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}Authenticated Client Extensions
// Extension methods for creating pre-configured test clients
public static class HttpClientExtensions
{
public static HttpClient AsNurse(this HttpClient client, Guid? userId = null)
{
client.DefaultRequestHeaders.Set(
TestAuthHandler.UserIdHeader, (userId ?? Guid.NewGuid()).ToString());
client.DefaultRequestHeaders.Set(
TestAuthHandler.RolesHeader, "Nurse");
return client;
}
public static HttpClient AsPharmacist(this HttpClient client, Guid? userId = null)
{
client.DefaultRequestHeaders.Set(
TestAuthHandler.UserIdHeader, (userId ?? Guid.NewGuid()).ToString());
client.DefaultRequestHeaders.Set(
TestAuthHandler.RolesHeader, "Pharmacist,Nurse");
return client;
}
public static HttpClient AsAdmin(this HttpClient client) =>
client.WithHeaders(
TestAuthHandler.UserIdHeader, Guid.NewGuid().ToString(),
TestAuthHandler.RolesHeader, "Admin,Pharmacist,Nurse");
private static HttpClient WithHeaders(
this HttpClient client, string nameHeader, string nameValue,
string rolesHeader, string rolesValue)
{
client.DefaultRequestHeaders.Set(nameHeader, nameValue);
client.DefaultRequestHeaders.Set(rolesHeader, rolesValue);
return client;
}
}Writing Auth Tests
[Collection("ClinicalApi")]
public sealed class PrescriptionAuthTests : IClassFixture<ClinicalApiFactory>
{
private readonly ClinicalApiFactory _factory;
public PrescriptionAuthTests(ClinicalApiFactory factory) => _factory = factory;
[Fact]
public async Task ApprovePrescription_Unauthenticated_Returns401()
{
var client = _factory.CreateClient();
// No auth headers — unauthenticated
var response = await client.PostAsJsonAsync(
$"/api/prescriptions/{Guid.NewGuid()}/approve",
new { InrValue = 2.5 });
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task ApprovePrescription_AsNurse_Returns403()
{
// Nurses can create prescriptions but not approve them
var client = _factory.CreateClient().AsNurse();
var response = await client.PostAsJsonAsync(
$"/api/prescriptions/{Guid.NewGuid()}/approve",
new { InrValue = 2.5 });
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task ApprovePrescription_AsPharmacist_Succeeds()
{
var prescriptionId = await CreateDraftPrescription();
var client = _factory.CreateClient().AsPharmacist();
var response = await client.PostAsJsonAsync(
$"/api/prescriptions/{prescriptionId}/approve",
new { InrValue = 2.5, CheckedAt = DateTime.UtcNow });
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GetAllPrescriptions_AsNurse_ReturnsOnlyOwnWardPrescriptions()
{
// Test ward-scoped data access — nurses see only their ward
var nurseId = Guid.NewGuid();
var nurseWardId = await GetNurseWardIdAsync(nurseId);
var client = _factory.CreateClient().AsNurse(nurseId);
var response = await client.GetAsync("/api/prescriptions");
var result = await response.Content
.ReadFromJsonAsync<List<PrescriptionDto>>();
// All returned prescriptions should belong to the nurse's ward
result!.All(p => p.WardId == nurseWardId).Should().BeTrue();
}
}Testing Policy-Based Authorisation
// Policy: "CanApproveWarfarin" requires Pharmacist role AND clinical certification
// In Program.cs:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanApproveWarfarin", policy =>
policy.RequireRole("Pharmacist")
.RequireClaim("clinical_cert", "warfarin_certified"));
});
// Test client with certification claim:
public static HttpClient AsCertifiedPharmacist(this HttpClient client)
{
client.DefaultRequestHeaders.Set(TestAuthHandler.UserIdHeader, Guid.NewGuid().ToString());
client.DefaultRequestHeaders.Set(TestAuthHandler.RolesHeader, "Pharmacist");
client.DefaultRequestHeaders.Set("X-Test-Claims", "clinical_cert=warfarin_certified");
return client;
}
// Test that uncertified pharmacist cannot approve Warfarin:
[Fact]
public async Task ApproveWarfarin_UncertifiedPharmacist_Returns403()
{
var client = _factory.CreateClient().AsPharmacist(); // no warfarin certification
var response = await client.PostAsJsonAsync(
$"/api/prescriptions/{warfarinPrescriptionId}/approve",
new { InrValue = 2.5 });
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}Production issue I've seen: A clinical API had integration tests that simply disabled authentication in the test environment (
services.AddAuthentication().AddFakeAuth()). All tests passed. When deployed, the authorisation policies (nurses cannot approve prescriptions) worked correctly — but a subtle bug in ward-scoping meant a nurse in Ward A could approve prescriptions from Ward B. This bug was never tested because the test setup didn't include user-to-ward mappings. Adding tests with specific nurse user IDs and ward assignments (using the TestAuthHandler pattern) caught the ward-scoping bug in the integration suite within 20 minutes of the tests being written.
Key Takeaway
Replace real JWT authentication with a
TestAuthHandlerthat reads identity from request headers — tests set the headers to simulate any user or role. ExtendHttpClientwith convenience methods likeAsNurse(),AsPharmacist(),AsAdmin(). Test all three auth states: unauthenticated (401), authenticated but unauthorised (403), and authorised (200). Test policy-based auth and data-scoping rules (ward-level access, tenant isolation) — these are the auth bugs most likely to reach production.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.