The incident
Here's a common way this shows up: a service that authenticates fine in Development suddenly returns 401 for every request once deployed to Production, even though the token, the signing key, and the client code are unchanged. The response body gives no detail beyond an empty 401, and the natural first instinct is to re-check the token itself — decoding it shows a well-formed JWT with a valid signature and a sensible expiry, which rules out the obvious culprits and leaves the actual cause, a mismatched ValidIssuer value, undiscovered for longer than it should take.
Why "the token looks valid" isn't the same as "the token validates"
A JWT's header and payload are just Base64URL-encoded JSON — decoding them proves nothing about whether the token will pass validation, because decoding doesn't check the signature, the expiry, the audience, or the issuer. Those four checks are a separate step, performed by TokenValidationParameters inside the JWT bearer middleware, entirely independent of whether the token is well-formed. A token can decode perfectly and still fail every one of those checks.
The specific mismatch: ValidIssuer vs the token's iss claim
ASP.NET Core's JWT bearer handler validates the issuer with an ordinal, case-sensitive string comparison between the token's iss claim and the configured ValidIssuer — by default, no normalization, no trailing-slash tolerance:
// appsettings.Development.json
{
"Jwt": { "Issuer": "https://localhost:5001", "Audience": "devtoolshub-dev" }
}
// appsettings.Production.json — note the difference
{
"Jwt": { "Issuer": "https://api.devtoolshub.info/", "Audience": "devtoolshub-api" }
}
// The actual token's "iss" claim, as issued by the real identity provider in production:
// { "iss": "https://api.devtoolshub.info", "aud": "devtoolshub-api", ... }
// ^ no trailing slash — but ValidIssuer above has one.
// "https://api.devtoolshub.info" != "https://api.devtoolshub.info/" as an ordinal string
// comparison, so validation fails even though every other part of the token is correct.
This is the same class of bug regardless of which specific string differs — a scheme mismatch (http vs https), a stale value left over from an earlier IdP migration, or a copy-paste that dropped or added a trailing slash. The token itself was issued correctly; the environment's expected value just doesn't match it byte for byte.
What actually happens at the middleware level
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = ctx =>
{
// Without this, the client just sees a 401 with no detail.
// ctx.Exception.Message here is what actually reveals which check failed:
// "IDX10205: Issuer validation failed. Issuer: 'https://api.devtoolshub.info'.
// Did not match: validationParameters.ValidIssuer: 'https://api.devtoolshub.info/'..."
logger.LogWarning("JWT validation failed: {Message}", ctx.Exception.Message);
return Task.CompletedTask;
}
};
});
// Without OnAuthenticationFailed logging this, the failure is invisible —
// the request just receives a 401 with a generic WWW-Authenticate header,
// and nothing in the response body indicates which claim caused it.
The exception thrown internally is SecurityTokenInvalidIssuerException (or SecurityTokenInvalidAudienceException for the equivalent audience mismatch) — both are caught by the middleware itself and converted into a 401 before they ever reach application code, which is exactly why this class of bug is invisible without explicitly wiring up OnAuthenticationFailed to log the underlying exception.
Why local development doesn't catch it
Development's ValidIssuer was set up once, correctly, to match the local identity provider's actual issuer string — and then never touched again, because it kept working. Production's value was set at the same time, based on what the production IdP's URL was expected to be, but nothing re-validates that assumption if the production IdP's actual issuer string changes, or if it was simply transcribed with a small difference from the start. There's no build-time or deploy-time check that a configured ValidIssuer actually matches what tokens from the real IdP will contain — the only way to find out is to receive a real token and watch it fail.
Finding it in minutes instead of hours
Decode a token straight from a failing production request using the JWT Decoder and read the literal iss and aud values from the payload. Compare those two strings, character for character, against the ValidIssuer/ValidAudience values actually deployed to that environment (not what you assume they are — pull the real running configuration). A byte-for-byte diff surfaces a trailing slash or scheme mismatch immediately, in a way that staring at the exception message's truncated string rarely does. This is a different failure mode from the clock-skew postmortem covered here — that one is a timing problem between servers; this one is a static string mismatch that's wrong the same way on every single request, which is actually the easier of the two to fix once you've found it. For the full structure of what a JWT contains and how each part is validated, see JWT Tokens Explained.