Authentication
Published
A token verification policy is the object you hand a library alongside the token: which issuer you trust, which audience the token must name, which algorithms you accept, which claims must be present, how old the token may be. Every key in it is read by the library. We renamed them one at a time and counted how many bad tokens got in.
The setup, and how this differs from reading claims
This article is not about the claims your code reads out of a verified token; that failure is a different one and has its own page. This is about the policy object you pass in, which decides whether verification succeeds at all. The two live a line apart and fail in opposite directions.
The sample uses jose 5.10, installed in this repository, and six tokens minted in a separate process and pasted in as opaque literals so that nothing in the file writes what it later reads. One token is valid for this service. The others carry another issuer, another audience, an issue date two months old, no subject claim, and a different signing algorithm.
The policy pins an issuer, an audience, a single-algorithm allowlist, two required claims, a maximum token age and a five-second clock tolerance. Every one of those is a departure from jose's defaults, which check no issuer, no audience, no subject, impose no age limit, and accept any algorithm the key supports. In the unprotected run exactly one token was admitted and five were refused.
Five names, five tokens that should not have been admitted
Renaming issuer admitted the token from another tenant's issuer. Renaming audience admitted a token minted for a different service in the same estate. Renaming algorithms admitted a token signed with HS384 under a rule that allows only HS256. Renaming requiredClaims admitted a token with no subject. Renaming maxTokenAge admitted a token issued two months earlier.
In every case the signature check still ran and still passed, which is what makes this so quiet. The library is not broken and the cryptography is not bypassed. The token is genuinely signed with the key you configured. What disappeared is the set of questions asked after the signature check, and each of those questions was represented by one property name.
Renaming the whole policy at once, which is what a pattern scoped to that object does, took the admitted count from one to six. Every token that should have been refused was accepted, and the endpoint returned a normal successful response for all of them.
What each admission actually costs
These are not equivalent failures. Losing the issuer check means any token signed by a key you trust for any purpose is accepted, which in a multi-tenant estate is the entire estate. Losing the audience check means a token issued for the reporting service is accepted by the orders service, so a read-only scope becomes a write scope by changing which door it knocks on.
Losing the algorithm allowlist is the one with the longest history of published incidents, because an allowlist is the standard defence against a token that names an algorithm you did not intend to support. Losing the required-claims check admits a token with no subject, so every downstream audit record attributes the action to nobody.
Losing the maximum age accepts a token minted two months ago. That is the failure that survives an incident response: rotating a key stops old tokens, but the whole point of a short maximum age is to limit the window when nobody knows a token leaked.
The arm that reported nothing, and why we are saying so
One name in the policy produced no change: clockTolerance. Our sample has no token sitting within a few seconds of an expiry boundary, so the tolerance never decides anything, and an arm that never exercises the operation a name governs is blind rather than clean.
That distinction has cost this series real time, so it gets stated on the page rather than buried. Three different reasons produce an unchanged result and they are indistinguishable from the result alone: the name is genuinely owned on both sides, the value you passed happens to equal the library's default, or the measurement never ran the operation. Only the first is evidence of safety.
The honest reading of the tolerance arm is that we do not know from this measurement whether it matters, and the safe action is the same regardless: put it in the exclusion list with the rest of the policy keys.
The half that fails loudly, one line later
For contrast, we renamed the names on the other side of the call: the payload object jose returns and the subject and scope claims read out of it. That failed immediately with a TypeError and the admitted count dropped to zero.
So the same file contains both directions. Renaming the policy you send in weakens the check silently and admits more traffic. Renaming the result you read back breaks the request loudly and admits none. Nobody is going to ship the second one, and that is precisely why it is not the dangerous one.
This is the clearest illustration in the series of a rule worth remembering: whether a renamed name fails loudly is decided by which side of the contract it sits on, not by how important it is. The most security-critical names in this file are the quietest ones.
Why the tests you have will not catch it
Authentication test suites are built around a valid token and an invalid signature, because those are the cases that matter when the code is written. Very few include a token that is correctly signed but wrong in some other way, since the library is trusted to handle that and there is nothing about your own code to test.
Those are exactly the cases that change here. A suite with only good-token and bad-signature tests passes completely in a build where the entire policy has been renamed away, because both of its cases still behave correctly.
The fix is a fixture set, not a framework. Mint five bad tokens once, keep them as literals in the test file, and assert that each is refused. They never expire if you set a far-future expiry and rely on the other claims to be wrong, they need no network, and they run in milliseconds.
What to exclude, and where to put the check
The exclusion list is the policy object's own keys: issuer, audience, algorithms, requiredClaims, maxTokenAge, clockTolerance, and subject if you use it. Add the claim names you read from the verified payload if any of your reads use dot access.
Run the fixture assertions against the protected artifact rather than the source. That is the general rule this series keeps arriving at, and for authentication it is not optional: the only build whose verification behaviour matters is the one you deploy.
None of this is a reason to leave an authentication service unprotected. Member renaming is off by default and scoped by a pattern you write. A pattern that matches only names your own code both writes and reads produces none of the results above, and the ten seconds it takes to add an exclusion is the whole mitigation.
Frequently asked questions
Does obfuscation weaken JWT verification?
Protection alone does not; all five presets reproduced our sample's output exactly. Member renaming does, if the pattern matches the keys of the verification policy object. Renaming the whole policy took the number of admitted tokens from one to six.
Is the signature check still performed?
Yes, and it still passes. That is what makes this quiet. Every admitted token is genuinely signed with the configured key. What disappears is the set of checks made after the signature: issuer, audience, algorithm allowlist, required claims and maximum age.
What is the difference between this and reading token claims?
The policy is what you pass in; the claims are what you read back. Renaming a policy key silently admits traffic that should have been refused. Renaming a claim read fails loudly with a TypeError one line later, which is why it is the less dangerous of the two.
Which single rename is worst?
It depends on your estate. Losing the audience check lets a token minted for one service be used against another. Losing the algorithm allowlist removes the standard defence against a token naming an algorithm you did not intend to accept. Losing the maximum age accepts tokens long after they should have aged out.
Why did the clock tolerance option show no change?
Because our sample has no token near an expiry boundary, so the tolerance never decided anything. That is a blind arm, not a clean one. An unchanged result can also mean the value equalled the library default, so it is not evidence that a name is safe to rename.
What test would have caught this?
A fixture set of correctly signed but otherwise wrong tokens: another issuer, another audience, an old issue date, a missing subject and a different algorithm. Assert each is refused, and run the assertions against the protected build rather than the source.
What should I exclude from member renaming?
The policy keys: issuer, audience, algorithms, requiredClaims, maxTokenAge, clockTolerance and subject, plus any claim names you read with dot access from the verified payload. Renaming is opt-in and pattern-scoped, so this is a configuration change rather than a decision about protecting the service.
Related reading