Skip to content

fix(backend): validate JWT issuer (iss) claim when an issuer option is provided#8772

Open
jacekradko wants to merge 1 commit into
mainfrom
jacek/backend-issuer-validation
Open

fix(backend): validate JWT issuer (iss) claim when an issuer option is provided#8772
jacekradko wants to merge 1 commit into
mainfrom
jacek/backend-issuer-validation

Conversation

@jacekradko
Copy link
Copy Markdown
Member

@jacekradko jacekradko commented Jun 8, 2026

verifyJwt never actually validated the iss claim. VerifyJwtOptions had no issuer field, the IssuerResolver type in jwt/assertions.ts was unused, and eleven tests in verifyJwt.test.ts passed an issuer: that nothing read, so they passed regardless. This wires it up.

assertIssuerClaim is the actual change, and it skips when no issuer is configured:

if (!issuer) return; // no issuer -> skip, same as audience/azp

Nobody passes issuer today (including the networkless jwtKey path and the machine/OAuth verifiers that share verifyJwt), so current behavior doesn't change, and verifyToken/authenticateRequest pick the option up through the existing options composition. The eleven inert tests now exercise the assertion, and I added the negatives that were missing: a mismatched string, a rejecting predicate, and a verifyToken({ issuer }) case to confirm the option reaches the public API. This does not turn issuer validation on by default in authenticateRequest; that's a separate change.

Summary by CodeRabbit

  • New Features

    • Token verification methods now support optional issuer validation via the issuer option. Provide a string for exact-match validation or a function for custom validation logic. When omitted, issuer validation is skipped entirely.
  • Bug Fixes

    • Fixed token verification to properly enforce issuer validation when the issuer option is provided, rather than ignoring it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 8, 2026

🦋 Changeset detected

Latest commit: 4602902

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@clerk/backend Patch
@clerk/astro Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jun 8, 2026 12:24am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 8, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds opt-in issuer claim validation to JWT verification in the Clerk backend. verifyToken() and verifyJwt() now accept an optional issuer parameter supporting exact-match strings or predicate functions to validate the JWT iss claim. A new TokenInvalidIssuer error reason reports validation failures.

Changes

JWT Issuer Validation

Layer / File(s) Summary
Error reason definition
packages/backend/src/errors.ts
TokenInvalidIssuer added to TokenVerificationErrorReason to enable issuer mismatch error reporting.
Issuer assertion validator
packages/backend/src/jwt/assertions.ts, packages/backend/src/jwt/verifyJwt.ts (imports)
assertIssuerClaim() function validates JWT iss claim against optional issuer parameter (string or predicate); skips validation when issuer is omitted and throws TokenVerificationError on mismatch.
VerifyJwt contract and integration
packages/backend/src/jwt/verifyJwt.ts
VerifyJwtOptions extended with optional issuer field; verifyJwt() destructures issuer and calls assertIssuerClaim() during payload verification after successful signature validation.
Assertion validator unit tests
packages/backend/src/jwt/__tests__/assertions.test.ts
Test suite for assertIssuerClaim() covering optional issuer, exact string match, predicate rejection, and error handling for missing/invalid iss claim.
Integration tests
packages/backend/src/jwt/__tests__/verifyJwt.test.ts, packages/backend/src/tokens/__tests__/verify.test.ts
Tests validate verifyJwt() issuer string mismatch, predicate rejection, and omitted issuer behavior; tests validate verifyToken() returns token-invalid-issuer error on issuer mismatch.
Release notes
.changeset/backend-issuer-validation.md
Changeset documenting issuer validation patch feature with opt-in clarification and supported forms.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A JWT comes hopping by,
Issuer claim up in the sky,
Validate the iss with string or function true,
Opt-in validation—neat and new! 🐾

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding JWT issuer (iss) claim validation when an issuer option is provided in the backend verifyJwt function.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 8, 2026

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8772

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8772

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8772

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8772

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8772

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8772

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8772

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8772

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8772

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8772

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8772

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8772

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8772

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8772

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8772

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8772

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8772

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8772

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8772

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8772

commit: 4602902

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

API Changes Report

Generated by Break Check on 2026-06-08T00:27:14.399Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 1
🔴 Breaking changes 0
🟡 Non-breaking changes 3
🟢 Additions 0

🤖 This report was reviewed by claude-sonnet-4-6.

Note
Break Check could not snapshot 3 subpaths; the diff below excludes them.

  • @clerk/astro ./env: Internal Error: Unable to determine module for: /home/runner/_work/javascript/javascript/packages/astro/env.d.ts You have encountered a software defect. Please consider reporting the issue to the maintainers of this application.
  • @clerk/shared ./cookie: Internal Error: Unable to follow symbol for "Cookies" You have encountered a software defect. Please consider reporting the issue to the maintainers of this application.
  • @clerk/testing ./cypress: Symbol not found for identifier: Cypress

@clerk/backend

Current version: 3.5.0
Recommended bump: MINOR → 3.6.0

Subpath .

🟡 Non-breaking Changes (1)

Modified: verifyToken
// ... 1 unchanged line elided ...
      audience?: string | string[] | undefined;
      authorizedParties?: string[] | undefined;
      clockSkewInMs?: number | undefined;
+     issuer?: import("./jwt/assertions").IssuerResolver | undefined;
      headerType?: string | string[] | undefined;
      secretKey?: string | undefined;
      apiUrl?: string | undefined;
// ... 5 unchanged lines elided ...

Static analyzer: Breaking change in function verifyToken: Parameter options type changed: {audience?:string|string[]|undefined;authorizedParties?:string[]|undefined;clockSkewInMs?:number|undefined;headerType?:string|string[]|undefined;secretKey?:string|undefined;apiUrl?:string|undefined;apiVersion?:string|undefined;skipJwksCache?:boolean|undefined;jwksCacheTtlInMs?:number|undefined;jwtKey?:string|undefined;}{audience?:string|string[]|undefined;authorizedParties?:string[]|undefined;clockSkewInMs?:number|undefined;issuer?:import("@clerk/backend").~IssuerResolver|undefined;headerType?:string|string[]|undefined;secretKey?:string|undefined;apiUrl?:string|undefined;apiVersion?:string|undefined;skipJwksCache?:boolean|undefined;jwksCacheTtlInMs?:number|undefined;jwtKey?:string|undefined;}

🤖 AI review (reclassified as non-breaking) (95%): The change only adds a new optional property issuer? to the options parameter object; existing callers who do not pass issuer are unaffected, and adding an optional field to an input type is non-breaking.

Subpath ./errors

🟡 Non-breaking Changes (1)

Modified: TokenVerificationErrorReason
// ... 2 unchanged lines elided ...
      TokenInvalid: string;
      TokenInvalidAlgorithm: string;
      TokenInvalidAuthorizedParties: string;
+     TokenInvalidIssuer: string;
      TokenInvalidSignature: string;
      TokenNotActiveYet: string;
      TokenIatInTheFuture: string;
// ... 9 unchanged lines elided ...

Static analyzer: Breaking change in variable TokenVerificationErrorReason: Type changed: TokenVerificationErrorReason:{TokenExpired:string;TokenInvalid:string;TokenInvalidAlgorithm:string;TokenInvalidAuthoriz…TokenVerificationErrorReason:{TokenExpired:string;TokenInvalid:string;TokenInvalidAlgorithm:string;TokenInvalidAuthoriz…

🤖 AI review (reclassified as non-breaking) (90%): A new property TokenInvalidIssuer was added to the TokenVerificationErrorReason object; no existing properties were removed or changed, so consumers reading or switching on existing keys are unaffected. This is a purely additive change to the variable's shape.

Subpath ./jwt

🟡 Non-breaking Changes (1)

Modified: VerifyJwtOptions
// ... 1 unchanged line elided ...
      audience?: string | string[];
      authorizedParties?: string[];
      clockSkewInMs?: number;
+     issuer?: IssuerResolver;
      key: JsonWebKey | string;
      headerType?: string | string[];
  };

Static analyzer: Breaking change in type alias VerifyJwtOptions: Type changed: {audience?:string|string[];authorizedParties?:string[];clockSkewInMs?:number;key:!JsonWebKey:interface|string;headerTyp…{audience?:string|string[];authorizedParties?:string[];clockSkewInMs?:number;issuer?:import("@clerk/backend").~IssuerRe…

🤖 AI review (reclassified as non-breaking) (95%): The change only adds a new optional property issuer? to VerifyJwtOptions, which is used as a function input parameter; existing callers that do not pass issuer remain valid, and no existing property was removed or made required.


Report generated by Break Check

Last ran on 4602902. Pushes that change no tracked declarations (no API surface change vs. base) are skipped and don't update this comment.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/jwt/__tests__/assertions.test.ts`:
- Around line 237-242: Update the tests in
packages/backend/src/jwt/__tests__/assertions.test.ts to stop treating an
explicitly configured-but-empty issuer as "opt-in": change the expectation for
assertIssuerClaim(iss, '') to expect it to throw (i.e., configured-empty should
fail), and add a new predicate-edge-case test that calls assertIssuerClaim with
a non-string or missing iss (e.g., an object with iss:number or undefined)
alongside a predicate function to verify it fails in a controlled way; reference
the assertIssuerClaim function and adjust the related assertions in both the
block around the current opt-in test and the second block noted (lines ~254-267)
to cover these failure paths.

In `@packages/backend/src/jwt/assertions.ts`:
- Around line 99-115: assertIssuerClaim currently treats an empty-string issuer
as "not configured" and calls predicate resolvers with iss cast to string, which
can skip validation or throw non-TokenVerificationError exceptions; change the
guard to check for issuer === undefined (so empty string is treated as
configured) and ensure the predicate is only invoked when typeof iss ===
'string' (otherwise throw TokenVerificationError with
TokenVerificationErrorAction.EnsureClerkJWT and
TokenVerificationErrorReason.TokenInvalidIssuer); update the isValid computation
in assertIssuerClaim to perform the typeof iss === 'string' check before calling
a function issuer(iss) and produce the stable token-invalid-issuer error when
iss is missing or malformed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 217d0731-ea9e-47a4-a02c-b7fb4e77af3a

📥 Commits

Reviewing files that changed from the base of the PR and between 565a516 and 4602902.

📒 Files selected for processing (7)
  • .changeset/backend-issuer-validation.md
  • packages/backend/src/errors.ts
  • packages/backend/src/jwt/__tests__/assertions.test.ts
  • packages/backend/src/jwt/__tests__/verifyJwt.test.ts
  • packages/backend/src/jwt/assertions.ts
  • packages/backend/src/jwt/verifyJwt.ts
  • packages/backend/src/tokens/__tests__/verify.test.ts

Comment on lines +237 to +242
it('does not throw if no issuer is provided (opt-in)', () => {
expect(() => assertIssuerClaim(iss)).not.toThrow();
expect(() => assertIssuerClaim(iss, undefined)).not.toThrow();
expect(() => assertIssuerClaim(undefined)).not.toThrow();
expect(() => assertIssuerClaim(iss, '')).not.toThrow();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Adjust issuer tests to avoid codifying validation bypass and cover predicate edge-case errors.

The current case at Line 241 (issuer: '') asserts skip behavior even though issuer is supplied. Add/adjust tests so configured-but-empty issuer fails, and include a predicate case with non-string/missing iss to verify controlled failure behavior.

Suggested test updates
   it('does not throw if no issuer is provided (opt-in)', () => {
     expect(() => assertIssuerClaim(iss)).not.toThrow();
     expect(() => assertIssuerClaim(iss, undefined)).not.toThrow();
     expect(() => assertIssuerClaim(undefined)).not.toThrow();
-    expect(() => assertIssuerClaim(iss, '')).not.toThrow();
+    expect(() => assertIssuerClaim(iss, '')).toThrow(`Invalid JWT issuer claim (iss) "https://clerk.example.com".`);
   });
@@
   it('throws if iss is missing or not a string when an issuer string is required', () => {
     expect(() => assertIssuerClaim(undefined, iss)).toThrow(`Invalid JWT issuer claim (iss) undefined.`);
     expect(() => assertIssuerClaim(42, iss)).toThrow(`Invalid JWT issuer claim (iss) 42.`);
   });
+
+  it('throws if iss is missing when issuer resolver is a predicate', () => {
+    expect(() => assertIssuerClaim(undefined, i => i.startsWith('https://clerk.'))).toThrow(
+      `Invalid JWT issuer claim (iss) undefined.`,
+    );
+  });

As per coding guidelines, **/*.{test,spec}.{ts,tsx} should verify proper error handling and edge cases for new functionality.

Also applies to: 254-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/jwt/__tests__/assertions.test.ts` around lines 237 -
242, Update the tests in packages/backend/src/jwt/__tests__/assertions.test.ts
to stop treating an explicitly configured-but-empty issuer as "opt-in": change
the expectation for assertIssuerClaim(iss, '') to expect it to throw (i.e.,
configured-empty should fail), and add a new predicate-edge-case test that calls
assertIssuerClaim with a non-string or missing iss (e.g., an object with
iss:number or undefined) alongside a predicate function to verify it fails in a
controlled way; reference the assertIssuerClaim function and adjust the related
assertions in both the block around the current opt-in test and the second block
noted (lines ~254-267) to cover these failure paths.

Source: Coding guidelines

Comment on lines +99 to +115
export const assertIssuerClaim = (iss: unknown, issuer?: IssuerResolver) => {
// No issuer configured, skip validation. Preserves the default behavior, matching how
// the audience and authorized parties claims are only checked when an option is provided.
if (!issuer) {
return;
}

const isValid = typeof issuer === 'function' ? issuer(iss as string) : typeof iss === 'string' && iss === issuer;

if (!isValid) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenInvalidIssuer,
message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}.`,
});
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail closed for configured issuer and guard predicate execution.

Line 102 currently treats issuer: '' as “not configured”, which silently skips validation. Line 106 also calls predicate resolvers with iss as string, so malformed tokens can surface non-TokenVerificationError exceptions instead of a stable token-invalid-issuer failure.

Suggested fix
-export const assertIssuerClaim = (iss: unknown, issuer?: IssuerResolver) => {
+export const assertIssuerClaim = (iss: unknown, issuer?: IssuerResolver): void => {
   // No issuer configured, skip validation. Preserves the default behavior, matching how
   // the audience and authorized parties claims are only checked when an option is provided.
-  if (!issuer) {
+  if (typeof issuer === 'undefined') {
     return;
   }

-  const isValid = typeof issuer === 'function' ? issuer(iss as string) : typeof iss === 'string' && iss === issuer;
+  const issValue = typeof iss === 'string' ? iss : undefined;
+  let isValid = false;
+
+  if (typeof issuer === 'function') {
+    try {
+      isValid = typeof issValue === 'string' && issuer(issValue);
+    } catch {
+      isValid = false;
+    }
+  } else {
+    isValid = typeof issValue === 'string' && issValue === issuer;
+  }

   if (!isValid) {
     throw new TokenVerificationError({
       action: TokenVerificationErrorAction.EnsureClerkJWT,
       reason: TokenVerificationErrorReason.TokenInvalidIssuer,
       message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}.`,
     });
   }
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/jwt/assertions.ts` around lines 99 - 115,
assertIssuerClaim currently treats an empty-string issuer as "not configured"
and calls predicate resolvers with iss cast to string, which can skip validation
or throw non-TokenVerificationError exceptions; change the guard to check for
issuer === undefined (so empty string is treated as configured) and ensure the
predicate is only invoked when typeof iss === 'string' (otherwise throw
TokenVerificationError with TokenVerificationErrorAction.EnsureClerkJWT and
TokenVerificationErrorReason.TokenInvalidIssuer); update the isValid computation
in assertIssuerClaim to perform the typeof iss === 'string' check before calling
a function issuer(iss) and produce the stable token-invalid-issuer error when
iss is missing or malformed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant