Skip to content

Verify session tokens before trusting server-side identity - #452

Merged
ericzakariasson merged 4 commits into
mainfrom
cursor/verify-server-sessions-f627
Oct 15, 2026
Merged

ericzakariasson merged 4 commits into
mainfrom
cursor/verify-server-sessions-f627

Conversation

@ericzakariasson

@ericzakariasson ericzakariasson commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Two auth hardening fixes. The Community Plugins MCP server (#453) builds on these, but they stand on their own and should merge first.

1. Server-side identity now comes from verified token claims

getSession() in apps/cursor/src/utils/supabase/auth.ts returned the session straight from the auth cookie. supabase.auth.getSession() does not check the access token's signature. So every server-side owner and admin check trusted unverified cookie contents: authActionClient, adminActionClient, /admin/plugins, and the edit pages. Several of those actions then write with the secret-key client, which bypasses row-level security.

getSession() now calls supabase.auth.getClaims(), which verifies the JWT against the project's JWKS (or asks the Auth server when the project uses symmetric keys). It builds the user from sub, email, and user_metadata.

  • Callers only read session.user.id, plus email and user_metadata in safe-action.ts, so no call sites change.
  • Client components keep using the browser getSession() for UI gating. That is fine because the server enforces everything.

2. /auth/callback only redirects within the site

The callback appended the raw next query parameter to the base URL. next=@evil.example therefore produced http://host@evil.example/, sending users off-site after they signed in. next is now resolved against the request origin, and anything that would leave the origin falls back to /.

Verification (local Supabase stack + bun dev)

  • Forged auth cookie, before this change: /admin/plugins rendered the moderation queue, and approvePluginAction published a hidden plugin.
  • Forged auth cookie, after this change: the action fails with Session not found!, the plugin stays hidden, and /admin/plugins renders no queue data.
  • Real sessions: a member gets Unauthorized: admin access required. An admin still sees the queue and can approve.
  • Real PKCE magic-link login through /auth/callback:
    • Before: next=@evil.example redirected to http://localhost:3000@evil.example/.
    • After: @evil.example → /@evil.example, //evil.example → /, and /plugins/acme-rules is preserved.
  • bunx biome ci . and bun run typecheck pass. The remaining Biome warnings already existed in files this PR doesn't touch.

Notes

  • If production still uses the legacy symmetric JWT secret, getClaims() makes one Auth server call per server-side session read. With asymmetric signing keys it verifies locally against a cached JWKS.
  • Vercel preview: the deployment check fails, but it has failed on every main commit since Add admin moderation actions to the plugin detail page #434 (Sep 11). This PR doesn't cause it, but it blocks deploying this fix until someone checks the Vercel build logs.
Open in Web Open in Cursor 

Note

High Risk
Touches authentication and authorization paths; behavior changes for forged cookies and redirect handling, though the intent is security hardening.

Reviewed by Cursor Bugbot for commit e437a94. Bugbot is set up for automated code reviews on this repo. Configure here.

cursoragent and others added 2 commits October 14, 2026 17:11
getSession() returned the session straight from the auth cookie, and
supabase.auth.getSession() does not check the access token signature, so
server-side owner and admin checks trusted unverified cookie contents.
Derive the user from getClaims() instead, which verifies the JWT against
the project's JWKS (or the Auth server for symmetric keys).

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
The callback appended the raw next query param to the base URL, so values
such as @evil.example redirected users off-site after signing in. Resolve
next against the request origin and fall back to / for anything that
leaves it.

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
cursor-directory Ready Ready Preview Oct 15, 2026 12:40am UTC

Request Review

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Expired tokens crash session reads
    • Reproduced that the no-argument getClaims() in the locked auth-js 2.105.4 throws a raw Error('JWT has expired') out of getSession() when the cookie session's expires_at is null or still in the future but its access token has expired, so getSession() now catches any throw from getClaims() and returns null, with createClient() left outside the catch so Next.js cookies() errors still propagate.

Create PR

Or push these changes by commenting:

@cursor push cc5236ce0a
Preview (cc5236ce0a)
diff --git a/apps/cursor/src/utils/supabase/auth.ts b/apps/cursor/src/utils/supabase/auth.ts
--- a/apps/cursor/src/utils/supabase/auth.ts
+++ b/apps/cursor/src/utils/supabase/auth.ts
@@ -19,7 +19,11 @@
  */
 export async function getSession(): Promise<VerifiedSession | null> {
   const supabase = await createClient();
-  const { data, error } = await supabase.auth.getClaims();
+  // getClaims() throws, instead of returning `error`, for some unverifiable
+  // tokens (e.g. an expired one whose session `expires_at` hasn't passed).
+  const { data, error } = await supabase.auth
+    .getClaims()
+    .catch((error: unknown) => ({ data: null, error }));
 
   if (error || !data?.claims.sub) {
     return null;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 604eb26. Configure here.

Comment thread apps/cursor/src/utils/supabase/auth.ts
getClaims() in @supabase/auth-js 2.105 throws a plain Error for an
expired token instead of returning it as an error, so getSession() let
the exception escape and server pages that call it could 500. Any
getClaims failure now means there's no verified session.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ericzakariasson
ericzakariasson marked this pull request as ready for review October 15, 2026 00:42
@ericzakariasson
ericzakariasson merged commit 275ef87 into main Oct 15, 2026
4 checks passed
@ericzakariasson
ericzakariasson deleted the cursor/verify-server-sessions-f627 branch October 15, 2026 00:43
@ericzakariasson
ericzakariasson restored the cursor/verify-server-sessions-f627 branch October 15, 2026 00:43
@ericzakariasson
ericzakariasson deleted the cursor/verify-server-sessions-f627 branch October 15, 2026 00:43

This branch was successfully deployed

1 active deployment
Preview – cursor-directory — e437a94f Deployed Oct 15, 2026 by vercel[bot]
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.

2 participants