Compatibility
Published
A JSON Web Token is a string your build receives and never writes. Somewhere inside it is a small JSON object whose key names -- sub, exp, scope, roles -- were chosen by a specification and are emitted by an identity server that has no idea your bundle exists. Reading them is ordinary property access, which puts them in the one category member renaming rewrites, and the failure modes are not evenly distributed: some throw on the first call and one of them quietly stops rejecting tokens that have expired.
What was measured
A single sample holding a token authored elsewhere and pasted in verbatim as a string constant. It decodes the payload, reads the standard claims, compares the expiry against a fixed timestamp, checks a role, splits the scope string, builds a session object using the application's own field names, serialises part of that session for an outgoing request, and reads the header. A second, long-expired token goes through the same expiry check so that the direction of failure is measured rather than inferred.
The first version of this sample built its own token by encoding an object literal a few lines above the code that decoded it. Under renaming, both the literal and the reads were rewritten together, so almost every arm reported no difference. That is a true statement about a file talking to itself and a useless one about tokens, because the defining property of a token is that somebody else wrote it. The token in the measured version is an opaque string; nothing in the build can keep a renamed read consistent with it.
The base column is clean. Default target, modern target, gate profile, modern gate profile and string transforms all produced output identical to the unprotected original. Decoding base64, parsing JSON, comparing numbers and splitting strings are not affected by protection, and the string transforms moved the token literal without altering a byte of it.
The expiry check stops rejecting expired tokens
This is the result worth the article. With a pattern matching exp alone, the long-expired token's own claim read stale-exp=undefined instead of its real value, the expiry comparison went from stale-expired=true to stale-expired=false, and the line that acts on it went from accepted=reject to accepted=accept. Nothing was thrown anywhere in the run.
The mechanism is arithmetic rather than anything exotic. The comparison in the source is claims.exp < now. After renaming, claims.exp is undefined, and any relational comparison involving undefined is false. So the branch that means "this token is too old" is never taken, for every token, including ones that expired years ago. The check does not report an error; it reports that everything is fine.
It is worth being precise about what this does and does not mean, because this site's position on client-side security has not changed. A client-side expiry check is a user-experience affordance, not an access control: the server validates the token on every request and will reject an expired one regardless of what the client believed. What this failure produces is a client that keeps presenting a dead token, never triggers its own refresh path, and surfaces a wall of authorisation errors from the API instead of a quiet re-authentication. That is a real and confusing bug, and it is not a hole in your authentication.
The claim that vanishes from the outgoing payload
In the same arm, the session object the sample sends onward changed shape. The original serialised as {"sub":"u-42","exp":1755300000}. The protected build produced {"sub":"u-42"}. The exp key is not renamed in the output, and it is not set to null. It is gone.
That happens because two ordinary rules compose. The renamed key holds undefined, since the claim it was copied from could not be found, and JSON.stringify omits properties whose value is undefined rather than emitting them. So the wire format loses a field entirely, and a receiver that treats a missing expiry as "no expiry supplied" will read it very differently from one that treats it as an error.
This is the quietest way a boundary can change. A renamed key that ships as _0x1 is at least visible in a network trace; a key that disappears looks like the sender simply chose not to include it.
The loud arms, and why they are the good outcome
Widening the pattern to sub, exp and scope together produced claims=undefined|undefined|undefined on the first line, and then a hard stop: TypeError: Cannot read properties of undefined (reading 'split'), thrown by the line that splits the scope string into a list. A pattern matching roles failed the same way, with TypeError: Cannot read properties of undefined (reading 'indexOf') on the role check.
Both are the merciful failures. Any claim you call a method on -- splitting a scope string, indexing into a role array, parsing a date out of a timestamp -- turns a renamed read into an immediate exception on the first authenticated request, in development, long before a release. The dangerous claims are the ones you only ever compare: an expiry compared with a number, a boolean-ish flag, an issuer compared with a string. Those absorb undefined and keep going.
The header arm sits in the same family. A pattern matching alg and typ left the run healthy and printed header=undefined/undefined. A build that inspects the header before trusting a token -- the common example being a check that rejects tokens whose algorithm is not the expected one -- is then comparing undefined against its expected value on every token, so the comparison no longer distinguishes anything. The check still runs. It just has nothing to look at.
Your own session object is yours
The clean arm is as informative as the failures. A pattern matching the application's own session field names -- userId, expiresAt, grants -- produced output identical to the original on both targets. Those names are written and read entirely inside the build, so renaming rewrote both halves and nothing outside ever needed to agree.
That is the boundary the whole series keeps landing on, and here it runs straight through the middle of one small function. On one side of the assignment are claims.sub and claims.exp, names chosen by a specification and emitted by a server. On the other side are session.userId and session.expiresAt, names chosen by whoever wrote the file. Same statement, same object literal, opposite exposure -- and copying the claims into your own names as early as possible is a genuinely useful habit, because it shrinks the number of lines where the external names appear at all.
One arm is recorded here as inconclusive rather than as a pass. A pattern matching iat reported no difference, but the sample never reads that claim as a member, so nothing in the file was renamed at all. The measurement harness flags an arm that renamed nothing, which is the only reason this is a footnote instead of a fifth clean result.
What this means in practice
In the default configuration there is nothing to do. Token handling was identical on all five profiles, and no arm of the base column moved.
With member renaming on, the standard claim names are exactly the kind of short, generic, externally owned identifiers that a broad pattern sweeps up by accident: sub, exp, iat, iss, aud, scope, jti. Put them in your reserved list, or -- better -- write a member pattern anchored to names distinctive to your own application so they are never candidates in the first place.
To check a build you already have, decode a token in the protected artifact and print the claims you rely on before anything branches on them. If any of them print as undefined while the same token decodes correctly in a debugger, the read was renamed. Do the expiry check explicitly with a token you know is expired, because that is the one arm where the failure is a wrong answer rather than an error, and it is also the one where the wrong answer means "keep going".
And when you write the test, use a real token from your identity server rather than one your test file builds a few lines earlier. A locally built token makes the file consistent with itself and hides every finding in this article.
Frequently asked questions
Does obfuscation break JWT handling?
Not in the default configuration. Decoding a token, reading its claims, checking expiry, splitting a scope string and building a session object all produced identical output across five protection profiles. Token handling only becomes a surface when member renaming is switched on, because the claim names are then ordinary property reads that can be rewritten.
Why does my token expiry check stop working after protection?
Because a renamed exp read produces undefined, and a relational comparison against undefined is false. In the measured arm a long-expired token went from rejected to accepted with nothing thrown: the branch meaning this token is too old is simply never taken. The comparison still runs and still returns an answer, and the answer is always that the token is current.
Is my authentication less secure if a claim gets renamed?
The server still validates the token on every request and still rejects an expired or insufficient one, so this is not a way past your access control. What it produces is a client that keeps presenting a dead token, never triggers its own refresh path, and surfaces authorisation errors from the API instead of quietly re-authenticating.
Why did a claim disappear from my outgoing request body?
Two ordinary rules compose. The renamed key holds undefined because the claim it was copied from could not be found, and JSON.stringify omits properties whose value is undefined. In the measured arm an outgoing payload went from carrying two fields to carrying one, with no renamed key visible in the JSON at all.
Which claims fail loudly and which fail quietly?
Any claim you call a method on fails loudly and immediately: splitting a scope string threw a TypeError on reading split, and a role check threw on reading indexOf. Claims you only compare absorb undefined and keep going, which covers expiry timestamps, issuer strings and boolean-ish flags. The measured header arm printed undefined for both algorithm and type without throwing.
Are the field names in my own session object affected?
No. A pattern matching the application's own session field names produced identical output on both targets, because those names are written and read entirely inside the build. Copying claims into your own names early is a useful habit for this reason: it reduces the number of lines where the externally owned names appear.
How should I test a protected build for this?
Use a real token from your identity server rather than one the test file builds a few lines earlier, because a locally built token is renamed on both sides and hides every finding here. Print the claims you rely on before anything branches on them, and run the expiry check explicitly with a token you know is expired.
Related reading