Skip to content

fix(auth): validate JWT exp claim in getSession before returning cached session - #2542

Open
clanzhang wants to merge 3 commits into
supabase:masterfrom
clanzhang:fix/auth-jwt-exp-validation-in-getSession
Open

fix(auth): validate JWT exp claim in getSession before returning cached session#2542
clanzhang wants to merge 3 commits into
supabase:masterfrom
clanzhang:fix/auth-jwt-exp-validation-in-getSession

Conversation

@clanzhang

Copy link
Copy Markdown

🔍 Description

What changed?

getSession() now independently decodes the JWT access token's exp claim before returning a cached session. Previously it only checked the session-level expires_at field set by the server response.

Two additions in __loadSession():

  1. JWT exp validation — if the JWT's exp has already passed, the session is invalidated and cleared, even if expires_at is still in the future.
  2. Drift correction — if JWT exp and session expires_at diverge by more than 60 seconds, expires_at is corrected to match the JWT exp (the actual token-level source of truth).

Malformed JWTs are handled gracefully (session cleared, no crash).

Why was this change needed?

getSession() trusts the session-level expires_at without verifying the JWT's own exp claim. This causes problems when:

  • Clock skew between the token-issuing server and the client machine causes the JWT to expire before expires_at suggests.
  • Token tampering or storage corruption results in a mismatched JWT.
  • Server-side revocation (admin sign-out, password change) invalidates the JWT but the cached session still looks valid locally.

In all these cases, developers receive a non-null session from getSession() but downstream API calls fail with 401 — a confusing debugging experience, especially in SSR/Edge environments.

Closes #1191

Approach considered and rejected

  1. Adding validation in _isValidSession() — rejected because it's a synchronous structural check; adding async JWT decode would break its type guard contract.
  2. Overriding expires_at at storage-write time — rejected because it doesn't catch post-write corruption or clock drift that happens after the session is stored.
  3. Validating in __loadSession() (this PR) — chosen because it's the single read path for all getSession() callers, and keeps the existing expiry-margin refresh logic intact.

📸 Screenshots/Examples

Before:

getSession() → { session: { access_token: "expired-jwt", ... }, error: null }

→ API call → 401 Unauthorized

After:
getSession() → { session: null, error: null }

→ caller knows immediately, can redirect to login

⚠️ Breaking changes

None. Normal session behavior is unchanged. The new logic only triggers when the JWT exp has genuinely expired or diverges significantly from expires_at.

🧪 Tests added

3 new test cases (no Docker/backend required):

  • getSession() invalidates session when JWT exp has expired but expires_at is in the future
  • getSession() corrects expires_at when JWT exp diverges by more than 60s
  • getSession() clears session when JWT is malformed

…ed session

Previously, getSession() only checked the session-level expires_at field
set by the server response. If the JWT's own exp claim had already passed
(e.g. clock skew, token tampering, or server-side revocation), the client
would still return the session as valid, causing downstream requests to
fail with 401 errors.

This adds a JWT exp decode step in __loadSession() so the access token's
actual expiry is verified before the session is returned to callers.
@clanzhang
clanzhang requested review from a team as code owners July 21, 2026 03:36
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 13800dcb-e6cb-4c77-8692-3c0667dd6abc

📥 Commits

Reviewing files that changed from the base of the PR and between 091ee2e and 4f56186.

📒 Files selected for processing (2)
  • packages/core/auth-js/src/GoTrueClient.ts
  • packages/core/auth-js/test/GoTrueClient.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/auth-js/test/GoTrueClient.test.ts

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved session validation when restoring sessions from storage, including safer JWT handling.
    • Expired or malformed access tokens are now cleared automatically.
    • Session expiration times are corrected when the stored value significantly differs from the token’s expiration.

Walkthrough

GoTrueClient.__loadSession() now decodes stored access-token JWTs, removes sessions with malformed tokens, and reconciles expires_at with JWT exp when they differ by more than 60 seconds before expiration handling. Tests cover expired tokens with future session metadata, expiration drift correction, and invalid JWT formats.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Biome (2.5.3)
packages/core/auth-js/test/GoTrueClient.test.ts

File contains syntax errors that prevent linting: Line 4982: expected } but instead the file ends

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/core/auth-js/test/GoTrueClient.test.ts

Parsing error: '}' expected.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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/core/auth-js/src/GoTrueClient.ts`:
- Around line 2984-3000: Update GoTrueClient.ts lines 2984-3000 to remove the
JWT expiration branch and unconditionally assign currentSession = maybeSession
after decoding, while retaining catch-based cleanup for malformed tokens. Update
GoTrueClient.test.ts lines 348-394 to verify that an expired JWT reaches the
refresh flow, asserting either the expected unmocked refresh error or a mocked
successful refresh response.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d081a133-0bcf-4c88-bc71-531830eadc3d

📥 Commits

Reviewing files that changed from the base of the PR and between 9e8c573 and 091ee2e.

📒 Files selected for processing (2)
  • packages/core/auth-js/src/GoTrueClient.ts
  • packages/core/auth-js/test/GoTrueClient.test.ts

Comment thread packages/core/auth-js/src/GoTrueClient.ts Outdated
…ing session

Previously, getSession() would destroy the session when the JWT exp had
expired, even if the refresh token was still valid. Now the session is
preserved and flows through to the existing refresh mechanism via drift
correction, allowing recovery when the refresh token is still good.
// will align expires_at with the JWT exp, and the existing
// hasExpired check will trigger a refresh if needed.
try {
decodeJWT(maybeSession.access_token)

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.

Severity: LOW

This parses the JWT but does not validate required payload claims such as a numeric exp. A stored token with a null, missing, or non-object payload therefore reaches currentSession; the later payload.exp check skips or throws, and getSession() returns it using stale expires_at instead of clearing it.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: After decoding the JWT, validate that the decoded payload is a non-null object and that exp is a finite number before assigning currentSession. If the payload is invalid, clear the session instead of using it. Replace the two lines that decode the JWT and assign currentSession with validation logic:

const { payload } = decodeJWT(maybeSession.access_token)
if (payload && typeof payload === 'object' && typeof payload.exp === 'number' && isFinite(payload.exp)) {
  currentSession = maybeSession
} else {
  this._debug('#getSession()', 'JWT payload missing valid exp claim')
  await this._removeSession()
}

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
decodeJWT(maybeSession.access_token)
const { payload } = decodeJWT(maybeSession.access_token)
if (payload && typeof payload === 'object' && typeof payload.exp === 'number' && isFinite(payload.exp)) {
currentSession = maybeSession
} else {
this._debug('#getSession()', 'JWT payload missing valid exp claim')
await this._removeSession()
}

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.

1 participant