Repository: colinhacks/zod
Pull request #4404 (2025-05-18 22:14:46)

[v3] Allow missing `type` in JWT


Patch:
diff --git a/packages/zod/src/v3/types.ts b/packages/zod/src/v3/types.ts
index c0d1129b..3669bc0e 100644
--- a/packages/zod/src/v3/types.ts
+++ b/packages/zod/src/v3/types.ts
@@ -706,7 +706,8 @@ function isValidJWT(jwt: string, alg?: string): boolean {
       .padEnd(header.length + ((4 - (header.length % 4)) % 4), "=");
     const decoded = JSON.parse(atob(base64));
     if (typeof decoded !== "object" || decoded === null) return false;
-    if (!decoded.typ || !decoded.alg) return false;
+    if ("typ" in decoded && decoded?.typ !== "JWT") return false;
+    if (!decoded.alg) return false;
     if (alg && decoded.alg !== alg) return false;
     return true;
   } catch {
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 9c3e3fc0..8f0eda26 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -943,6 +943,7 @@ export function isValidJWT(token: string, algorithm: util.JWTAlgorithm | null =
     const [header] = tokensParts;
     const parsedHeader = JSON.parse(atob(header));
     if ("typ" in parsedHeader && parsedHeader?.typ !== "JWT") return false;
+    if (!parsedHeader.alg) return false;
     if (algorithm && (!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)) return false;
     return true;
   } catch {


Test patch:
diff --git a/packages/zod/src/v3/tests/string.test.ts b/packages/zod/src/v3/tests/string.test.ts
index a2ec41fe..c91ac53b 100644
--- a/packages/zod/src/v3/tests/string.test.ts
+++ b/packages/zod/src/v3/tests/string.test.ts
@@ -247,33 +247,41 @@ for (const str of invalidBase64URLStrings) {
   });
 }
 
+function makeJwt(header: object, payload: object) {
+  const headerBase64 = Buffer.from(JSON.stringify(header)).toString("base64url");
+  const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
+  const signature = "signature"; // Placeholder for the signature
+  return `${headerBase64}.${payloadBase64}.${signature}`;
+}
+
 test("jwt validations", () => {
   const jwt = z.string().jwt();
   const jwtWithAlg = z.string().jwt({ alg: "HS256" });
 
-  // Valid JWTs
-  const validHeader = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url");
-  const validPayload = Buffer.from("{}").toString("base64url");
-  const validSignature = "signature";
-  const validJWT = `${validHeader}.${validPayload}.${validSignature}`;
-
-  expect(() => jwt.parse(validJWT)).not.toThrow();
-  expect(() => jwtWithAlg.parse(validJWT)).not.toThrow();
-
-  // Invalid format
   expect(() => jwt.parse("invalid")).toThrow();
   expect(() => jwt.parse("invalid.invalid")).toThrow();
   expect(() => jwt.parse("invalid.invalid.invalid")).toThrow();
 
+  // Valid JWTs
+  const d1 = makeJwt({ typ: "JWT", alg: "HS256" }, {});
+  expect(() => jwt.parse(d1)).not.toThrow();
+  expect(() => jwtWithAlg.parse(d1)).not.toThrow();
+
   // Invalid header
-  const invalidHeader = Buffer.from("{}").toString("base64url");
-  const invalidHeaderJWT = `${invalidHeader}.${validPayload}.${validSignature}`;
-  expect(() => jwt.parse(invalidHeaderJWT)).toThrow();
+  const d2 = makeJwt({}, {});
+  expect(() => jwt.parse(d2)).toThrow();
 
   // Wrong algorithm
-  const wrongAlgHeader = Buffer.from(JSON.stringify({ typ: "JWT", alg: "RS256" })).toString("base64url");
-  const wrongAlgJWT = `${wrongAlgHeader}.${validPayload}.${validSignature}`;
-  expect(() => jwtWithAlg.parse(wrongAlgJWT)).toThrow();
+  const d3 = makeJwt({ typ: "JWT", alg: "RS256" }, {});
+  expect(() => jwtWithAlg.parse(d3)).toThrow();
+
+  // missing typ is fine
+  const d4 = makeJwt({ alg: "HS256" }, {});
+  jwt.parse(d4);
+
+  // type isn't JWT
+  const d5 = makeJwt({ typ: "SUP", alg: "HS256" }, { foo: "bar" });
+  expect(() => jwt.parse(d5)).toThrow();
 
   // Custom error message
   const customMsg = "Invalid JWT token";
diff --git a/packages/zod/src/v4/classic/tests/string.test.ts b/packages/zod/src/v4/classic/tests/string.test.ts
index 9d7fb775..94f3c944 100644
--- a/packages/zod/src/v4/classic/tests/string.test.ts
+++ b/packages/zod/src/v4/classic/tests/string.test.ts
@@ -264,35 +264,69 @@ test("big base64 and base64url", () => {
   z.base64url().parse(bigbase64url);
 });
 
+function makeJwt(header: object, payload: object) {
+  const headerBase64 = Buffer.from(JSON.stringify(header)).toString("base64url");
+  const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
+  const signature = "signature"; // Placeholder for the signature
+  return `${headerBase64}.${payloadBase64}.${signature}`;
+}
+
 test("jwt token", () => {
-  const ONE_PART = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
-  const NOT_BASE64 =
-    "headerIsNotBase64Encoded.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
-  const NO_TYP =
-    "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.GuoUe6tw79bJlbU1HU0ADX0pr0u2kf3r_4OdrDufSfQ";
-  const TYP_NOT_JWT =
-    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpUVyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
-
-  const GOOD_JWT_HS256 =
-    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
-  const GOOD_JWT_ES256 =
-    "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA";
-
-  const jwtSchema = z.string().jwt();
-
-  expect(() => jwtSchema.parse(ONE_PART)).toThrow();
-  expect(() => jwtSchema.parse(NOT_BASE64)).toThrow();
-  expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
-  expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
-  expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
-  expect(() => z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_HS256)).toThrow();
-  expect(() => z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_ES256)).toThrow();
-  //Success
-  jwtSchema.parse(NO_TYP); // allow no typ
-  expect(() => jwtSchema.parse(GOOD_JWT_HS256)).not.toThrow();
-  expect(() => jwtSchema.parse(GOOD_JWT_ES256)).not.toThrow();
-  expect(() => z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_HS256)).not.toThrow();
-  expect(() => z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_ES256)).not.toThrow();
+  const jwt = z.string().jwt();
+  expect(() => jwt.parse("invalid")).toThrow();
+  expect(() => jwt.parse("invalid.invalid")).toThrow();
+  expect(() => jwt.parse("invalid.invalid.invalid")).toThrow();
+
+  // Valid JWTs
+  const es256jwt = z.string().jwt({ alg: "ES256" });
+  const d1 = makeJwt({ typ: "JWT", alg: "ES256" }, {});
+  jwt.parse(d1);
+  es256jwt.parse(d1);
+
+  // Invalid header
+  const d2 = makeJwt({}, {});
+  expect(() => jwt.parse(d2)).toThrow();
+
+  // Wrong algorithm
+  const d3 = makeJwt({ typ: "JWT", alg: "RS256" }, {});
+  expect(() => es256jwt.parse(d3)).toThrow();
+
+  // missing typ is fine
+  const d4 = makeJwt({ alg: "HS256" }, {});
+  jwt.parse(d4);
+
+  // type isn't JWT
+  const d5 = makeJwt({ typ: "SUP", alg: "HS256" }, { foo: "bar" });
+  expect(() => jwt.parse(d5)).toThrow();
+
+  // const ONE_PART = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
+  // const NOT_BASE64 =
+  //   "headerIsNotBase64Encoded.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
+  // const NO_TYP =
+  //   "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.GuoUe6tw79bJlbU1HU0ADX0pr0u2kf3r_4OdrDufSfQ";
+  // const TYP_NOT_JWT =
+  //   "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpUVyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
+
+  // const GOOD_JWT_HS256 =
+  //   "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
+  // const GOOD_JWT_ES256 =
+  //   "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA";
+
+  // const jwtSchema = z.string().jwt();
+
+  // expect(() => jwtSchema.parse(ONE_PART)).toThrow();
+  // expect(() => jwtSchema.parse(NOT_BASE64)).toThrow();
+  // expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
+  // expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
+  // expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
+  // expect(() => z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_HS256)).toThrow();
+  // expect(() => z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_ES256)).toThrow();
+  // //Success
+  // jwtSchema.parse(NO_TYP); // allow no typ
+  // expect(() => jwtSchema.parse(GOOD_JWT_HS256)).not.toThrow();
+  // expect(() => jwtSchema.parse(GOOD_JWT_ES256)).not.toThrow();
+  // expect(() => z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_HS256)).not.toThrow();
+  // expect(() => z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_ES256)).not.toThrow();
 });
 
 test("url validations", () => {