JWT troubleshooting · Updated October 24, 2026

JWT Token Decode: Why a Large Integer Changes After JSON.parse

A JWT payload can decode correctly while its large integer claim changes during JSON parsing. In JavaScript, JSON.parse('{"account_id":9007199254740993}') produces 9007199254740992. Preserve the raw payload text. For an identifier you control, agree on a decimal-string representation with the producer and consumer; converting an already-rounded Number to BigInt cannot restore the lost digit. Decoding is not signature verification.

The bytes survived; the displayed number did not

The failure happens at a different boundary from broken Base64url or invalid UTF-8. Our fixture starts with exact ASCII JSON text, encodes that text to a Base64url payload segment and decodes it back. A byte-for-byte text comparison passes. Only then does the ordinary JavaScript JSON parser create a Number and lose precision for some inputs.

The existing JWT decoder parses the decoded payload with JSON.parse. Its object display shares this numeric boundary. Returning to the encoded segment can recover the original text; copying only the parsed object cannot. This test uses a private claim called account_id so it does not suggest changing the required type of a registered JWT claim.

The raw JSON integer 9007199254740993 survives Base64url, then JSON.parse rounds it to 9007199254740992.
Transport round-trip success and exact numeric parsing are separate checks.

Seven fixed inputs, compared in Node and Python

For each row, Node recovered the original JSON text from Base64url, parsed account_id, checked the result against an explicit expectation and recorded Number.isSafeInteger. Python independently parsed the same raw text with its standard JSON decoder; these integer literals become Python integers without this Number rounding. The quoted control becomes a string in both runtimes. These are synthetic payload segments, not accepted tokens or a benchmark of every JWT library.

Raw JSONNode parsed valuePython parsed valueNode typeSafe integer?
{"account_id":9007199254740991}90071992547409919007199254740991numbertrue
{"account_id":9007199254740992}90071992547409929007199254740992numberfalse
{"account_id":9007199254740993}90071992547409929007199254740993numberfalse
{"account_id":9007199254740994}90071992547409949007199254740994numberfalse
{"account_id":"9007199254740993"}90071992547409939007199254740993stringn/a
{"account_id":-9007199254740993}-9007199254740992-9007199254740993numberfalse
{"account_id":2000000000}20000000002000000000numbertrue

The second and third rows collide: different original account IDs become equal Numbers. The fourth row is a useful control. Its value happens to survive unchanged, yet it remains outside the safe-integer range. An unsafe flag does not mean every value at that magnitude has already changed; it means integer-level exactness and comparison are no longer generally safe. Rejecting unsafe numeric IDs is a policy, not a reconstruction technique.

The negative fixture makes the same failure visible below zero. The final row is a smaller numeric control, not a timestamp-validity check. None of these rows evaluates expiry or authorizes an account.

Run the complete regression test

Download the seven-fixture Node script and its expected output. It uses Node's built-in assert and Buffer modules, needs no package installation and makes no network requests. Save the file and run node jwt-integer-precision.cjs. Each output line includes the raw text, parsed value, type, safety flag and reserialized JSON; the final PASS line appears only after the collision and late-BigInt assertions also succeed.

This shorter example isolates the failure and the string control:

const assert = require('node:assert/strict');
const raw = '{"account_id":9007199254740993}';
const segment = Buffer.from(raw).toString('base64url');
const text = Buffer.from(segment, 'base64url').toString('utf8');
assert.equal(text, raw);
const parsed = JSON.parse(text);
assert.equal(String(parsed.account_id), '9007199254740992');
assert.equal(BigInt(parsed.account_id).toString(), '9007199254740992');
const quoted = JSON.parse('{"account_id":"9007199254740993"}');
assert.equal(BigInt(quoted.account_id).toString(), '9007199254740993');
console.log('raw text intact; Number rounded; quoted ID intact');

Why converting to BigInt afterwards fails

BigInt receives the value that exists when you call it. If JSON.parse has already rounded the Number, BigInt faithfully converts the rounded integer. Calling String on that Number has the same limitation. Neither operation consults the original payload text.

A normal reviver that only examines its numeric value argument has also arrived after conversion. Some runtimes expose additional source-text information to revivers; do not assume that support is present across your deployed environments. For a migration, test the exact parser and runtime you ship. A source-aware parser can be appropriate, but a regular expression that rewrites numbers in arbitrary JSON is not a substitute for parsing strings, escapes and nested values correctly.

Make an identifier contract before changing the producer

First, capture a synthetic failing payload and preserve its original text beside the parsed display. Check both the exact literal and the downstream lookup key. Second, find the producer's documented type for that private identifier. Third, agree on a string contract if both sides can change; include whether leading zeros are significant and whether arithmetic is ever allowed. Fourth, run the quoted and numeric boundary fixtures through the actual consumer before deploying.

Do not silently turn every claim into a string. Registered claims have their own types: RFC 7519 defines NumericDate for time claims and defines sub as a string. An account identifier is not an expiration time. A type change can break a consumer even when it prevents this particular rounding.

If the producer cannot change, keep the original signed token intact and choose a parser that preserves the required numeric semantics, or explicitly reject inputs your consumer cannot represent safely. Rewriting the decoded JSON and re-encoding it changes the signed input. This article's string fixture represents an alternative producer contract, not an instruction to edit someone else's signed token.

Check the failure you actually have

A changed integer is not the same issue as a disappearing member. If the payload repeats a claim name, use the duplicate-claims fixtures instead. If a token is embedded in a query parameter, keep URL decoding separate from Base64url and JSON handling. None of those transport fixes repairs a rounded Number after parsing.

Keep the exact raw fixture, the runtime result and the consumer decision together in your regression test. Our seven rows prove the stated Node/Python parsing difference for these inputs. They do not establish a vulnerability in a deployed service, and they do not replace signature, issuer, audience or time validation.

References: JavaScript safe-integer range and RFC 7519 terminology and registered claims. The seven-input cross-runtime experiment and downloadable checks were run on October 24, 2026.