Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix JWT validation in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Configure JwtBearer's TokenValidationParameters with the issuer's signing key from configuration (or options.Authority), ValidAlgorithms = [RS256], ValidIssuer, ValidAudience and a ClockSkew of seconds, leaving ValidateIssuer, ValidateAudience, ValidateLifetime, RequireExpirationTime and RequireSignedTokens true. Never replace SignatureValidator or set the Validate* flags to false to silence IDX errors.

Stack
  • Language C# 14
  • Runtime .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
  • Framework ASP.NET Core 10 minimal API with JWT bearer authentication
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 32 KB
On this page

Steps

  1. Find every TokenValidationParameters: SignatureValidator delegates, ValidateIssuer, ValidateAudience, ValidateLifetime, RequireExpirationTime or RequireSignedTokens set to false, AudienceValidator or LifetimeValidator delegates that return true, and code that reads claims with ReadJsonWebToken() or ReadJwtToken(), which do not validate.
  2. Register AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...) and call RequireAuthorization() on every protected endpoint; JwtBearer answers failures with 401 and WWW-Authenticate: Bearer error="invalid_token".
  3. With an OpenID Connect provider, set options.Authority to the issuer and options.Audience to this API: JwtBearer reads the signing keys from the discovery document, adds the metadata issuer to ValidIssuers, and copies Audience into ValidAudience. With a fixed key, as here, set IssuerSigningKey, ValidIssuer and ValidAudience yourself.
  4. Set ValidAlgorithms = [SecurityAlgorithms.RsaSha256] (left null, any algorithm the key supports is accepted) and ClockSkew = TimeSpan.FromSeconds(30) (the default is five minutes).
  5. Keep ValidateIssuer, ValidateAudience, ValidateLifetime, RequireExpirationTime and RequireSignedTokens at their default of true. This card spells them out so that switching one off shows up in review.
  6. Turn on CA5404 (dotnet build -p:AnalysisModeSecurity=All, or dotnet_diagnostic.CA5404.severity in .editorconfig) so that Validate* flags set to false break the build or at least warn.
  7. Write regression tests that send real signed tokens through WebApplicationFactory, as this card's ExploitTests do: alg none, HS256 signed with the public key, an attacker's key, an edited payload, expired tokens, no exp, another API's aud and another tenant's iss.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/Accounts/impl/VulnerableTokenValidation.csC#
public static void Configure(JwtBearerOptions options, IssuerSettings issuer)
{
    // "The API gateway checks tokens, we only need the user ID."
    options.TokenValidationParameters = new TokenValidationParameters
    {
        SignatureValidator = (token, _) => new JsonWebToken(token), // accepts any signature, or none
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = false,
    };
}

With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).

The fix

Fixedsrc/Accounts/impl/FixedTokenValidation.csC#
public static void Configure(JwtBearerOptions options, IssuerSettings issuer)
{
    // The key comes from configuration, never from the token. With an identity provider that
    // rotates keys, set options.Authority to the issuer instead of IssuerSigningKey, and
    // JwtBearer reads the signing keys and the issuer from its discovery document.
    options.TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = issuer.SigningKey,
        ValidAlgorithms = [SecurityAlgorithms.RsaSha256], // the issuer's algorithm only
        ValidIssuer = issuer.Issuer, // "iss" must equal our issuer
        ValidAudience = issuer.Audience, // "aud" must include this API
        ClockSkew = TimeSpan.FromSeconds(30), // the default is 5 minutes
        // The defaults, spelled out so that switching one off stands out in review.
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        RequireExpirationTime = true,
        RequireSignedTokens = true,
    };
}

With IMPL=fixed, 14 of 14 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Accounts/impl/WrongTokenValidation.csC#
public static void Configure(JwtBearerOptions options, IssuerSettings issuer)
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = issuer.SigningKey, // the signature is checked with the right key...
        ValidateIssuer = false, // ...but "iss" is not,
        ValidateAudience = false, // "aud" is not, and ClockSkew stays at 5 minutes
    };
}

With IMPL=wrong, 3 of 14 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Accounts.Tests/ExploitTests.csC#
/// <summary>Exploit: every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.</summary>
[Collection(AccountsApiCollection.Name)]
public sealed class ExploitTests(AccountsApiFactory factory) : AccountsApiTests(factory)
{
    private async Task AssertRejected(string token)
    {
        var (status, _, body) = await GetAccount(token);
        Assert.True(status == HttpStatusCode.Unauthorized, $"the token was accepted: HTTP {(int)status} {body}");
        Assert.DoesNotContain("alice@example.test", body);
    }

    [Fact]
    public Task AnUnsignedTokenAlgNoneIsRejected() =>
        AssertRejected(TestTokens.Unsecured(new Dictionary<string, object> { ["alg"] = "none", ["typ"] = "JWT" }, ClaimsFor("alice")));

    [Fact]
    public Task AnHs256TokenWhoseHmacSecretIsThePublicKeyIsRejected()
    {
        // The public key is public. A verifier that lets the token pick HS256 and passes it the
        // RSA public key would check this HMAC with the very bytes the attacker used.
        var header = new Dictionary<string, object> { ["alg"] = "HS256", ["typ"] = "JWT", ["kid"] = Fixture.KeyId };
        var publicKeyAsSecret = Encoding.ASCII.GetBytes(Fixture.IssuerKeys.PublicKeyPem);
        return AssertRejected(TestTokens.SignHs256(header, ClaimsFor("alice"), publicKeyAsSecret));
    }

    [Fact]
    public Task ATokenSignedWithTheAttackersOwnKeyEmbeddedInItsJwkHeaderIsRejected()
    {
        var header = new Dictionary<string, object>(Header) { ["jwk"] = Fixture.AttackerKeys.PublicJwk };
        return AssertRejected(TestTokens.SignRs256(header, ClaimsFor("alice"), Fixture.AttackerKeys.Key));
    }

    [Fact]
    public Task MallorysTokenWithItsPayloadChangedToAlicesIsRejected() =>
        AssertRejected(TestTokens.WithPayload(Issue(ClaimsFor("mallory")), ClaimsFor("alice")));

    [Fact]
    public Task ATokenThatExpiredAnHourAgoIsRejected()
    {
        var t = Now();
        return AssertRejected(Issue(ClaimsFor("alice", new() { ["iat"] = t - 3900, ["exp"] = t - 3600 })));
    }

    [Fact]
    public Task ATokenThatExpiredTwoMinutesAgoIsRejectedBeyondThe30SecondTolerance()
    {
        var t = Now();
        return AssertRejected(Issue(ClaimsFor("alice", new() { ["iat"] = t - 420, ["exp"] = t - 120 })));
    }

    [Fact]
    public Task ATokenWithoutExpIsRejected()
    {
        var neverExpires = ClaimsFor("alice");
        neverExpires.Remove("exp");
        return AssertRejected(Issue(neverExpires));
    }

    [Fact]
    public Task ATokenTheIdentityProviderIssuedForAnotherApiAudIsRejected() =>
        AssertRejected(Issue(ClaimsFor("alice", new() { ["aud"] = Fixture.OtherAudience })));

    [Fact]
    public Task ATokenFromAnotherTenantThatSharesTheSigningKeyIssIsRejected() =>
        AssertRejected(Issue(ClaimsFor("alice", new() { ["iss"] = Fixture.OtherIssuer })));
}

Pitfalls

  • ValidateIssuer = false and ValidateAudience = false are the usual answers to IDX10204 and IDX10208 (no ValidIssuer or ValidAudience configured). The signature is still checked, so everything works with the real identity provider, while tokens it issued for other APIs or tenants are accepted too.
  • ClockSkew defaults to five minutes (TokenValidationParameters.DefaultClockSkew), so a token is accepted for five minutes after its exp. This card's wrong implementation accepts a token that expired two minutes ago.
  • A SignatureValidator delegate replaces signature validation entirely; returning new JsonWebToken(token), a workaround often suggested for IDX10500, accepts unsigned and forged tokens. Since .NET 8 JwtBearer validates with JsonWebTokenHandler by default, and such a delegate must return a JsonWebToken (IDX10506 otherwise).
  • ValidateIssuerSigningKey = false does not switch signature validation off, and true does not switch it on: it only controls validation of the signing key itself, for example a certificate carried in the token. It defaults to false.
  • Microsoft Entra ID's tenant-independent keys sign tokens for every tenant. Microsoft's guidance for multitenant APIs is to check iss against the token's tid and to key user data by tenant and subject; a signature check alone admits every tenant.
  • MapInboundClaims defaults to true and renames sub to the ClaimTypes.NameIdentifier URI. This card sets it to false and reads sub; code that looks for sub after the rename finds nothing.
  • On macOS, RSA.Create(2048) generates the key on first use without a lock, so two test threads that touch a shared test key at once can end up with two different keys and random 401s; this card's tests export the key once, up front, and share one host.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs jwt-validation/csharp-aspnetcore does this for you).
  2. The nine exploit tests build tokens byte by byte with System.Security.Cryptography, all claiming to be Alice: alg none, HS256 signed with the public key, the attacker's key in a jwk header, Mallory's token with an edited payload, expired an hour ago, expired two minutes ago, no exp, another API's aud and another tenant's iss. Each must get 401 without Alice's data. With IMPL=fixed, the WWW-Authenticate error_description names the reason (invalid issuer or audience, expired, no expiration, invalid signature or signature key not found).
  3. Against your own API, replay a token the identity provider issued for a different API and one whose payload you edited, and expect 401 with WWW-Authenticate: Bearer error="invalid_token". Run CA5404, but keep the tests: no analyzer or Semgrep rule checked here sees a SignatureValidator delegate or the five-minute ClockSkew.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix JWT validation in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp.lang.security.ad.jwt-tokenvalidationparameters-no-expiry-validation.jwt-tokenvalidationparameters-no-expiry-validation1 finding: ValidateLifetime = false in VulnerableTokenValidation.cs. Nothing in WrongTokenValidation.cs: the rule looks only at ValidateLifetime and RequireExpirationTime, not at ValidateIssuer, ValidateAudience or the default five-minute ClockSkew.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/csharp.lang.security.ad.jwt-tokenvalidationparameters-no-expiry-validation.jwt-tokenvalidationparameters-no-expiry-validation <file> on each impl file and Program.cs
Semgrepcsharp.lang.security.cryptography.unsigned-security-token.unsigned-security-tokenNo findings. The rule flags RequireSignedTokens = false inside new TokenValidationParameters { ... } (a probe file was flagged); VulnerableTokenValidation.cs switches signature checking off with a SignatureValidator delegate instead, which no rule checked here recognises.Checked: Semgrep 1.180.0 (CE)
CodeQLcs/json-webtoken-handler/security-validations-disabledFrom the query and JsonWebTokenHandlerLib.qll: reports ValidateIssuer, ValidateAudience, ValidateLifetime, RequireExpirationTime or RequireAudience set to false, so it should flag VulnerableTokenValidation.cs and WrongTokenValidation.cs. It is experimental and its library is marked deprecated, so the standard suites do not run it.Checked: github/codeql main at b5b8165; not executed
CodeQLcs/json-webtoken-handler/delegated-security-validations-always-return-trueFrom the query source: reports validation delegates, SignatureValidator among them, that always return true. This card's SignatureValidator returns new JsonWebToken(token), not true, so it is not expected to be reported.Checked: github/codeql main at b5b8165; not executed
.NET code analysisCA5404Do not disable token validation checks5 warnings: ValidateIssuer, ValidateAudience and ValidateLifetime set to false in VulnerableTokenValidation.cs, and ValidateIssuer and ValidateAudience set to false in WrongTokenValidation.cs; none in FixedTokenValidation.cs. The rule is off by default in .NET 10. It does not look at SignatureValidator delegates or ClockSkew.Checked: .NET SDK 10.0.107, dotnet build src/Accounts/Accounts.csproj --no-incremental -p:AnalysisModeSecurity=All
.NET code analysisCA5405Do not always skip token validation in delegatesNo warnings: the rule covers AudienceValidator and LifetimeValidator delegates that always return true, and this card uses neither.Checked: Same build

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
FrameworkASP.NET Core 10 minimal API with JWT bearer authentication
Microsoft.AspNetCore.Authentication.JwtBearer10.0.12
Microsoft.IdentityModel.JsonWebTokens and Microsoft.IdentityModel.Tokens (via JwtBearer)8.19.2
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Accounts.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs jwt-validation/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 32 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • jwtBearer 10.0.12
  • identityModel 8.19.2
  • mvcTesting 10.0.12
  • xunitV3 4.0.1
  • microsoftTestingPlatform 2.4.0
Matrix
fixed: 14 of 14 tests pass; vulnerable: 9 of 14 tests fail (9 exploit tests); wrong: 3 of 14 tests fail (3 exploit tests)
Test results
fixed 14/14 pass; vulnerable 9/14 fail (all 9 exploit tests); wrong 3/14 fail (3 exploit tests: another API's aud, another tenant's iss, a token that expired two minutes ago), offline after install
Dependency freshness
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.12 and Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) are the current 10.0.x patches. JwtBearer 10.0.12 asks for Microsoft.IdentityModel.Protocols.OpenIdConnect 8.19.2 or later, and NuGet takes 8.19.2 (2026-07-14); 8.23.0 is newer but OSV lists no advisories for 8.19.2. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), a bug-fix release under 14 days old. dotnet list package --vulnerable --include-transitive found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch. Earlier runs with one host per test class failed at random with 401s: both classes touched the shared test key at once, and on macOS RSA.Create(2048) generates the key lazily without a lock (dotnet/runtime release/10.0, RSASecurityTransforms.GetKeys), so two keys were made. The tests now generate and export the key once and share one host; 8 consecutive runs per mode then gave identical results.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Configure JWT bearer authentication in ASP.NET Core (.NET 10)
  2. TokenValidationParameters class (ValidAlgorithms, ClockSkew, ValidateIssuerSigningKey, SignatureValidator)
  3. Microsoft identity platform: access tokens (validate the issuer, multitenant applications, signing key issuer)
  4. CA5404: Do not disable token validation checks
  5. CA5405: Do not always skip token validation in delegates
  6. RFC 8725: JSON Web Token Best Current Practices (2.1 weak signatures, 3.1 algorithm verification, 3.8 issuer, 3.9 audience)
  7. OWASP JSON Web Token Cheat Sheet
  8. RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (section 4, validating access tokens)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.