Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-rename-that-silently-unbound-a-signing-key && read --section="top" 0%
.NET

The Rename That Silently Unbound a Signing Key

Of eight JWT keys in the README's flagship example, four bind to nothing, three restate defaults and one has any effect - and the last edit to that README came ten minutes before the commit that invalidated it.

By Kumar Chandrachooda 25 Feb 2026 8 min read
An old key and a new one facing three padlocks, only one of which has changed shape

Every token your gateway receives is rejected. The signing key is right there in the configuration file, spelled correctly, matching the issuer exactly. Nothing in the logs mentions the key. Nothing at startup mentions the key. The configuration binder read your file, found a property it did not recognise, and discarded it — quietly, by design, because that is what configuration binders do.

Part 6 watched a feature die in four days because a broker refused it. This part watches a feature die in silence because nothing refused it at all.

Ten minutes on a Friday evening

On 20 December 2019 the author of Ntrada made two commits, ten minutes apart, and never touched the repository's documentation again.

At 20:37, 5016fdb “Core 3.1 update, load balancer url fix” moved every project to netcoreapp3.1, moved the container from port 5000 to port 80, and changed exactly one line of README.md — a docker run port mapping. That single line is the last edit any README in this repository ever received.

At 20:47, bb6df80 “JWT update” rewrote the JWT extension's options class:

-        public string Key { get; set; }
-        public string Issuer { get; set; }
-        public bool ValidateIssuer { get; set; }
-        public IEnumerable<string> Issuers { get; set; }
+        public string IssuerSigningKey { get; set; }
+        public string Authority { get; set; }
         public string Audience { get; set; }
-        public IEnumerable<string> Audiences { get; set; }
-        public bool ValidateAudience { get; set; }
-        public bool ValidateLifetime { get; set; }
+        public string Challenge { get; set; } = "Bearer";
+        public string MetadataAddress { get; set; }
+        public string ValidAudience { get; set; }
+        public IEnumerable<string> ValidAudiences { get; set; }
+        public string ValidIssuer { get; set; }
+        public IEnumerable<string> ValidIssuers { get; set; }

git show bb6df80 -- extensions/Ntrada.Extensions.Jwt/JwtOptions.cs, trimmed

The refactor was right. It took a hand-picked subset of six JWT settings and replaced it with a near-total, property-for-property mirror of Microsoft's TokenValidationParameters and JwtBearerOptions — twenty-six options, every one of them nameable from YAML with zero mapping code. That is a genuinely good library decision and it is why this extension can configure anything the framework can.

The same commit updated extensions\Ntrada.Extensions.Jwt\jwt.yml, the fragment that documents the extension, to use the new names. It did not update samples\Ntrada.Samples.Api\ntrada.yml, and it did not update README.md — which had been edited ten minutes earlier, by the same person, in the same session.

Eight keys, one of which does anything

The README's “Advanced configuration” block and the sample gateway's own YAML carry the identical JWT stanza:

  jwt:
    key: <sample HMAC key, committed>
    issuer: ntrada
    issuers:
    validateIssuer: true
    audience:
    audiences:
    validateAudience: false
    validateLifetime: true

README.md:151-159, and samples\Ntrada.Samples.Api\ntrada.yml:49-57

Against JwtOptions as it exists at HEAD:

YAML key Binds to Effect
key no Key property dead
issuer no Issuer property dead
issuers no Issuers property dead
validateIssuer: true ValidateIssuer binds, and restates the = true default
audience Audience binds, to null, which is the default
audiences no Audiences property dead
validateAudience: false ValidateAudience the only key in the block that changes anything
validateLifetime: true ValidateLifetime binds, and restates the = true default

Four bind to nothing, three restate existing defaults, and one has an effect. The README's advertised JWT configuration is, functionally, the single line validateAudience: false.

Follow that into the runtime and it gets worse. options.IssuerSigningKey is null, so the guard in JwtExtension never fires:

if (!string.IsNullOrWhiteSpace(options.IssuerSigningKey))
{
    tokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(options.IssuerSigningKey));
}

extensions\Ntrada.Extensions.Jwt\JwtExtension.cs:44-48

Authority and MetadataAddress are null too — the YAML never mentions them — so the bearer handler has no discovery endpoint to fetch keys from either. And RequireSignedTokens defaults to true. The result is a JWT bearer handler with no signing key by any route, which rejects every token ever presented to it.

The gateway's authentication is not misconfigured. It is inert.

Why nobody noticed for a year

Because nothing in this repository ever authenticates. auth: enabled: true is set, but global: false, and AuthenticationManager short-circuits to success unless the route opts in:

if (_options.Auth is null || !_options.Auth.Enabled || _options.Auth?.Global != true &&
    routeConfig.Route?.Auth != true)
{
    return true;
}

src\Ntrada\Auth\AuthenticationManager.cs:20-24

No route in the sample config sets auth: true. The RabbitMQ route explicitly sets auth: false; the others say nothing. And Ntrada.Samples.Api.rest, the executable request collection sitting beside that config, contains six requests and not one Authorization header.

So the estate's flagship example enables JWT, ships a signing key, wires the extension, logs Authentication is enabled. at startup, authenticates no route, and its own test collection never sends a token. Every path by which the broken configuration could have been discovered is closed by a different, independently reasonable decision.

And it had already happened once. 67e72bc “Updated JWT config” (2019-09-21) renamed validate_issuer/validate_audience/validate_lifetime to camelCase — in jwt.yml only. Before that commit, the repository's own JWT sample had lifetime validation, issuer validation and audience validation all silently unbound, because snake_case does not bind to PascalCase and nothing says so. Two successive silent breakages of the same extension's configuration contract, three months apart, both corrected in one file and not the others.

Your options class is a public API. Your configuration binder does not enforce it. Your only defence is a hand-maintained YAML file that nothing in the build checks.

The mechanism is IConfiguration.Bind, which discards unmatched keys without comment. There is no BinderOptions.ErrorOnUnknownConfiguration anywhere in this estate, no ValidateOnStart — that landed in .NET 6, well after the last commit — and no schema. The rename was a breaking change to a public configuration contract with zero compile errors, zero runtime errors, zero warnings and zero log lines. Three lines further up the same sample file, auth: claims: binds to nothing either, because Configuration.Auth has exactly Enabled, Global and Policies. Dead keys in the first five lines and dead keys in the fiftieth.

There is a real irony worth naming. This gateway ships NJsonSchema and uses it to validate user request payloads against declared schemas. The one document it never validates is its own configuration file.

The key that is committed, and what its presence teaches

That <sample HMAC key, committed> in the table above is a real value in the repository, in three files: the JWT extension's own jwt.yml, samples\Ntrada.Samples.Api\ntrada.yml:50, and README.md:152 — on the project's front page. I am not going to reproduce it, and the reason is not that it is dangerous.

The concrete risk is nil. It is a sample key for a sample gateway that authenticates nothing, and in two of the three places it does not even bind. Nothing was leaked and nothing was bypassed; it was committed deliberately, as documentation.

The pedagogical risk is the finding. A reader of a teaching repository learns the shape of a solution, and the shape on offer here is: the signing key is a plain string, in the same YAML document as your routes, checked into git, and shown that way on the front page. There is no ${JWT_KEY} interpolation syntax in this configuration language, no keyFile: alternative, no comment saying “replace this”, and no mention anywhere of environment overrides.

And the standard workaround is foreclosed by the estate's own configuration precedence. Both hosts add the YAML file inside their own ConfigureAppConfiguration callback, which runs after Host.CreateDefaultBuilder's defaults. So the layering is appsettings.json → environment variables → command line → then ntrada.yml. The gateway's primary configuration source sits at the bottom of the override stack, which means an environment variable cannot override a key the YAML file defines. The one escape hatch every deployer reaches for first does not work here, and nothing says so.

The cost of fixing that in 2019 was one comment line, one paragraph, and registering the YAML provider before the environment-variable provider.

The twenty-seventh knob

One value in this extension is hard-coded, and it is the only one:

ClockSkew = TimeSpan.Zero

extensions\Ntrada.Extensions.Jwt\JwtExtension.cs:36

Twenty-six properties on JwtOptions, every one of them settable from YAML, and this one is a literal. TokenValidationParameters.DefaultClockSkew is five minutes; zero means exp and nbf are enforced to the second against the gateway's own clock.

The security argument is real. A five-minute grace period extends the useful life of a stolen or revoked token by five minutes, and for the sixty-second tokens common in service-to-service traffic it makes expiry almost meaningless. For the one component that should be strict, zero is arguably correct.

The operational argument is equally real. The gateway now depends on NTP for correctness. A container whose clock drifts two seconds ahead of the token issuer rejects every freshly-minted token on the nbf check, because the token is not yet valid in the gateway's future. Clock drift stops being a degradation and becomes a total authentication outage — and the symptom inverts with the sign of the drift, so the same fault reads as “tokens expire early” or “tokens are not yet valid” depending on which way you are wrong. It is invisible: no log line, no health signal, no configuration key an operator debugging the outage would ever read. And it cannot be relaxed during an incident, because every other validation behaviour in this extension can be turned off from YAML and this one cannot.

Judged fairly, hard-coding ClockSkew = TimeSpan.Zero was a widely-recommended pattern in .NET writing of the period, precisely because the five-minute default surprised people. Commit a21533e “JWT clock skew” (2019-10-23) is a three-line diff adding this and a using. The author hit the surprise and applied the standard fix. The value is right; the hard-coding is the defect — and it is a defect the extension model actively encourages, because there was no cost to adding a twenty-seventh option and no mechanism anywhere that would have flagged the twenty-sixth as special.

Next, the version number that was deleted rather than carried across.