Skip to content

feat(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

feat(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

@jacekradko jacekradko commented Jun 8, 2026

Copy link
Copy Markdown
Member

verifyJwt never 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 the option up for real: pass a string or a list of strings, the token's iss must match one exactly, and mismatches surface as a new token-invalid-issuer error. Omitting the option skips the check, the same opt-in shape as audience and azp.

Two decisions worth scrutiny. The option is string | string[] rather than a predicate, because middleware options cross a JSON serialization boundary in Next.js (encryptClerkRequestData) where a function value would be silently dropped. And machine token verification now passes an explicit options object to verifyJwt instead of spreading the caller's options through, so an issuer (or audience/authorizedParties) meant for session tokens can't reject M2M and OAuth tokens, whose iss differs. Both paths have regression tests, and the previously inert tests now exercise the assertion.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added optional issuer validation for token verification with verifyToken() and verifyJwt()
    • Issuer parameter accepts a string or array of strings for flexible token validation
    • Issuer validation is opt-in and backward compatible; omitting it preserves existing behavior
    • Machine tokens (M2M, OAuth access tokens, API keys) are unaffected by issuer validation

@changeset-bot

changeset-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b0232f2

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 Minor
@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

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

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 11, 2026 12:11pm
swingset Ready Ready Preview, Comment Jun 11, 2026 12:11pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro

Run ID: 3dac4493-18f6-4ad5-86ee-462303d229a1

📥 Commits

Reviewing files that changed from the base of the PR and between 73cdabf and b0232f2.

📒 Files selected for processing (8)
  • .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/jwt/verifyMachineJwt.ts
  • packages/backend/src/tokens/__tests__/verify.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/backend-issuer-validation.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/backend/src/errors.ts
  • packages/backend/src/jwt/tests/assertions.test.ts

📝 Walkthrough

Walkthrough

Adds an opt-in issuer option (string or string[]) to verifyJwt()/verifyToken() that validates the JWT iss claim for session tokens; removes predicate-based issuer resolvers, introduces TokenInvalidIssuer, wires assertIssuerClaim into verifyJwt, scopes machine-token calls to avoid issuer checks, and adds tests and a changeset.

Changes

JWT Issuer Validation

Layer / File(s) Summary
Release notes
.changeset/backend-issuer-validation.md
Changeset documenting opt-in issuer validation for @clerk/backend (session tokens only).
Error reason definition
packages/backend/src/errors.ts
Adds TokenInvalidIssuer to exported TokenVerificationErrorReason.
Issuer assertion implementation
packages/backend/src/jwt/assertions.ts, packages/backend/src/jwt/__tests__/assertions.test.ts
Removes predicate-based IssuerResolver and adds assertIssuerClaim(iss, issuer?) supporting `string
verifyJwt options and wiring
packages/backend/src/jwt/verifyJwt.ts
Imports assertIssuerClaim; extends VerifyJwtOptions with `issuer?: string
Machine-token scoping
packages/backend/src/jwt/verifyMachineJwt.ts
resolveKeyAndVerifyJwt stops forwarding full machine options to verifyJwt; forwards only clockSkewInMs (and headerType when needed) so session-token claim options (issuer/aud/azp) are not asserted for machine tokens.
Integration and unit tests
packages/backend/src/jwt/__tests__/verifyJwt.test.ts, packages/backend/src/tokens/__tests__/verify.test.ts
Adds tests covering issuer array/string acceptance, issuer mismatch producing token-invalid-issuer, omitted issuer skipping validation, and ensuring machine-token verification ignores session issuer option.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • wobsoriano

Poem

🐰 A rabbit checks the JWT with care,
Hops through iss claims here and there,
Strings or lists, it will inspect,
Skip when absent, call out when wrecked.
Hooray for tidy token fare!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding JWT issuer (iss) claim validation when an issuer option is provided to verifyJwt and verifyToken.
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.

✏️ 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

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

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: b0232f2

@coderabbitai coderabbitai Bot left a comment

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.

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 thread packages/backend/src/jwt/assertions.ts Outdated
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.

@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-06-11T12:15:52.447Z

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.6.1
Recommended bump: MINOR → 3.7.0

Subpath .

🟡 Non-breaking Changes (1)

Modified: verifyToken
// ... 1 unchanged line elided ...
      audience?: string | string[] | undefined;
      authorizedParties?: string[] | undefined;
      clockSkewInMs?: number | undefined;
+     issuer?: string | string[] | 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?:string|string[]|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) (97%): A new optional property issuer was added to the options parameter (an input type); existing callers that do not pass issuer are unaffected, and the addition of an optional field to an input object 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: {TokenExpired:string;TokenInvalid:string;TokenInvalidAlgorithm:string;TokenInvalidAuthorizedParties:string;TokenInvalid…{TokenExpired:string;TokenInvalid:string;TokenInvalidAlgorithm:string;TokenInvalidAuthorizedParties:string;TokenInvalid…

🤖 AI review (reclassified as non-breaking) (90%): The only structural difference between before and after is the addition of a new TokenInvalidIssuer property; no existing properties were removed or renamed, making this a pure addition 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?: string | string[];
      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?:string|string[];key:!JsonWebKey:in…

🤖 AI review (reclassified as non-breaking) (97%): An optional issuer field was added to VerifyJwtOptions, which is an input type (parameter to verifyJwt). Existing callers that omit issuer remain fully valid; adding a new optional property to an input type is non-breaking.


Report generated by Break Check

Last ran on b0232f2.

@jacekradko jacekradko changed the title fix(backend): validate JWT issuer (iss) claim when an issuer option is provided feat(backend): validate JWT issuer (iss) claim when an issuer option is provided Jun 11, 2026
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