[GH-ISSUE #7999] getAccessToken overwrites refresh_token with undefined for providers that don't return it on refresh #19599

Closed
opened 2026-04-15 18:52:30 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @async3619 on GitHub (Feb 16, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7999

Is this suited for github?

  • Yes, this is suited for github

Describe the bug

When using getAccessToken with Google OAuth (stateless mode, storeAccountCookie: true), the refresh token gets wiped out after the first successful token refresh.

Google's token refresh endpoint (oauth2.googleapis.com/token) does not return a new refresh_token in the response — it only returns a new access_token. This is expected behavior from Google.

However, in getAccessToken handler (api/routes/account.ts), the updatedData object always includes refreshToken from the refresh response, even when it's undefined:

newTokens = await provider.refreshAccessToken(refreshToken$1);
const updatedData = {
    accessToken: await setTokenUtil(newTokens.accessToken, ctx.context),
    accessTokenExpiresAt: newTokens.accessTokenExpiresAt,
    refreshToken: await setTokenUtil(newTokens.refreshToken, ctx.context), // undefined for Google
    refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt
};
// ...
if (ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, {
    ...account,
    ...updatedAccount ?? updatedData  // refreshToken: undefined overwrites the original
});

The spread { ...account, ...updatedData } overwrites account.refreshToken with undefined.

To Reproduce

  1. Set up Google OAuth with accessType: 'offline', storeAccountCookie: true, no database
  2. Log in — refresh token is stored in the account cookie
  3. Wait for access token to expire (~1 hour) or test with a short-lived token
  4. getAccessToken triggers refresh — succeeds, but the cookie is updated with refreshToken: undefined
  5. Next refresh attempt fails because account.refreshToken is now falsy:
    if (account.refreshToken && accessTokenExpired && provider.refreshAccessToken) {
    //  ^^^^^^^^^^^^^^^^^^^^^ → false, refresh skipped
    

Expected behavior

The original refresh token should be preserved when the provider doesn't return a new one. Something like:

const updatedData = {
    accessToken: await setTokenUtil(newTokens.accessToken, ctx.context),
    accessTokenExpiresAt: newTokens.accessTokenExpiresAt,
    refreshToken: newTokens.refreshToken
        ? await setTokenUtil(newTokens.refreshToken, ctx.context)
        : account.refreshToken,  // keep the original
    refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt,
};

The same pattern exists in the refreshToken endpoint as well (same file, different handler).

Workaround

Provide a custom refreshAccessToken that explicitly returns the original refresh token:

google: {
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    accessType: 'offline',
    prompt: 'consent',
    refreshAccessToken: async (refreshToken) => {
        const response = await fetch('https://oauth2.googleapis.com/token', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                grant_type: 'refresh_token',
                refresh_token: refreshToken,
                client_id: process.env.GOOGLE_CLIENT_ID!,
                client_secret: process.env.GOOGLE_CLIENT_SECRET!,
            }),
        })
        const data = await response.json()
        return {
            accessToken: data.access_token,
            refreshToken, // preserve original
            accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
        }
    },
},

Additional context

This likely affects any OAuth provider that doesn't return a new refresh token on refresh (Google, and possibly others). The built-in refreshAccessToken in oauth2/refresh-access-token.ts maps data.refresh_token directly, which is undefined when the provider omits it.

Version: better-auth 1.4.18

Originally created by @async3619 on GitHub (Feb 16, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7999 ### Is this suited for github? - [x] Yes, this is suited for github ### Describe the bug When using `getAccessToken` with Google OAuth (stateless mode, `storeAccountCookie: true`), the refresh token gets wiped out after the first successful token refresh. Google's token refresh endpoint (`oauth2.googleapis.com/token`) does **not** return a new `refresh_token` in the response — it only returns a new `access_token`. This is expected behavior from Google. However, in `getAccessToken` handler (`api/routes/account.ts`), the `updatedData` object always includes `refreshToken` from the refresh response, even when it's `undefined`: ```ts newTokens = await provider.refreshAccessToken(refreshToken$1); const updatedData = { accessToken: await setTokenUtil(newTokens.accessToken, ctx.context), accessTokenExpiresAt: newTokens.accessTokenExpiresAt, refreshToken: await setTokenUtil(newTokens.refreshToken, ctx.context), // undefined for Google refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt }; // ... if (ctx.context.options.account?.storeAccountCookie) await setAccountCookie(ctx, { ...account, ...updatedAccount ?? updatedData // refreshToken: undefined overwrites the original }); ``` The spread `{ ...account, ...updatedData }` overwrites `account.refreshToken` with `undefined`. ### To Reproduce 1. Set up Google OAuth with `accessType: 'offline'`, `storeAccountCookie: true`, no database 2. Log in — refresh token is stored in the account cookie 3. Wait for access token to expire (~1 hour) or test with a short-lived token 4. `getAccessToken` triggers refresh — succeeds, but the cookie is updated with `refreshToken: undefined` 5. Next refresh attempt fails because `account.refreshToken` is now falsy: ```ts if (account.refreshToken && accessTokenExpired && provider.refreshAccessToken) { // ^^^^^^^^^^^^^^^^^^^^^ → false, refresh skipped ``` ### Expected behavior The original refresh token should be preserved when the provider doesn't return a new one. Something like: ```ts const updatedData = { accessToken: await setTokenUtil(newTokens.accessToken, ctx.context), accessTokenExpiresAt: newTokens.accessTokenExpiresAt, refreshToken: newTokens.refreshToken ? await setTokenUtil(newTokens.refreshToken, ctx.context) : account.refreshToken, // keep the original refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt, }; ``` The same pattern exists in the `refreshToken` endpoint as well (same file, different handler). ### Workaround Provide a custom `refreshAccessToken` that explicitly returns the original refresh token: ```ts google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, accessType: 'offline', prompt: 'consent', refreshAccessToken: async (refreshToken) => { const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: process.env.GOOGLE_CLIENT_ID!, client_secret: process.env.GOOGLE_CLIENT_SECRET!, }), }) const data = await response.json() return { accessToken: data.access_token, refreshToken, // preserve original accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000), } }, }, ``` ### Additional context This likely affects any OAuth provider that doesn't return a new refresh token on refresh (Google, and possibly others). The built-in `refreshAccessToken` in `oauth2/refresh-access-token.ts` maps `data.refresh_token` directly, which is `undefined` when the provider omits it. **Version**: better-auth 1.4.18
GiteaMirror added the lockedbug labels 2026-04-15 18:52:30 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Feb 16, 2026):

Hi @async3619! I'm Dosu and I’m helping the better-auth team.

This is a confirmed bug. The getAccessToken handler always includes refreshToken in updatedData even when the provider doesn't return one, and the spread operator then overwrites the original value with undefined.

The refreshAccessToken function correctly treats refreshToken as optional (returning data.refresh_token which can be undefined), but the calling code doesn't handle this properly.

Your suggested fix is exactly right. An alternative approach using conditional spreading:

const updatedData = {
    accessToken: await setTokenUtil(newTokens.accessToken, ctx.context),
    accessTokenExpiresAt: newTokens.accessTokenExpiresAt,
    ...(newTokens.refreshToken !== undefined && {
        refreshToken: await setTokenUtil(newTokens.refreshToken, ctx.context),
    }),
    ...(newTokens.refreshTokenExpiresAt !== undefined && {
        refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt,
    }),
};

This affects any provider that doesn't return new refresh tokens on refresh—Google being the most common example. Your workaround with a custom refreshAccessToken that preserves the original token is the correct approach until this is fixed.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3909981726 --> @dosubot[bot] commented on GitHub (Feb 16, 2026): <!-- Greeting --> Hi @async3619! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a confirmed bug. The [getAccessToken handler](https://github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/better-auth/src/api/routes/account.ts#L565-L577) always includes `refreshToken` in `updatedData` even when the provider doesn't return one, and the spread operator then overwrites the original value with `undefined`. The [refreshAccessToken function](https://github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/core/src/oauth2/refresh-access-token.ts#L108-L121) correctly treats `refreshToken` as optional (returning `data.refresh_token` which can be undefined), but the calling code doesn't handle this properly. Your suggested fix is exactly right. An alternative approach using conditional spreading: ```ts const updatedData = { accessToken: await setTokenUtil(newTokens.accessToken, ctx.context), accessTokenExpiresAt: newTokens.accessTokenExpiresAt, ...(newTokens.refreshToken !== undefined && { refreshToken: await setTokenUtil(newTokens.refreshToken, ctx.context), }), ...(newTokens.refreshTokenExpiresAt !== undefined && { refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt, }), }; ``` This affects any provider that doesn't return new refresh tokens on refresh—Google being the most common example. Your workaround with a custom `refreshAccessToken` that preserves the original token is the correct approach until this is fixed. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/e5986f06-b2d1-4caf-940a-b0788d183b60?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20confirmed%20bug.%20The%20%5BgetAccessToken%20handler%5D%28https%3A//github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/better-auth/src/api/routes/account.ts%23L565-L577%29%20always%20includes%20%60refreshToken%60%20in%20%60updatedData%60%20even%20when%20the%20provider%20doesn%27t%20return%20one%2C%20and%20the%20spread%20operator%20then%20overwrites%20the%20original%20value%20with%20%60undefined%60.%0A%0AThe%20%5BrefreshAccessToken%20function%5D%28https%3A//github.com/better-auth/better-auth/blob/d3b579e42a707a9037a9210ad14611366b745d2a/packages/core/src/oauth2/refresh-access-token.ts%23L108-L121%29%20correctly%20treats%20%60refreshToken%60%20as%20optional%20%28returning%20%60data.refresh_token%60%20which%20can%20be%20undefined%29%2C%20but%20the%20calling%20code%20doesn%27t%20handle%20this%20properly.%0A%0AYour%20suggested%20fix%20is%20exactly%20right.%20An%20alternative%20approach%20using%20conditional%20spreading%3A%0A%0A%60%60%60ts%0Aconst%20updatedData%20%3D%20%7B%0A%20%20%20%20accessToken%3A%20await%20setTokenUtil%28newTokens.accessToken%2C%20ctx.context%29%2C%0A%20%20%20%20accessTokenExpiresAt%3A%20newTokens.accessTokenExpiresAt%2C%0A%20%20%20%20...%28newTokens.refreshToken%20%21%3D%3D%20undefined%20%26%26%20%7B%0A%20%20%20%20%20%20%20%20refreshToken%3A%20await%20setTokenUtil%28newTokens.refreshToken%2C%20ctx.context%29%2C%0A%20%20%20%20%7D%29%2C%0A%20%20%20%20...%28newTokens.refreshTokenExpiresAt%20%21%3D%3D%20undefined%20%26%26%20%7B%0A%20%20%20%20%20%20%20%20refreshTokenExpiresAt%3A%20newTokens.refreshTokenExpiresAt%2C%0A%20%20%20%20%7D%29%2C%0A%7D%3B%0A%60%60%60%0A%0AThis%20affects%20any%20provider%20that%20doesn%27t%20return%20new%20refresh%20tokens%20on%20refresh%E2%80%94Google%20being%20the%20most%20common%20example.%20Your%20workaround%20with%20a%20custom%20%60refreshAccessToken%60%20that%20preserves%20the%20original%20token%20is%20the%20correct%20approach%20until%20this%20is%20fixed.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/7999)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19599