Skip to content

Conversation

@alfonso-paella
Copy link
Contributor

@alfonso-paella alfonso-paella commented Jan 9, 2026

Description

Fixes the "Unexpected JWT encountered while verifying" warning logs that occur when developers switch between different Crossmint projects (especially during local development). The root cause was that JWT cookies were stored with fixed names (crossmint-jwt, crossmint-refresh-token) not scoped to the project, causing JWTs from one project to be sent with requests for another project.

Changes:

  1. Scoped cookie storage: Cookies are now scoped by project ID (e.g., crossmint-jwt-{projectId}). The project ID is extracted from the API key using the existing validateAPIKey function from @crossmint/common-sdk-base. For backward compatibility, the SDK reads from legacy (unscoped) cookies as fallback but writes to new scoped cookies.

Key files to review:

  • packages/client/auth/src/utils/storage.ts - New ScopedCookieStorage class and getProjectIdFromApiKey utility
  • packages/client/react-base/src/providers/CrossmintAuthBaseProvider.tsx - API key change detection effect

Human review checklist

  • Verify validateAPIKey correctly extracts project ID for all API key formats used in production
  • Confirm fallback to "default" when API key is invalid/missing is acceptable behavior
  • Test backward compatibility: existing sessions with legacy cookies should still work
  • Test project switching: changing API keys should clear auth state and force re-authentication

Test plan

  • Unit tests updated to reflect new scoped cookie names
  • Ran pnpm lint - passed
  • Ran pnpm test:vitest - all tests pass
  • Manual testing recommended: verify existing sessions still work (backward compatibility) and that switching API keys clears auth state

Package updates

  • @crossmint/client-sdk-auth: patch
  • @crossmint/client-sdk-react-base: patch

Changeset added via pnpm change:add.


Link to Devin run: https://crossmint.devinenterprise.com/sessions/83bec0c3906643d591cd8d648b6549db
Requested by: Alfonso (@alfonso-paella)

@changeset-bot
Copy link

changeset-bot bot commented Jan 9, 2026

🦋 Changeset detected

Latest commit: d220cf1

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

This PR includes changesets to release 9 packages
Name Type
@crossmint/client-sdk-auth Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-react-ui Patch
expo-demo Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/client-sdk-nextjs-starter Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/client-sdk-smart-wallet-next-starter 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

@devin-ai-integration
Copy link
Contributor

Original prompt from Alfonso
hey devin. i see thousands of logs like these

https://app.datadoghq.com/logs?query=%22Unexpected%20JWT%20encountered%20while%20verifying%22%20%40environment%3Astaging&agg_m=count&agg_m_source=base&agg_t=count&clustering_pattern_field_path=message&cols=service%2C%40http.url_details.path%2C%40analytics.project_billing_id%2C%40project_name%2C%40environment&messageDisplay=inline&refresh_mode=sliding&storage=hot&stream_sort=desc&viz=stream&from_ts=1762719906524&to_ts=1767990306524&live=true

GET api/2025-06-09/wallets/me
GET api/2025-06-09/wallets/me:walletLocator

Where we log this warning:
WARN	Unexpected JWT encountered while verifying [...]

So this log coms from apps/crossmint-nextjs/src/api/session/auth-sdk.service.ts:124 that logs when the audience from the jwt and the project being accessed mismatch, that shouldn't happen

I want you to check both the code in crossbit-main referenced above, as well as the crossmint sdk, to see if you find what can explain why is this so common? i am suspecting some reac condition or insufficient cache busting on logout on the sdk side, but not sure


You only need to look in the following repos: Paella-Labs/crossbit-main, Crossmint/crossmint-sdk, Crossmint/open-signer

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

}

/**
* Cookie storage that scopes cookies by API key prefix.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think you believe that "ck_staging" is the project name, or something. But it's not. That indicates Client Key in Staging environment

With the code change you have made, two keys from the same environment will always be stored in the same place

What you need to do is not to extract the API key prefix. You need to extract the project ID from the key, and use that as the storage key. Check the codebase as thee's some parsing function to decode the project ID from it

@devinai

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Good catch! I've updated the implementation to properly extract the project ID from the API key using the existing validateAPIKey function from @crossmint/common-sdk-base. The cookie names are now scoped by project ID (e.g., crossmint-jwt-{projectId}) instead of just the API key prefix.

@alfonso-paella alfonso-paella marked this pull request as draft January 10, 2026 02:06
@devin-ai-integration devin-ai-integration bot changed the title fix: scope JWT cookies by API key prefix to prevent audience mismatch warnings fix: scope JWT cookies by project ID to prevent audience mismatch warnings Jan 10, 2026
devin-ai-integration bot and others added 5 commits January 11, 2026 13:03
- For JWTs: Only read from scoped cookies to prevent using wrong project's JWT
- For refresh tokens: Fall back to legacy cookies to allow migration
- If legacy refresh token is for wrong project, server rejects and SDK calls logout()
- Remove API key change detection logic from CrossmintAuthBaseProvider
- This prevents the audience mismatch warnings while maintaining backward compatibility

Co-Authored-By: Alfonso <[email protected]>
This prevents cross-project token usage when a legacy refresh token is used
with a different project. The server accepts the refresh token but issues
a JWT for the original project, not the current one. This fix detects the
mismatch and calls logout instead of storing the mismatched JWT.

Co-Authored-By: Alfonso <[email protected]>
"@crossmint/client-sdk-auth": patch
---

Fix JWT cookie scoping to prevent cross-project token usage. JWTs are now scoped by project ID and only refresh tokens fall back to legacy cookies for migration.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@devinai this is not entirely accurate. I woudl say something on the lines of "Moved JWT cookie scoping to allow for multiple project cookies in the same domain, and prevent issues of cookie mismatch during development"

Copy link
Contributor

Choose a reason for hiding this comment

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

Updated the changeset description as suggested. Thanks!

console.debug(
`JWT audience mismatch: JWT is for project ${jwtAudience}, but current project is ${currentProjectId}. Logging out.`
);
await this.logout();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@devinai explain to me why we need this logout here, and what is it clearing (a) the current auth token, or (b) the one mapping to the api key for the current project?

Copy link
Contributor

Choose a reason for hiding this comment

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

Why we need this logout:

This scenario happens when a legacy refresh token (from before cookie scoping) is used with a different project. The server accepts the refresh token but issues a JWT for the original project, not the current one. We can't use this JWT because it would cause the audience mismatch warnings you were seeing.

What logout() clears:

The answer is (b) the cookies mapping to the API key for the current project.

The storageProvider is a ScopedCookieStorage initialized with the current API key (Project 2). When logout() is called, it removes:

  • crossmint-jwt-{project2Id} (scoped cookie - doesn't exist yet)
  • crossmint-refresh-token-{project2Id} (scoped cookie - doesn't exist yet)
  • crossmint-jwt (legacy cookie)
  • crossmint-refresh-token (legacy cookie - this is the one that caused the issue)

Important: Project 1's scoped cookies (crossmint-jwt-{project1Id}, crossmint-refresh-token-{project1Id}) are NOT affected by this logout. They remain intact.

The purpose is to clean up the legacy refresh token that caused the mismatch, so we don't keep trying to use it. The user will need to re-authenticate for Project 2, but their Project 1 session remains valid.

Copy link
Contributor

Choose a reason for hiding this comment

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

You're right - I've removed this block. The refresh doesn't actually fail (server accepts the token and issues a JWT), but the warnings don't break functionality. As users re-authenticate with the updated SDK, they'll get scoped cookies and the warnings will naturally stop. The forced logout was more disruptive than the warnings themselves.

@alfonso-paella alfonso-paella marked this pull request as ready for review January 11, 2026 22:12
return decoded.exp;
}

export function getJWTAudience(token: string): string | undefined {
Copy link
Collaborator

Choose a reason for hiding this comment

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

unused

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Removed the unused getJWTAudience function.

* If a legacy refresh token is used with the wrong project, the server will reject it
* and the SDK will call logout(), cleaning up the legacy cookie.
*/
export class ScopedCookieStorage implements StorageProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

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

i would change cookiestorage to use this implementation instead of having 2

Copy link
Contributor

Choose a reason for hiding this comment

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

Good suggestion! I kept both implementations for now to minimize the scope of this change, but consolidating them would be a good follow-up. The main difference is that ScopedCookieStorage adds project ID scoping and legacy cookie fallback for refresh tokens. Would you like me to consolidate them in this PR or as a separate follow-up?

@jmderby
Copy link
Contributor

jmderby commented Jan 12, 2026

We're still calling our prefix var export const SESSION_PREFIX = "crossmint-jwt"; in src/utils/constants.ts. I'm assuming we should take that in to account for legacy changes?

@devin-ai-integration
Copy link
Contributor

Good question! The SESSION_PREFIX constant (crossmint-jwt) is still used as the base name for cookies. The ScopedCookieStorage class appends the project ID to create scoped cookie names like crossmint-jwt-{projectId}. For legacy cookie migration, we also check for the unscoped crossmint-jwt cookie (for refresh tokens only) to support users upgrading from older SDK versions. So the constant is still relevant and used correctly.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants