← Back to Blog

Debugging a JWT That Validated Locally But Failed in Production

Same token format, same signing algorithm, same code — and every request in Production returned 401 while Development worked perfectly. The difference was three characters in one config value.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 15 Jul 2026· Last reviewed Jul 2026· 7 min read · About the author →
Senior .NET engineer working across ASP.NET Core APIs, EF Core, and Azure deployments.
Key takeaways
  • ASP.NET Core's JWT bearer middleware compares the token's iss claim against ValidIssuer using an exact, case-sensitive string match by default
  • A trailing slash, an http vs https scheme, or a stale local IdP URL in one environment's config is enough to fail validation while the token itself is completely valid
  • SecurityTokenInvalidIssuerException and SecurityTokenInvalidAudienceException are the two exceptions that fire for this class of mismatch — both surface as a generic 401 to the client
  • OnAuthenticationFailed in JwtBearerEvents is the one place the real exception message is visible without attaching a debugger
  • Decoding the token and reading its actual iss/aud claims, then diffing them against each environment's TokenValidationParameters, finds this class of bug in minutes instead of hours
Table of Contents

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.

Try the free tool

JWT Decoder

Decode a JWT's header and payload instantly and compare its iss, aud, and exp claims directly against your TokenValidationParameters configuration.

Open JWT Decoder