Testing ASP.NET Core endpoints with fake JWT tokens and WebApplicationFactory

5 minute read

When writing integration tests targeting a web application, everything go smoothly until authentication comes into play.

Luckily, most web APIs rely on stateless authentication schemes based on decorating requests with some kind of authentication token, often JWTs.

On the other hand, to be able to trust incoming tokens, the application needs to be able to validate them. Depending on the specific token schema used, the system can validate multiple aspects of the token. In the case of JWTs, the system often validate the issuer, the audience, the key used to sign the token, its expiration date, and so on.

Such validation makes it somewhat hard to generate an arbitrary token, use it to create an authenticated test HTTP request and have the application under test accept it.

When working with ASP.NET Core web applications there are two main approaches to bypass token validation:

  • Replace the authentication handler with a fake authentication handler.
  • Reconfigure the existing one to tweak the validation process.

The second method is often preferable, especially when the application relies on claims or metadata provided by the actual handler.

In this post, we look at how to configure the WebApplicationFactory so that validation of incoming tokens is simplified if not disabled all together.

For a quick recap on WebApplicationFactory check this post.

The application under test

Let’s introduce our application under test.

To keep things simple, we’ll use a Minimal API ASP.NET Core application with a single protected endpoint that requires JWT-based authentication.

The snippet below assumes you are already able to configure your application to properly accept and validate JWT tokens.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    { 
        builder.Configuration.Bind("Authentication", options);
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/me", (ClaimsPrincipal user) =>
{
    var email = user.FindFirst(ClaimTypes.Email)?.Value;
    return Results.Ok(new { email });
}).RequireAuthorization();

app.Run();

This small setup gives us:

  • A JWT bearer authentication handler
  • A single endpoint (/me) protected by authorization
  • A response that returns the authenticated user’s email

For the sake of brevity, I skipped the using statements.

Let’s also introduce a baseline test.

[TestFixture]
public class GetMeTests
{
    private WebApplicationFactory<Program> _factory;

    [SetUp]
    public void Setup()
    {
        _factory = new WebApplicationFactory<Program>();
    }

    [Test]
    public async Task GetMe_Should_ReturnUserEmail()
    {
        var client = _factory.CreateClient();

        var response = await client.GetAsync("/me");

        Assert.Multiple(async () =>
        {
            Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));

            var content = await response.Content.ReadAsStringAsync();

            var result = JsonSerializer.Deserialize<JsonElement>(content);
            Assert.That(result.TryGetProperty("email", out var email), Is.True);
            Assert.That(email.GetString(), Is.EqualTo("test@example.com"));
        });
    }

    [TearDown]
    public void TearDown()
    {
        _factory?.Dispose();
    }
}

This test will fail because our app is configured to reject requests without a valid authentication token, thus responding with the 401 Unauthorized status.

Forging a token

Since the web application expects a valid JWT token, let’s tweak our test to attach one to the request.

We will use the JwtSecurityTokenHandler and the JwtSecurityToken utilities to forge a correct JWT token.

private static string GetJwtToken()
{
    var token = new JwtSecurityToken(
        issuer: "http://localhost",
        audience: "http://localhost",
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: null,
        claims:
        [
            new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.Email, "test@example.com")
        ]
    );

    return new JwtSecurityTokenHandler().WriteToken(token);
}

The utility method above will generate a JWT token that, once decoded looks like

// HEADER
{
  "alg": "none",
  "typ": "JWT"
}

// PAYLOAD
{
  "sub": "cbc54525-ce42-4715-a5f2-bdb0d3d0526e",
  "email": "test@example.com",
  "exp": 1754046366,
  "iss": "http://localhost",
  "aud": "http://localhost"
}

Now, we can change the test as follows

[Test]
public async Task GetMe_Should_ReturnUserEmail()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", GetJwtToken());

    var response = await client.GetAsync("/me");

    Assert.Multiple(async () =>
    {
        Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));

        var content = await response.Content.ReadAsStringAsync();

        var result = JsonSerializer.Deserialize<JsonElement>(content);
        Assert.That(result.TryGetProperty("email", out var email), Is.True);
        Assert.That(email.GetString(), Is.EqualTo("test@example.com"));
    });
}

Unfortunately, the test will still fail because the token has an invalid issuer and audience.

The application checks more than just the token format: it also validates the issuer, the audience, and whether the token is signed. Since we’re skipping all of that for now, the test still fails with a 401.

Disabling token validation

To finally convince our app to accept the forged JWT, we need to disable the validation.

The authentication handler allows us to stop validating things like:

  • Whether the token is signed
  • Whether the signature is valid
  • Whether the issuer or audience match expected values
  • Whether the token is expired

Obviously, we don’t want to change the actual app’s code. We want to make sure that only the test requests are accepted without validation.

Let’s modify our test setup to reconfigure the JWT bearer options. We do this using PostConfigure<JwtBearerOptions>, which ensures our configuration is applied after the app’s default setup logic. If we used Configure<JwtBearerOptions> instead, our changes might get silently overridden by the framework or app’s internal configuration.

[SetUp]
public void Setup()
{
    _factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
        builder.ConfigureServices((context, services) =>
        {
            services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters.ValidateIssuerSigningKey = false;
                options.TokenValidationParameters.ValidateIssuer = false;
                options.TokenValidationParameters.ValidateAudience = false;
                options.TokenValidationParameters.ValidateLifetime = false;
                options.TokenValidationParameters.RequireSignedTokens = false;
            });
        });
    });
}

With this configuration in place, the application accepts our forged tokens during tests, without requiring a valid signature or matching issuer.

This change is scoped to the test setup only: the production configuration remains untouched and secure.

Recap

In this post, we looked at how to write integration tests for an ASP.NET Core web application that uses JWT authentication. We started by building a simple Minimal API with a protected endpoint, then verified that unauthenticated and fake requests were correctly rejected.

To bypass JWT validation during tests, we reconfigured the existing authentication handler using PostConfigure<JwtBearerOptions>. This allowed us to skip issuer, audience, lifetime, and signature checks without modifying the application code itself.

Here is the final version of our test fixture:

[TestFixture]
public class GetMeTests
{
    private WebApplicationFactory<Program> _factory;

    [SetUp]
    public void Setup()
    {
        _factory = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices((context, services) =>
            {
                services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
                {
                    options.TokenValidationParameters.ValidateIssuerSigningKey = false;
                    options.TokenValidationParameters.ValidateIssuer = false;
                    options.TokenValidationParameters.ValidateAudience = false;
                    options.TokenValidationParameters.ValidateLifetime = false;
                    options.TokenValidationParameters.RequireSignedTokens = false;
                });
            });
        });
    }

    [Test]
    public async Task GetMe_Should_ReturnOk()
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", GetJwtToken());

        var response = await client.GetAsync("/me");

        Assert.Multiple(async () =>
        {
            Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));

            var content = await response.Content.ReadAsStringAsync();

            var result = JsonSerializer.Deserialize<JsonElement>(content);
            Assert.That(result.TryGetProperty("email", out var email), Is.True);
            Assert.That(email.GetString(), Is.EqualTo("test@example.com"));
        });
    }

    [TearDown]
    public void TearDown()
    {
        _factory?.Dispose();
    }

    private static string GetJwtToken()
    {
        var token = new JwtSecurityToken(
            issuer: "http://localhost",
            audience: "http://localhost",
            expires: DateTime.UtcNow.AddHours(1),
            signingCredentials: null,
            claims:
            [
                new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
                new Claim(JwtRegisteredClaimNames.Email, "test@example.com")
            ]
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

This approach lets us keep authentication in place while testing real endpoints with meaningful claims. It avoids mocking or bypassing the authentication system entirely, and allows us to focus on verifying application behavior under realistic conditions.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!