DEV Community

Anaya Upadhyay
Anaya Upadhyay

Posted on

JWT Auth in .NET 8: The Validation Settings People Skip

There is a category of security bug that produces no error, no log entry, and no failing test. A token that should have been rejected gets a 200 instead, and the only way anyone finds out is by reading the auth configuration line by line, usually after something has already gone wrong.

Most of these live inside one class: TokenValidationParameters. Its defaults are good. The trouble is that its defaults are also easy to turn off, and the moments when people turn them off tend to be the moments they are trying to make an error message go away.

This article walks the five settings that get skipped most, what each one actually checks, what turning it off silently allows, and what the correct .NET 8 configuration looks like. At the end there is a checklist you can run against your own codebase in about two minutes.

The baseline

Here is a complete, correct JWT bearer setup for a .NET 8 minimal API. Everything below refers back to this.

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = "https://auth.example.com",

            ValidateAudience = true,
            ValidAudience = "orders-api",

            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(1),
            RequireExpirationTime = true,

            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

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

app.MapGet("/orders", () => Results.Ok(new[] { "order-1", "order-2" }))
   .RequireAuthorization();

app.Run();
Enter fullscreen mode Exit fullscreen mode

One note before the flags: if you point the handler at an identity provider via options.Authority, the issuer and signing keys come from OIDC discovery metadata and much of this configures itself. The bugs below live almost entirely in hand-rolled setups, which is exactly where copied snippets end up.

1. ValidateLifetime

Default: true. Lifetime validation checks the token's nbf (not before) and exp (expires) claims against the current UTC time.

The bug is not the default. The bug is this line, which shows up in copied configuration with remarkable consistency:

ValidateLifetime = false
Enter fullscreen mode Exit fullscreen mode

It usually gets added to silence a lifetime validation error during local testing, often when someone's test token has expired and regenerating it feels like friction. The error goes away. The line stays. It ships.

From that point on, an expired token validates. The signature still checks out, so nothing looks wrong from the outside: the request carries a structurally valid, correctly signed token whose expiry date has simply stopped mattering. Picture a leaked token from six months ago. Your API would still honor it.

The fix is deletion. The default is already true, so the safest version of this configuration is the one with less code in it. If a flag has to be false for a test to pass, that belongs in the test project's configuration, not in Program.cs.

2. ClockSkew

Default: five minutes. TokenValidationParameters.DefaultClockSkew is TimeSpan.FromMinutes(5) in the Microsoft.IdentityModel reference documentation, and it applies as tolerance on both ends of the lifetime check: a token is accepted when nbf - skew <= now <= exp + skew.

This one is not a flag someone flipped. It is a default nobody tuned, and it means every token in your system is accepted for five minutes past its printed expiry.

For hour-long tokens, that grace window is noise. For short-lived access tokens the math changes: picture a five minute access token, which now validates for close to ten. Whatever reasoning went into choosing that five minute lifetime, the effective lifetime is quietly double it.

ClockSkew = TimeSpan.FromMinutes(1)
Enter fullscreen mode Exit fullscreen mode

The temptation is to set it to TimeSpan.Zero, and some teams do. Before you follow them, remember what the skew is for: real machines drift, and a token minted by an identity server whose clock runs twenty seconds ahead of yours would be rejected as "not yet valid" with zero tolerance. Size the skew to your infrastructure and your token lifetime rather than deleting it on principle.

3. ValidateIssuer

Default: true. Issuer validation checks the token's iss claim against ValidIssuer or ValidIssuers.

Here is how it gets disabled in the wild. Someone wires up validation, runs the app, and gets an IDX10204 or IDX10205 error from the issuer check, typically because ValidIssuer was never set or does not match what the identity server actually mints. The fastest way to make that exception disappear is:

ValidateIssuer = false
Enter fullscreen mode Exit fullscreen mode

And it does disappear. Along with the question "who minted this token?"

With issuer validation off, a token signed with any key your API accepts will pass, regardless of where it came from. In a system with one API and one symmetric key that may sound theoretical. In a system where keys get shared across environments, or where a staging identity server signs with the same key as production, it stops being theoretical fast.

The exception was the security feature. Fix the configuration instead of the symptom:

ValidateIssuer = true,
ValidIssuer = "https://auth.example.com"
Enter fullscreen mode Exit fullscreen mode

4. ValidateAudience

Default: true. Audience validation checks the token's aud claim against ValidAudience or ValidAudiences, answering the question "was this token minted for me, specifically?"

Same disable story as the issuer, different error code: an IDX10208 or IDX10214 fires because no audience was configured, and ValidateAudience = false makes it stop. What it also does is make every API in your system interchangeable from a token's point of view.

Picture two services, billing-api and orders-api, validating with the same signing key. A token minted for billing carries aud: "billing-api". With audience validation off, orders accepts it too. One leaked or over-scoped token now opens both doors, and any future service that joins the key club gets opened by it as well.

ValidateAudience = true,
ValidAudience = "orders-api"
Enter fullscreen mode Exit fullscreen mode

One audience per API. The scope of a token should mean something.

5. RequireExpirationTime

Default: true. This is the flag behind the lifetime flag, and it covers a case most people have not considered: what happens when a token has no exp claim at all?

Lifetime validation checks the expiry when one is present. RequireExpirationTime is what makes its presence mandatory. Set it to false and a token minted without an expiry sails through lifetime validation, because there is nothing to evaluate. That token has no end date. It outlives the incident review, the key rotation discussion, and possibly the service itself.

There is close to no legitimate reason for an access token without an expiry, so this default deserves to stay exactly where it is:

RequireExpirationTime = true
Enter fullscreen mode Exit fullscreen mode

Two more worth knowing

RequireSignedTokens defaults to true and rejects unsigned tokens. Leave it. An API that accepts alg: none tokens is an API with optional authentication.

ValidateIssuerSigningKey defaults to false, and this one surprises people in the other direction: signatures are verified regardless. What this flag adds is validation of the key material itself, for example rejecting a token signed with an X.509 certificate that has expired. Worth turning on when your signing keys are certificates with real lifetimes.

Why the flags don't cover for each other

Validation is a chain of independent gates: signature, issuer, audience, lifetime. Each one answers a different question. Signature asks whether the token is intact. Issuer asks who minted it. Audience asks whether it was minted for you. Lifetime asks whether it is still alive.

A yes to one answers none of the others, which is why "the signature checks out" is such a dangerous form of reassurance. Disable a gate and it is not weakened, it is gone, and the three remaining gates will keep passing tokens that the fourth would have stopped. No exception fires when a check is skipped. Skipped checks are silent by definition.

The checklist

Run this against every TokenValidationParameters in your codebase:

  • ValidateLifetime stays true. Delete the override rather than negotiating with it.
  • ClockSkew sized to your token lifetime, not left at the five minute default for short-lived tokens.
  • ValidIssuer named. Tokens from anywhere are not a feature.
  • ValidAudience set per API. One token, one door.
  • RequireExpirationTime stays true. Every token gets an end date.
  • Bonus: RequireSignedTokens untouched, ValidateIssuerSigningKey considered if your keys are certificates. Then the two minute audit: grep your solution for = false inside any TokenValidationParameters initializer and make each hit justify itself with a comment naming who approved it and why. In my experience most of them can't.

If you prefer this material as visual breakdowns, I publish them as carousels and short reels at @thesharpfuture on Instagram, aimed at junior to mid-level .NET developers. This week's set covers exactly these five flags.

Found one of these switched off in a real codebase? I'd genuinely like to hear which one, and what the comment next to it said, if there was one.

Top comments (0)