JWT troubleshooting · Updated October 23, 2026

JWT Token Decode: Why Duplicate Claims Disappear

A JWT decoder can display one claim even when the original payload contains it twice. For {"sub":"alpha","sub":"beta"}, JavaScript JSON.parse and Python's default JSON decoder retain beta. Keep the decoded JSON text before parsing, and use a duplicate-aware parser if your application requires rejection. Decoding and parsing do not verify the signature or authorize the claims.

The failure is after Base64url decoding

A compact signed JWT carries encoded JSON in its payload segment. Base64url decoding recovers that text; the next JSON parsing step turns it into an object. If you save only the object, the overwritten value is no longer available to the display. Adding URL escapes, switching Base64 alphabets, or editing padding cannot recover a member that the JSON parser already discarded.

Our existing JWT decoder uses JSON.parse to present payload objects. Its display therefore shows the final value for these repeated-name fixtures. This article examines that representation boundary rather than adding another generic token converter. Never paste production credentials into debugging examples or public issue reports; the examples below are synthetic.

Raw JSON has sub alpha and sub beta; default parsing retains beta while a duplicate-aware policy rejects the input.
Preserve the text before creating the object: the display cannot reconstruct discarded members.

Seven payloads, tested in two runtimes

We generated seven fixed JSON payloads, encoded each into a synthetic HS256 fixture, and checked its test HMAC separately. Python recovered the payloads, ran its default decoder and a rejecting object-pairs hook. Node independently checked the raw text, the HMAC and each JSON.parse result. The signing key is a public test constant, not a deployment secret. These fixtures demonstrate parser behavior, not acceptance by a real JWT library.

FixtureOriginal decoded JSONDefault parsed objectOur rejecting policy
unique{"sub":"alpha","exp":2000000000}{"sub":"alpha","exp":2000000000}accepted by this duplicate-key check
repeated subject{"sub":"alpha","sub":"beta"}{"sub":"beta"}duplicate key: sub
repeated expiry{"exp":100,"exp":2000000000}{"exp":2000000000}duplicate key: exp
escaped name{"sub":"alpha","\u0073ub":"beta"}{"sub":"beta"}duplicate key: sub
different case{"sub":"alpha","SUB":"beta"}{"sub":"alpha","SUB":"beta"}accepted by this duplicate-key check
nested repeat{"profile":{"label":"a","label":"b"}}{"profile":{"label":"b"}}duplicate key: label
separate objects{"left":{"label":"a"},"right":{"label":"b"}}{"left":{"label":"a"},"right":{"label":"b"}}accepted by this duplicate-key check

The escaped-name row is the important trap. The source spelling \u0073ub becomes sub during JSON string decoding. Searching the source for two literal occurrences of "sub" would miss it. The rejecting hook receives decoded names, so it detects the collision without trying to approximate JSON syntax using a regular expression.

The different-case row is a control: sub and SUB are different member names here. Neither our check nor the tested default parsers merge them. The separate-objects row is another control. A label under left and a label under right are not duplicates within one object.

Reproduce the disappearing claim

Run this Python standard-library example. It prints the recovered text, the default parsed object, and the failure from our explicit rejecting policy. The code intentionally stops before any trust decision:

import base64, json

def reject_repeated_names(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate key: " + key)
        result[key] = value
    return result

# Synthetic payload: two spellings of the same decoded name.
raw = r'{"sub":"alpha","\u0073ub":"beta"}'
segment = base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
text = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)).decode()
print(text)  # Preserve the original JSON before parsing.
print(json.loads(text))  # {'sub': 'beta'}
try:
    json.loads(text, object_pairs_hook=reject_repeated_names)
except ValueError as error:
    print(error)  # duplicate key: sub
# No signature verification or token acceptance occurs here.

A JSON reviver used with JavaScript's ordinary JSON.parse is too late to recover the original duplicate members: the parsed object has already lost them. Likewise, Python's ordinary object hook sees an object after member resolution. The object-pairs hook used above receives ordered name/value pairs before they are collapsed into a dictionary.

Run all seven fixtures yourself

Download the complete seven-fixture Python script, the raw fixture JSON, and the expected output. The script embeds all fixtures, needs only Python 3 and its standard library, and makes no network requests. Save it locally and run:

python jwt-duplicate-claims.py

It checks recovered raw text, the synthetic fixture HMAC, the default parsed object and our duplicate-rejection policy. A failed check stops execution. Matching this test HMAC checks fixture integrity only; it is not production token validation. Expand the expected output to compare all seven cases:

Expected output: seven fixtures
unique
  raw: {"sub":"alpha","exp":2000000000}
  default: {"sub":"alpha","exp":2000000000}
  policy: accepted by this duplicate-key check
repeated subject
  raw: {"sub":"alpha","sub":"beta"}
  default: {"sub":"beta"}
  policy: duplicate key: sub
repeated expiry
  raw: {"exp":100,"exp":2000000000}
  default: {"exp":2000000000}
  policy: duplicate key: exp
escaped name
  raw: {"sub":"alpha","\u0073ub":"beta"}
  default: {"sub":"beta"}
  policy: duplicate key: sub
different case
  raw: {"sub":"alpha","SUB":"beta"}
  default: {"sub":"alpha","SUB":"beta"}
  policy: accepted by this duplicate-key check
nested repeat
  raw: {"profile":{"label":"a","label":"b"}}
  default: {"profile":{"label":"b"}}
  policy: duplicate key: label
separate objects
  raw: {"left":{"label":"a"},"right":{"label":"b"}}
  default: {"left":{"label":"a"},"right":{"label":"b"}}
  policy: accepted by this duplicate-key check
PASS: 7 fixtures; raw payload, synthetic HMAC, default parse and duplicate policy match.
Fixture integrity only: issuer, audience, time and application authorization are not validated.

Do not confuse a chosen policy with the JWT standard

RFC 7519 section 4 requires unique claim names from producers and permits consumers to reject duplicates or retain the last member. Our example deliberately chooses rejection. It is not evidence that every last-member parser violates the JWT requirement, nor a claim that all JWT libraries behave identically. Check the actual library and configuration used by your application.

The hook is also stricter in scope than a top-level claim-name check: Python calls it for nested objects too. That is why the nested-repeat row is rejected even though profile occurs only once at the top level. If your application uses another policy for nested data, specify and test it separately. The table labels this behavior as our policy rather than attributing it to the RFC.

A migration regression test, not an authorization shortcut

Suppose an inspection service displays the first member it encounters while the consumer retains the last. That hypothetical disagreement can mislead an operator reviewing a token. Our table establishes the tested parsers' behavior; it does not prove a vulnerability exists in any deployed service. Build a controlled fixture in your own test environment before making that claim.

For a parser or JWT-library migration, preserve the original encoded token as a protected test input, record the decoded raw JSON, and compare the old and new acceptance results. Include both duplicate orderings, the escaped-name case, a clean payload and separate nested objects. Verify that a rejecting configuration fails before any application action is taken. Do not repair an incoming signed token by serializing the parsed object: changing the payload changes the signed input.

The expiry row is intentionally not a clock test. It records which exp value remains; it neither checks the current time nor decides whether a token is valid. Signature verification, algorithm restrictions, issuer, audience and temporal validation remain separate responsibilities of the application's JWT validation path. Even a matching HMAC on our synthetic fixtures says nothing about real issuer authorization.

Keep each boundary visible

If your token lives in a query parameter, separate URL handling with the URL decoder from JWT decoding. If text decoding fails before JSON parsing, see the invalid UTF-8 troubleshooting matrix; that is a different failure. For disappearing claim members, return to the original JSON text and the parser policy rather than altering transport encoding.

References: RFC 7519 section 4 and Python JSON repeated-name behavior. The seven-fixture matrix, escaped-name controls and cross-runtime checks were run for this page on October 23, 2026.