jsonwebtoken: String Payload Parsing Inconsistency Leads to Auth Bypass
Overview #
The Node jsonwebtoken library behaves differently depending on whether you sign an object payload or a string payload. That difference can turn into an authentication/authorization bypass if an application assumes it is always working with an object and tries to “lock” fields (like role) by mutating the payload before signing.
This is primarily an application-level bug (failure to validate user-controlled types). However, jsonwebtoken can make the failure mode surprising: a payload signed as a string may be returned from verify() as an object, which means downstream code can observe a different type than the one that was originally signed.
The Inconsistency #
When signing an object, the token header includes typ: "JWT":
{
"alg": "HS256",
"typ": "JWT"
}
When signing a string, the token header typically omits typ:
{
"alg": "HS256"
}
In node-jws, payload handling is gated on header type, and this is handled sensibly:
https://github.com/auth0/node-jws/blob/master/lib/verify-stream.js#L70
However, in node-jsonwebtoken, decode() runs the payload through JSON.parse regardless of that check:
https://github.com/auth0/node-jsonwebtoken/blob/master/decode.js#L10
This makes it possible for a payload that was originally a string (e.g. "{\"role\":\"admin\"}") to be returned as an object ({ "role": "admin" }) during verification.
jsonwebtoken documentation even calls out the json option:
json: forceJSON.parseon the payload even if the header doesn’t contain"typ":"JWT".
But with the current behavior, it’s effectively as if that option is always true.
Why This Becomes a Bypass #
Many apps implement “set server-side fields then sign” like this:
// make sure new users have the "user" role
user.role = "user";
let token = jwt.sign(user, secret);
If user is actually a string, user.role = "user" is a no-op in JavaScript (it does not throw in typical Node/CommonJS execution). The application believes it has enforced the role, but it hasn’t changed anything.
If the attacker supplies a string that looks like JSON, and jsonwebtoken later returns it from verify() as an object, the attacker’s chosen fields become real object properties in req.user.
Proof of Concept App #
Full project:
https://gist.github.com/matt-/975fa0d5d4c424df56b3bd3d24372627
Hosted demo:
https://jwt.up.railway.app
Relevant vulnerable logic:
https://gist.github.com/matt-/975fa0d5d4c424df56b3bd3d24372627#file-index-js-L40
Expected (safe) request flow #
Register with an object:
curl --request POST \
--url https://jwt.up.railway.app/register \
--header 'content-type: application/json' \
--data '{"user": {"name": "matt","role": "asdf"}}'
The server assigns role = "user" before signing. Confirm via /me:
curl --request GET \
--url https://jwt.up.railway.app/me \
--header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibWF0dCIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjc1NDA0MTI0fQ.DXLyjm0l8qBd7Zgy_DPtz5bYItxsBIHFuM72T1dTaJE'
Response:
{"name":"matt","role":"user","iat":1675404124}
Bypass #
Register with a string that contains JSON:
curl --request POST \
--url https://jwt.up.railway.app/register \
--header 'content-type: application/json' \
--data '{"user": "{\"name\": \"matt\", \"role\": \"admin\"}"}'
Result:
{"name":"matt","role":"admin"}
At a high level:
- The server attempts
user.role = "user"(no-op becauseuseris a string) - The server signs the string
jsonwebtoken.verify()returns the payload as an object (due to forced parsing)- Downstream authorization checks now see
req.user.role === "admin"
Impact #
- AuthZ bypass in real apps: Any application that mutates/overrides fields on an assumed-object payload (e.g.
role,isAdmin,tenantId,scopes) before signing can be bypassed if it accepts attacker-controlled payload types. - Surprising failure mode: Even if the app logs/inspects types at signing time, the verified token payload may come back with a different type than expected.
This is best described as an application bug (missing validation). The library behavior is still worth understanding because it can make this class of bug easier to miss during review.
Disclosure / Response #
This was reported upstream and was not treated as a library vulnerability. The response was:
First of all, thanks for the submission. We have reviewed your report and in our estimation this seems to be an issue with the business logic, rather than a problem with the library. The POC code assumes that user is an object, however if user is a string, the assignment to the role property does not modify the String object.
Viewing the logic in the POC you supplied:
app.post("/register", async (req, res, next) => { let user = req.body.user; // The assignment to the **role** property does not modify the String object. user.role = "user"; // user.role has not been modified if user is a String let token = jwt.sign( user, secret ); res.send({token}) });The library signs the data that it receives, in this case a string containing "role": "admin", it is our view that developer in this case should validate the user input before calling sign. Please feel free to reach out to us, in case you have any further questions.
We appreciate your efforts in keeping Auth0 secure
Takeaways #
- If you intend to sign an object and then use object properties for authorization, enforce that the payload is an object before you mutate/sign it.
- Prefer constructing a new payload (server-controlled fields win) over mutating untrusted inputs in place.
Remediation #
Application-side (recommended) #
- Validate types: Reject non-objects for payloads you intend to mutate/sign.
if (typeof user !== "object" || user === null || Array.isArray(user)) {
return res.status(400).send({ error: "user must be an object" });
}
- Construct new objects: Don’t mutate untrusted inputs in place.
const payload = { name: user.name, role: "user" };
const token = jwt.sign(payload, secret);
Library-side (suggested improvement) #
jsonwebtoken should not unconditionally JSON.parse payloads returned by node-jws. It should respect the typ/json gating so that:
- string payloads remain strings unless the caller explicitly opts into forced JSON parsing
- behavior matches the documented intent of the
jsonoption