Skip to content

Fix cross-subdomain cookie deletion and prefetch trusted parent domain#1302

Merged
BilalG1 merged 5 commits into
devfrom
fix/cross-subdomain-cookie-sharing
Apr 4, 2026
Merged

Fix cross-subdomain cookie deletion and prefetch trusted parent domain#1302
BilalG1 merged 5 commits into
devfrom
fix/cross-subdomain-cookie-sharing

Conversation

@BilalG1
Copy link
Copy Markdown
Collaborator

@BilalG1 BilalG1 commented Apr 1, 2026

Cross-subdomain refresh cookies were not being deleted correctly because the domain option was not passed to deleteCookie/deleteCookieClient. This caused stale cookies to accumulate and auth state to persist across subdomains after sign-out.

Also eagerly warms the trusted parent domain cache on app construction to avoid a race condition where navigation after sign-in could prevent the cross-subdomain cookie from being written.

Summary by CodeRabbit

  • New Features

    • Automatically recreates a missing cross-subdomain refresh cookie on app startup in browser sessions when applicable.
  • Bug Fixes

    • Cookie deletions now correctly scope removals to the encoded parent domain when applicable for both browser and server token-store flows.
  • Performance

    • Pre-warms a domain-resolution cache in browser token-store scenarios to reduce authentication latency.
  • Tests

    • Added end-to-end tests validating custom refresh-cookie name encoding/decoding, non-custom cookie handling, and eager cookie recreation.

Cross-subdomain refresh cookies were not being deleted correctly because
the domain option was not passed to deleteCookie/deleteCookieClient.
This caused stale cookies to accumulate and auth state to persist across
subdomains after sign-out.

Also eagerly warms the trusted parent domain cache on app construction
to avoid a race condition where navigation after sign-in could prevent
the cross-subdomain cookie from being written.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 1, 2026

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

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment Apr 3, 2026 10:47pm
stack-backend Ready Ready Preview, Comment Apr 3, 2026 10:47pm
stack-dashboard Ready Ready Preview, Comment Apr 3, 2026 10:47pm
stack-demo Ready Ready Preview, Comment Apr 3, 2026 10:47pm
stack-docs Ready Ready Preview, Comment Apr 3, 2026 10:47pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

Client constructor now pre-warms a trusted-parent-domain cache in-browser for cookie-based token stores, decodes base32-encoded custom refresh-cookie names to derive parent domains, creates missing cross-subdomain refresh cookies, and uses decoded domains when deleting custom refresh cookies (browser and nextjs-cookie server flows).

Changes

Cohort / File(s) Summary
Domain-Aware Cookie Handling
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Added decodeBase32 import; new _getDomainFromCustomRefreshCookieName(name) to decode parent domains; constructor pre-warms _trustedParentDomainCache in browser cookie modes and ensures cross-subdomain custom refresh-cookie exists; cookie deletions now pass { domain } when derivable (browser and nextjs-cookie server flows).
E2E Tests — Cookie decoding & eager creation
apps/e2e/tests/js/cookies.test.ts
Added tests for custom refresh-cookie name encode/decode round-trips, negative cases returning null, and a constructor-time test that verifies cross-subdomain custom refresh cookie is re-created when missing.

Sequence Diagram(s)

sequenceDiagram
    participant ClientApp as ClientApp
    participant Browser as Browser
    participant TokenStore as TokenStore
    participant Server as Server

    ClientApp->>Browser: constructor (browser && tokenStore = "cookie"|"nextjs-cookie")
    ClientApp->>TokenStore: pre-warm _trustedParentDomainCache(window.location.hostname)
    ClientApp->>ClientApp: _ensureCrossSubdomainCookieExists()

    Note over ClientApp,TokenStore: cached trusted parent domain used for cookie scoping

    Browser->>ClientApp: token-store change / onChange -> delete custom refresh cookies
    ClientApp->>ClientApp: _getDomainFromCustomRefreshCookieName(name)
    alt domain decoded
        ClientApp->>Browser: deleteCookieClient(name, { domain })
    else not decoded
        ClientApp->>Browser: deleteCookieClient(name)
    end

    Server->>ClientApp: server-side store.onChange (nextjs-cookie)
    ClientApp->>ClientApp: _getDomainFromCustomRefreshCookieName(name)
    alt domain decoded
        ClientApp->>Server: deleteCookieServer(name, { noOpIfServerComponent:true, domain })
    else not decoded
        ClientApp->>Server: deleteCookieServer(name, { noOpIfServerComponent:true })
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • fix host cookie deleting #1011: Modifies cookie deletion behavior and related client-app-impl deletion logic; overlaps with domain-aware deletion changes.
  • Partitioned cookie support #1052: Refactors cookie APIs and touches client-app-impl.ts; relevant to cookie naming/deletion adjustments.
  • fix cookie issue #1002: Addresses hostname handling and cookie-host logic in client-app-impl.ts, closely related to parent-domain/cookie scoping updates.

Suggested reviewers

  • N2D4

Poem

🐰 I hopped through base32, nibbling names so sly,
I warmed the parent-cache beneath the sky,
I scoped each cookie to its rightful home,
Recreated crumbs where they once did roam,
A tidy trail of cookies—happy hop, goodbye! 🍪

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: fixing cross-subdomain cookie deletion and prefetching the trusted parent domain cache. It is concise, specific, and directly reflects the core objectives.
Description check ✅ Passed The description clearly explains both the bug (missing domain option in cookie deletion) and its impact (stale cookies persisting), plus the race condition and mitigation strategy (eager cache warming). It matches the PR objectives and provides necessary context.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cross-subdomain-cookie-sharing

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 and usage tips.

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.

🧹 Nitpick comments (1)
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)

626-634: Optional: normalize decoded domain to lowercase for canonical consistency.

You already lowercase before encoding in _getCustomRefreshCookieName; mirroring that on decode avoids mixed-case edge behavior from non-canonical cookie names.

♻️ Suggested tweak
   private _getDomainFromCustomRefreshCookieName(name: string): string | null {
     const prefix = `${this._refreshTokenCookieName}--custom-`;
     if (!name.startsWith(prefix)) return null;
     try {
-      return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
+      return new TextDecoder().decode(decodeBase32(name.slice(prefix.length))).toLowerCase();
     } catch {
       return null;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`
around lines 626 - 634, The decoded domain returned by
_getDomainFromCustomRefreshCookieName should be normalized to lowercase for
consistency with _getCustomRefreshCookieName; after decoding (inside the try
block) call .toLowerCase() on the decoded string before returning it so the
function always returns a canonical lowercase domain (still returning null on
failures).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`:
- Around line 626-634: The decoded domain returned by
_getDomainFromCustomRefreshCookieName should be normalized to lowercase for
consistency with _getCustomRefreshCookieName; after decoding (inside the try
block) call .toLowerCase() on the decoded string before returning it so the
function always returns a canonical lowercase domain (still returning null on
failures).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b006e776-69d1-4166-9265-356dab6a34d2

📥 Commits

Reviewing files that changed from the base of the PR and between df5c588 and 69405ae.

📒 Files selected for processing (1)
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 1, 2026

Greptile Summary

This PR fixes two related cross-subdomain cookie issues in _StackClientAppImplIncomplete: stale domain-scoped refresh cookies surviving sign-out because the domain attribute was missing from the delete call, and a race condition where the trusted parent domain cache could be cold when a cookie write was needed immediately after sign-in.

Changes:

  • Adds _getDomainFromCustomRefreshCookieName(name) to reverse the base32 encoding used in _getCustomRefreshCookieName, extracting the target domain from the cookie name.
  • In both browser (deleteCookieClient) and Next.js server (deleteCookie) paths, the extracted domain is now forwarded when deleting custom cross-subdomain cookies, so the Set-Cookie: Max-Age=0; Domain=example.com header is emitted correctly.
  • Eagerly calls this._trustedParentDomainCache.getOrWait(...) in the constructor (browser + cookie store only) so the API response is already cached by the time _queueCustomRefreshCookieUpdate needs it after sign-in navigation.
  • Adds the required decodeBase32 import alongside the existing encodeBase32.

The logic is sound: non-custom cookies (legacy names, --default variants, and __Host- prefixed cookies) still decode to null and are deleted without a domain, which is correct since __Host- cookies must not carry a domain attribute.

Confidence Score: 5/5

  • Safe to merge — changes are narrowly scoped, correctly invert the existing base32 encoding, and the pre-existing cookie infra already handles the domain option correctly.
  • No P0 or P1 issues found. The new helper precisely mirrors the encoding logic in _getCustomRefreshCookieName. The domain is forwarded through both the client-side and server-side delete paths. Non-domain cookies (legacy/--default/__Host- variants) decode to null and fall back to the old behaviour, which is correct. The constructor warm-up is safely gated on isBrowserLike() and the relevant token store types.
  • No files require special attention.

Important Files Changed

Filename Overview
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts Adds domain extraction helper for custom refresh cookie names, passes domain when deleting cross-subdomain cookies, and eagerly warms the trusted-parent-domain cache on app construction to prevent a sign-in race condition.

Sequence Diagram

sequenceDiagram
    participant App as StackClientApp (constructor)
    participant Cache as _trustedParentDomainCache
    participant API as getClientProject API
    participant Store as BrowserCookieTokenStore
    participant Cookie as deleteCookieClient / deleteCookie

    App->>Cache: getOrWait([hostname], "write-only") [eager warm-up]
    Cache->>API: _getTrustedParentDomain(hostname)
    API-->>Cache: parentDomain (e.g. "example.com")

    Note over App,Store: On sign-out / token update

    Store->>Store: _prepareRefreshCookieUpdate()<br/>→ cookieNamesToDelete = [custom cookie names]
    loop For each name in cookieNamesToDelete
        Store->>Store: _getDomainFromCustomRefreshCookieName(name)<br/>→ decodes base32 → domain string or null
        Store->>Cookie: deleteCookieClient(name, { domain } or {})
    end
    Cookie-->>Store: Cookie deleted (with correct domain scope)
Loading

Reviews (1): Last reviewed commit: "Fix cross-subdomain cookie deletion and ..." | Re-trigger Greptile

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

🧹 Nitpick comments (1)
apps/e2e/tests/js/cookies.test.ts (1)

317-318: Avoid as any access for private method calls in tests.

Line 317, Line 318, and Lines 327-329 bypass type safety. Prefer a typed exported helper (or a test-only public wrapper) so these tests don’t rely on any casts.

As per coding guidelines: “Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it.” and “Try to avoid the any type.”

Also applies to: 327-329

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/e2e/tests/js/cookies.test.ts` around lines 317 - 318, Tests are using
unsafe casts to call private methods on clientApp (_getCustomRefreshCookieName
and _getDomainFromCustomRefreshCookieName); instead, add a small typed test
helper or a test-only public wrapper that exposes these behaviors (e.g., export
a getCustomRefreshCookieNameForTest or expose a
ClientApp.testHelpers.getDomainFromCookieName) and update the test to call that
typed helper instead of using (clientApp as any), ensuring the helper returns
proper TypeScript types for cookieName and decoded domain so the tests no longer
bypass the type system.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/e2e/tests/js/cookies.test.ts`:
- Around line 323-330: Add a test case to exercise the decode-failure branch for
custom-prefixed cookie names in the existing spec: when calling (clientApp as
any)._getDomainFromCustomRefreshCookieName with a malformed custom cookie like
`stack-refresh-${clientApp.projectId}--custom-%%%` it should return null; update
the "should return null for non-custom refresh cookie names" test (which already
uses createApp() and getDefaultRefreshCookieName) to include this malformed
`--custom-` case and assert .toBeNull() to cover the guarded decode error path.

---

Nitpick comments:
In `@apps/e2e/tests/js/cookies.test.ts`:
- Around line 317-318: Tests are using unsafe casts to call private methods on
clientApp (_getCustomRefreshCookieName and
_getDomainFromCustomRefreshCookieName); instead, add a small typed test helper
or a test-only public wrapper that exposes these behaviors (e.g., export a
getCustomRefreshCookieNameForTest or expose a
ClientApp.testHelpers.getDomainFromCookieName) and update the test to call that
typed helper instead of using (clientApp as any), ensuring the helper returns
proper TypeScript types for cookieName and decoded domain so the tests no longer
bypass the type system.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: cc825b9b-035b-45d0-9f8d-2187cae54d04

📥 Commits

Reviewing files that changed from the base of the PR and between 69405ae and aa99287.

📒 Files selected for processing (1)
  • apps/e2e/tests/js/cookies.test.ts

Comment thread apps/e2e/tests/js/cookies.test.ts
@BilalG1 BilalG1 requested a review from N2D4 April 1, 2026 01:21
@BilalG1 BilalG1 assigned N2D4 and unassigned BilalG1 Apr 1, 2026
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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/e2e/tests/js/cookies.test.ts`:
- Around line 361-428: The test only deletes the custom cookie but doesn't seed
the legacy refresh-cookie names used by older clients; before constructing the
new StackClientApp instance (the reloadedApp), also set either
getDefaultRefreshCookieName(clientApp.projectId, false) or the legacy
"stack-refresh" (or "stack-refresh-${projectId}") with the signed-in refresh
token value (use the parsed.refresh_token and updated_at_millis as in
defaultValue) so the constructor can run its migration; then assert
cookieStore.has(customCookieName) becomes true after construction (same
waitUntil/customRecreated assertion). Locate symbols: StackClientApp,
getDefaultRefreshCookieName, getCustomRefreshCookieName, cookieStore, waitUntil.

In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`:
- Around line 627-635: The _getDomainFromCustomRefreshCookieName function
currently returns any decodable payload as a domain; change it to normalize and
validate the decoded hostname before returning: after decoding via
decodeBase32(name.slice(prefix.length)) and TextDecoder(), trim/normalize
(lowercase, remove surrounding dots/spaces), then validate against a
cookie-domain pattern (e.g., host label rules or a strict regex that allows
optional leading dot and valid hostname characters/length) and return null if it
does not match; keep references to this._refreshTokenCookieName and decodeBase32
in your implementation so callers at lines that pass the result to cookie
helpers receive only validated domains.
- Around line 792-796: _extractRefreshTokenFromCookieMap currently returns
updatedAt: null for legacy cookie names, so the constructor skips migration
because the code requires updatedAt; change the check to migrate when
refreshToken exists even if updatedAt is null: if refreshToken is present, set a
fallback updatedAt (e.g., Date.now()) before calling
this._formatRefreshCookieValue(refreshToken, updatedAtFallback) and then call
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365,
domain: domain.data }); add a "// TODO next-release" comment near this fallback
so the shim can be removed in the next release; keep references to
_extractRefreshTokenFromCookieMap, _formatRefreshCookieValue,
setOrDeleteCookieClient and customCookieName to locate the change.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: a93c1366-0b04-4aef-a3dd-5cdf2fd9f7d4

📥 Commits

Reviewing files that changed from the base of the PR and between e1a16b1 and e673169.

📒 Files selected for processing (2)
  • apps/e2e/tests/js/cookies.test.ts
  • packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Comment on lines +361 to +428
it("should eagerly create cross-subdomain cookie on construction when session exists but custom cookie is missing", async ({ expect }) => {
const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" });

const { clientApp, apiKey } = await createApp(
{
config: {
domains: [
{ domain: "https://example.com", handlerPath: "/handler" },
{ domain: "https://**.example.com", handlerPath: "/handler" },
],
},
},
{
client: {
tokenStore: "cookie",
noAutomaticPrefetch: true,
},
},
);

// Sign in to get a valid session
const email = `${crypto.randomUUID()}@eager-cookie.test`;
const password = "password";
await clientApp.signUpWithCredential({ email, password, verificationCallbackUrl: "http://localhost:3000", noRedirect: true });
await clientApp.signInWithCredential({ email, password, noRedirect: true });

const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true);
const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com");

// Wait for the cross-subdomain cookie to be written
const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customReady).toBe(true);

// Grab the refresh token before we manipulate cookies
const customCookieValue = cookieStore.get(customCookieName)!;
const parsed = JSON.parse(decodeURIComponent(customCookieValue));

// Simulate state where user was signed in before wildcard domain was added:
// default cookie exists with the session, but no cross-subdomain cookie
cookieStore.delete(customCookieName);
const defaultValue = encodeURIComponent(JSON.stringify({
refresh_token: parsed.refresh_token,
updated_at_millis: parsed.updated_at_millis,
}));
cookieStore.set(defaultCookieName, defaultValue);

expect(cookieStore.has(customCookieName)).toBe(false);
expect(cookieStore.has(defaultCookieName)).toBe(true);

// Construct a new client app (simulates page reload)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const reloadedApp = new StackClientApp({
baseUrl: STACK_BACKEND_BASE_URL,
projectId: clientApp.projectId,
publishableClientKey: apiKey.publishableClientKey,
tokenStore: "cookie",
redirectMethod: "none",
noAutomaticPrefetch: true,
extraRequestHeaders: { "x-stack-disable-artificial-development-delay": "yes" },
});

// The cross-subdomain cookie should be eagerly created on construction
const customRecreated = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customRecreated).toBe(true);

// Clean up
(reloadedApp as any).dispose?.();
});
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

Add a regression for legacy-cookie upgrade on app construction.

This scenario only deletes the custom cookie. It does not cover the supported legacy refresh-cookie names, which are the sessions most likely to rely on this constructor-time migration path. Seed stack-refresh-${projectId} or stack-refresh with the signed-in refresh token and assert that constructing a new app recreates the parent-domain custom cookie.

Based on learnings: Applies to **/apps/e2e/**/*.{ts,tsx} — ALWAYS add new E2E tests when you change the API or SDK interface. Generally, err on the side of creating too many tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/e2e/tests/js/cookies.test.ts` around lines 361 - 428, The test only
deletes the custom cookie but doesn't seed the legacy refresh-cookie names used
by older clients; before constructing the new StackClientApp instance (the
reloadedApp), also set either getDefaultRefreshCookieName(clientApp.projectId,
false) or the legacy "stack-refresh" (or "stack-refresh-${projectId}") with the
signed-in refresh token value (use the parsed.refresh_token and
updated_at_millis as in defaultValue) so the constructor can run its migration;
then assert cookieStore.has(customCookieName) becomes true after construction
(same waitUntil/customRecreated assertion). Locate symbols: StackClientApp,
getDefaultRefreshCookieName, getCustomRefreshCookieName, cookieStore, waitUntil.

Comment on lines +627 to +635
private _getDomainFromCustomRefreshCookieName(name: string): string | null {
const prefix = `${this._refreshTokenCookieName}--custom-`;
if (!name.startsWith(prefix)) return null;
try {
return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
} catch {
return null;
}
}
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

Validate the decoded hostname before returning it.

Line 631 trusts any decodable payload as a hostname. A --custom- suffix whose base32 payload decodes to something like bad domain will currently come back as a non-null string, and Lines 893 and 953 then pass it straight into the cookie helpers as domain. Normalize the decoded value and return null unless it matches a real cookie-domain shape.

As per coding guidelines: Any assumption you make should either be validated through the type system (preferred), assertions, or tests. Optimally, two out of three.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`
around lines 627 - 635, The _getDomainFromCustomRefreshCookieName function
currently returns any decodable payload as a domain; change it to normalize and
validate the decoded hostname before returning: after decoding via
decodeBase32(name.slice(prefix.length)) and TextDecoder(), trim/normalize
(lowercase, remove surrounding dots/spaces), then validate against a
cookie-domain pattern (e.g., host label rules or a strict regex that allows
optional leading dot and valid hostname characters/length) and return null if it
does not match; keep references to this._refreshTokenCookieName and decodeBase32
in your implementation so callers at lines that pass the result to cookie
helpers receive only validated domains.

Comment on lines +792 to +796
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
}
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

Migrate legacy refresh cookies into the new cross-subdomain format.

Line 793 requires updatedAt, but _extractRefreshTokenFromCookieMap() returns updatedAt: null for the still-supported legacy refresh-cookie names. That means already-signed-in users coming from stack-refresh-${projectId} or stack-refresh will skip this constructor-time repair and keep missing the parent-domain cookie until the next token write or a full sign-in.

💡 Suggested migration-friendly fix
       const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
-      if (refreshToken && updatedAt) {
-        const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
-        setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
-      }
+      if (!refreshToken) {
+        return;
+      }
+      // TODO next-release: remove the legacy-cookie timestamp fallback once all active sessions use structured refresh cookies
+      const value = this._formatRefreshCookieValue(refreshToken, updatedAt ?? Date.now());
+      setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });

Based on learnings: In the stack-auth/stack-auth codebase, the convention // TODO next-release is used to mark temporary backward-compatibility shims that only need to survive a single release cycle and can be safely removed in the next release.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
}
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
if (!refreshToken) {
return;
}
// TODO next-release: remove the legacy-cookie timestamp fallback once all active sessions use structured refresh cookies
const value = this._formatRefreshCookieValue(refreshToken, updatedAt ?? Date.now());
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`
around lines 792 - 796, _extractRefreshTokenFromCookieMap currently returns
updatedAt: null for legacy cookie names, so the constructor skips migration
because the code requires updatedAt; change the check to migrate when
refreshToken exists even if updatedAt is null: if refreshToken is present, set a
fallback updatedAt (e.g., Date.now()) before calling
this._formatRefreshCookieValue(refreshToken, updatedAtFallback) and then call
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365,
domain: domain.data }); add a "// TODO next-release" comment near this fallback
so the shim can be removed in the next release; keep references to
_extractRefreshTokenFromCookieMap, _formatRefreshCookieValue,
setOrDeleteCookieClient and customCookieName to locate the change.

@github-actions github-actions Bot assigned BilalG1 and unassigned N2D4 Apr 2, 2026
Comment on lines +793 to +794
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
if (refreshToken) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt ?? Date.now());

Users with legacy cookies (like stack-refresh or the legacy refresh token cookie) never get cross-subdomain cookies created because _ensureCrossSubdomainCookieExists requires updatedAt to be truthy, but legacy cookies return updatedAt: null.

Fix on Vercel

@BilalG1 BilalG1 merged commit e2fbe2c into dev Apr 4, 2026
33 of 35 checks passed
@BilalG1 BilalG1 deleted the fix/cross-subdomain-cookie-sharing branch April 4, 2026 00:10
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