Skip to content

jwt.verify returns JSON-stringified payload (string) instead of parsed object — auth middleware breaks #927

Description

@proggeramlug

Repro (4 lines, no Fastify, no MySQL)

import jwt from "jsonwebtoken";
import { readFileSync } from "fs";
const PEM = readFileSync("/tmp/skelpo-jwt.pem", "utf8");
const TOK = jwt.sign({ sub: "u-001", acc: "a-001" }, PEM, { algorithm: "ES256", issuer: "z" });
const decoded = jwt.verify(TOK, PEM, { algorithms: ["ES256"], issuer: "z" }) as any;
console.log("typeof decoded:", typeof decoded);
console.log("decoded JSON:", JSON.stringify(decoded));
console.log("decoded.sub:", decoded.sub);

Observed (perry 0.5.958)

[jwt-verify] success, claims={"acc":"a-001","sub":"u-001"}     ← binding decoded correctly
typeof decoded: string                                          ← but returned as STRING
decoded JSON: "{\"acc\":\"a-001\",\"sub\":\"u-001\"}"           ← JSON-encoded-string-of-the-object
decoded.sub: undefined                                          ← property access yields undefined

Expected (tsx / node — same source)

typeof decoded: object
decoded JSON: {"sub":"u-001","acc":"a-001","iat":..., "iss":"z"}
decoded.sub: u-001

Per spec / jsonwebtoken README, jwt.verify returns the decoded payload as an object (it's the JwtPayload type). Perry's binding decodes correctly internally (the [jwt-verify] success log shows the right shape) but the value returned to user code is the JSON-stringified object, not the parsed one.

How this breaks shop-admin

server/crypto/jwt.ts::verifyAccessToken does:

const decoded = jwt.verify(token, loadKey(), { algorithms: [ALG], issuer: ISSUER }) as jwt.JwtPayload;
if (typeof decoded.sub !== "string" || typeof decoded["acc"] !== "string" || ...) {
  return null;
}
return { sub: decoded.sub, acc: decoded["acc"] as string, ... };

Under perry: decoded is a string. decoded.sub is undefined. typeof undefined !== "string" is true → returns null. The auth middleware then early-returns without setting req.user, every authenticated route sees req.user === undefined, and requireUser(req) throws — which on its own triggers a second perry bug (issue to be filed separately): throw new Error/ApiError in an async route handler crashes the process instead of being caught by Fastify.

This is the residual blocker for the shop-admin native server after #859 and #915 closed; signup itself now works end-to-end (HTTP 201 with full JWT JSON, all 5 tables populate) but the very first authenticated request fails because of this jwt.verify return-shape bug.

Pointer

Likely in the perry-stdlib jsonwebtoken binding. The fix presumably is to return the parsed payload object directly to the JS side rather than the JSON-encoded representation; the js_jsonwebtoken_verify (or whatever wrapper) needs to construct a JS object from the verified payload rather than handing back the JSON text.

jwt.sign works correctly (verified by my #915 minimal repro and the standalone working).

Files: this repro is short enough that nothing's committed to the user repo for it; happy to PR a test fixture into test-files/ if useful.

Local checkout: clean at perry HEAD (v0.5.958), no source edits.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions