[GH-ISSUE #7394] Stateless session and refresh token #28124

Closed
opened 2026-04-17 19:31:48 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @sergio-milu on GitHub (Jan 15, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7394

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Hey all,

I'm trying to migrate from next-auth to better auth with the stateless mode.

To add some context, i got a third party backed with two endpoints

  • login -> that give me access_token, refresh_token, and expires_at
  • refresh access token -> refresh the current access token for a new one

For this, i created a custom plugin with two endpoints, signIn and refreshToken, while access token is valid every thing works as expected

My issue start when calling refresh tokens, as you can see I call setSessionCookie, but after that, when i read auth.api.getSession I got the old tokens instead of the refreshed ones.

I've been playing with maxAge config, but if this is to short I got sign out instead of trying to refresh (my proxy kicks me out).

I handle refresh token in my api client

Is there a way to this this?

Plugin

import {
  APIError,
  createAuthEndpoint,
  getSessionFromCtx,
} from 'better-auth/api';
import { setSessionCookie } from 'better-auth/cookies';
import { z } from 'zod';
import { authRefresh, authVerifyOtp } from '../api';
import type { RefreshTokenResponseDto } from '../api';
import type { Response as MiluResponse } from '@milu/shared/types/base.types';
import type { BetterAuthPlugin } from 'better-auth';


const RefreshTokenSchema = z.object({
  refreshToken: z.string(),
});

const SignInSchema = z.union([
  z.object({
    email: z.email(),
    phoneNumber: z.never().optional(),
    code: z.string(),
  }),
  z.object({
    phoneNumber: z.string(),
    email: z.never().optional(),
    code: z.string(),
  }),
]);

const createSession = (tokens: {
  accessToken: string;
  refreshToken: string;
  idToken: string;
  accessTokenExpires: number;
}) => ({
  session: {
    id: tokens.idToken,
    userId: 'not-used',
    token: 'not-used',
    createdAt: new Date(),
    updatedAt: new Date(),
    expiresAt: new Date(tokens.accessTokenExpires),
    tokens: {
      accessToken: tokens.accessToken,
      refreshToken: tokens.refreshToken,
      idToken: tokens.idToken,
      accessTokenExpires: tokens.accessTokenExpires,
    },
  },
  user: {
    id: tokens.idToken,
    email: '',
    name: '',
    emailVerified: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  },
});

const inFlightRefreshes: Map<
  string,
  Promise<MiluResponse<RefreshTokenResponseDto>>
> = new Map();

const refreshAccessToken = (refreshToken: string) => {
  const existingPromise = inFlightRefreshes.get(refreshToken);
  if (existingPromise) {
    return existingPromise;
  }

  // Set up cleanup timeout
  const timeoutId = setTimeout(() => {
    inFlightRefreshes.delete(refreshToken);
  }, 30 * 1000);

  // Create wrapped promise and set immediately to prevent race conditions
  const wrappedPromise = authRefresh(
    { refreshToken },
    { isPublic: true }
  ).finally(() => {
    clearTimeout(timeoutId);
    inFlightRefreshes.delete(refreshToken);
  });

  inFlightRefreshes.set(refreshToken, wrappedPromise);

  return wrappedPromise;
};

export const passwordlessPlugin = () => {
  return {
    id: 'passwordless',
    schema: {
      session: {
        fields: {
          tokens: {
            type: 'json',
          },
        },
      },
    },
    endpoints: {
      signIn: createAuthEndpoint(
        '/passwordless/sign-in',
        {
          method: 'POST',
          body: SignInSchema,
        },
        async (ctx) => {
          const body = ctx.body;

          const { data, error } = await authVerifyOtp(
            {
              deliveryMethod: body.email ? 'email' : 'sms',
              code: body.code,
              destination: body.email ?? body.phoneNumber,
            },
            { isPublic: true }
          );

          if (error) {
            throw new APIError('UNAUTHORIZED', {
              message: error.message ?? 'Unknown error',
            });
          }

          const tokens = {
            idToken: data.idToken,
            accessToken: data.accessToken,
            refreshToken: data.refreshToken,
            accessTokenExpires: data.accessTokenExpires,
          };

          const session = createSession(tokens);
          await setSessionCookie(ctx, session);

          return ctx.json({
            ok: true,
            session: session.session,
            tokens,
          });
        }
      ),
      refreshTokens: createAuthEndpoint(
        '/passwordless/refresh-tokens',
        {
          method: 'POST',
          body: RefreshTokenSchema,
        },
        async (ctx) => {
          const body = ctx.body;

          // Get current session from context
          const session = await getSessionFromCtx(ctx);

          if (!session) {
            throw new APIError('UNAUTHORIZED', {
              message: 'No session found',
            });
          }

          const { error, data } = await refreshAccessToken(body.refreshToken);

          if (error) {
            throw new APIError('UNAUTHORIZED', {
              message: error?.message ?? 'Failed to refresh tokens',
            });
          }

          const tokens = {
            accessToken: data.accessToken,
            refreshToken: data.refreshToken,
            idToken: session.session.id, // Preserve original session ID
            accessTokenExpires: data.accessTokenExpires,
          };

          // Create a fresh session object using the same helper
          const updatedSession = createSession(tokens);

          // Preserve the original user data
          updatedSession.user = session.user;

          // Update session cookie with new tokens
          await setSessionCookie(ctx, updatedSession);

          return ctx.json({
            ok: true,
            session: updatedSession.session,
            tokens,
          });
        }
      ),
    },
  } satisfies BetterAuthPlugin;
};

auth config

import { betterAuth } from 'better-auth';
import { nextCookies } from 'better-auth/next-js';
import { customSession } from 'better-auth/plugins';
import { passwordlessPlugin } from './passwordless.plugin';
import type { BetterAuthOptions } from 'better-auth';

const options = {
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 7 * 24 * 60 * 60, // 7 days cache duration
      strategy: 'jwt',
      refreshCache: false,
    },
  },
  account: {
    storeStateStrategy: 'cookie',
    storeAccountCookie: true,
  },
  plugins: [nextCookies(), passwordlessPlugin()],
} satisfies BetterAuthOptions;

export const auth = betterAuth({
  ...options,
  plugins: [
    ...(options.plugins ?? []),
    customSession(({ user, session }) => {
      return Promise.resolve({
        user,
        session: {
          ...session,
          tokens: {
            accessToken: session.tokens['accessToken'] as string,
            refreshToken: session.tokens['refreshToken'] as string,
            accessTokenExpires: session.tokens['accessTokenExpires'] as number,
          },
        },
      });
    }, options),
  ],
});

export type Session = typeof auth.$Infer.Session;

my proxy

const proxy = async (
  request: NextRequest
): Promise<ReturnType<NextProxy>> => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    console.log('¡No session, redirecting to login!');
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return await helmetMiddleware(NextResponse.next());
};

api client

const tryRefreshToken = async (tokens: SessionTokens) => {
  const { isBrowser } = isSsr();

  // Proactively refresh if token expired or expiring soon (5 min buffer)
  const now = Date.now();
  const expiresIn = tokens.accessTokenExpires - now;
  // Token expired or expiring within 5 minutes
  if (expiresIn < 5 * 60 * 1000) {
    try {
      if (isBrowser) {
        const { data } = await authClient.passwordless.refreshTokens({
          refreshToken: tokens.refreshToken,
        });

        if (!data?.session?.tokens) {
          throw new Error('No tokens returned');
        }

        return data.session.tokens;
      } else {
        const { headers } = await import('next/headers');
        const headersList = await headers();

        const { session } = await auth.api.refreshTokens({
          body: { refreshToken: tokens.refreshToken },
          headers: headersList,
        });

        if (!session?.tokens) {
          throw new Error('No tokens returned');
        }

        return session.tokens;
      }
    } catch (error) {
      console.log('ERROR REFRESHING TOKEN,', error);
      console.log(tokens);
      await signOut();
      return null;
    }
  }

  return tokens;
};

export const getAuthSession = async (): Promise<SessionTokens | null> => {
  if (
    process.env['NEXT_PUBLIC_TESTING'] ||
    process.env['API_MODE'] === 'stubs'
  ) {
    return {
      accessToken: 'test-access-token',
      refreshToken: 'test-refresh-token',
      accessTokenExpires: Date.now() + 30 * 60 * 1000, // 30 minutes
    };
  }

  const { isBrowser } = isSsr();

  if (isBrowser) {
    const { data, error } = await authClient.getSession();

    if (error || !data) return null;

    const tokens = data.session.tokens;

    const refreshedTokens = await tryRefreshToken(tokens);

    return refreshedTokens;
  }

  const { headers } = await import('next/headers');
  const headersList = await headers();
  const session = await auth.api.getSession({
    headers: headersList,
  });

  if (!session) return null;

  const tokens = session.session.tokens;

  const refreshedTokens = await tryRefreshToken(tokens);

  console.log('server refreshed', refreshedTokens);
  return refreshedTokens;
};

Current vs. Expected behavior

I want to find away to refresh the cookies when i know a new refresh token and access token is available

What version of Better Auth are you using?

1.4.13

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:26 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6041",
    "release": "25.0.0",
    "cpuCount": 12,
    "cpuModel": "Apple M4 Pro",
    "totalMemory": "24.00 GB",
    "freeMemory": "0.30 GB"
  },
  "node": {
    "version": "v22.19.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.4"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.0.10"
    },
    {
      "name": "react",
      "version": "19.2.2"
    }
  ],
  "databases": [
    {
      "name": "better-sqlite3",
      "version": "12.2.0"
    },
    {
      "name": "pg",
      "version": "^8.11.3"
    }
  ],
  "betterAuth": {
    "version": "^1.4.13",
    "config": null
  }

Which area(s) are affected? (Select all that apply)

Other

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
   session: {
    cookieCache: {
      enabled: true,
      maxAge: 7 * 24 * 60 * 60, // 7 days cache duration
      strategy: 'jwt',
      refreshCache: false,
    },
  },
  account: {
    storeStateStrategy: 'cookie',
    storeAccountCookie: true,
  },
  plugins: [nextCookies(), passwordlessPlugin()],
});

Additional context

No response

Originally created by @sergio-milu on GitHub (Jan 15, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7394 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Hey all, I'm trying to migrate from next-auth to better auth with the stateless mode. To add some context, i got a third party backed with two endpoints - login -> that give me access_token, refresh_token, and expires_at - refresh access token -> refresh the current access token for a new one For this, i created a custom plugin with two endpoints, signIn and refreshToken, while access token is valid every thing works as expected My issue start when calling refresh tokens, as you can see I call setSessionCookie, but after that, when i read `auth.api.getSession` I got the old tokens instead of the refreshed ones. I've been playing with maxAge config, but if this is to short I got sign out instead of trying to refresh (my proxy kicks me out). I handle refresh token in my api client Is there a way to this this? Plugin ``` import { APIError, createAuthEndpoint, getSessionFromCtx, } from 'better-auth/api'; import { setSessionCookie } from 'better-auth/cookies'; import { z } from 'zod'; import { authRefresh, authVerifyOtp } from '../api'; import type { RefreshTokenResponseDto } from '../api'; import type { Response as MiluResponse } from '@milu/shared/types/base.types'; import type { BetterAuthPlugin } from 'better-auth'; const RefreshTokenSchema = z.object({ refreshToken: z.string(), }); const SignInSchema = z.union([ z.object({ email: z.email(), phoneNumber: z.never().optional(), code: z.string(), }), z.object({ phoneNumber: z.string(), email: z.never().optional(), code: z.string(), }), ]); const createSession = (tokens: { accessToken: string; refreshToken: string; idToken: string; accessTokenExpires: number; }) => ({ session: { id: tokens.idToken, userId: 'not-used', token: 'not-used', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(tokens.accessTokenExpires), tokens: { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, idToken: tokens.idToken, accessTokenExpires: tokens.accessTokenExpires, }, }, user: { id: tokens.idToken, email: '', name: '', emailVerified: false, createdAt: new Date(), updatedAt: new Date(), }, }); const inFlightRefreshes: Map< string, Promise<MiluResponse<RefreshTokenResponseDto>> > = new Map(); const refreshAccessToken = (refreshToken: string) => { const existingPromise = inFlightRefreshes.get(refreshToken); if (existingPromise) { return existingPromise; } // Set up cleanup timeout const timeoutId = setTimeout(() => { inFlightRefreshes.delete(refreshToken); }, 30 * 1000); // Create wrapped promise and set immediately to prevent race conditions const wrappedPromise = authRefresh( { refreshToken }, { isPublic: true } ).finally(() => { clearTimeout(timeoutId); inFlightRefreshes.delete(refreshToken); }); inFlightRefreshes.set(refreshToken, wrappedPromise); return wrappedPromise; }; export const passwordlessPlugin = () => { return { id: 'passwordless', schema: { session: { fields: { tokens: { type: 'json', }, }, }, }, endpoints: { signIn: createAuthEndpoint( '/passwordless/sign-in', { method: 'POST', body: SignInSchema, }, async (ctx) => { const body = ctx.body; const { data, error } = await authVerifyOtp( { deliveryMethod: body.email ? 'email' : 'sms', code: body.code, destination: body.email ?? body.phoneNumber, }, { isPublic: true } ); if (error) { throw new APIError('UNAUTHORIZED', { message: error.message ?? 'Unknown error', }); } const tokens = { idToken: data.idToken, accessToken: data.accessToken, refreshToken: data.refreshToken, accessTokenExpires: data.accessTokenExpires, }; const session = createSession(tokens); await setSessionCookie(ctx, session); return ctx.json({ ok: true, session: session.session, tokens, }); } ), refreshTokens: createAuthEndpoint( '/passwordless/refresh-tokens', { method: 'POST', body: RefreshTokenSchema, }, async (ctx) => { const body = ctx.body; // Get current session from context const session = await getSessionFromCtx(ctx); if (!session) { throw new APIError('UNAUTHORIZED', { message: 'No session found', }); } const { error, data } = await refreshAccessToken(body.refreshToken); if (error) { throw new APIError('UNAUTHORIZED', { message: error?.message ?? 'Failed to refresh tokens', }); } const tokens = { accessToken: data.accessToken, refreshToken: data.refreshToken, idToken: session.session.id, // Preserve original session ID accessTokenExpires: data.accessTokenExpires, }; // Create a fresh session object using the same helper const updatedSession = createSession(tokens); // Preserve the original user data updatedSession.user = session.user; // Update session cookie with new tokens await setSessionCookie(ctx, updatedSession); return ctx.json({ ok: true, session: updatedSession.session, tokens, }); } ), }, } satisfies BetterAuthPlugin; }; ``` auth config ``` import { betterAuth } from 'better-auth'; import { nextCookies } from 'better-auth/next-js'; import { customSession } from 'better-auth/plugins'; import { passwordlessPlugin } from './passwordless.plugin'; import type { BetterAuthOptions } from 'better-auth'; const options = { session: { cookieCache: { enabled: true, maxAge: 7 * 24 * 60 * 60, // 7 days cache duration strategy: 'jwt', refreshCache: false, }, }, account: { storeStateStrategy: 'cookie', storeAccountCookie: true, }, plugins: [nextCookies(), passwordlessPlugin()], } satisfies BetterAuthOptions; export const auth = betterAuth({ ...options, plugins: [ ...(options.plugins ?? []), customSession(({ user, session }) => { return Promise.resolve({ user, session: { ...session, tokens: { accessToken: session.tokens['accessToken'] as string, refreshToken: session.tokens['refreshToken'] as string, accessTokenExpires: session.tokens['accessTokenExpires'] as number, }, }, }); }, options), ], }); export type Session = typeof auth.$Infer.Session; ``` my proxy ``` const proxy = async ( request: NextRequest ): Promise<ReturnType<NextProxy>> => { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) { console.log('¡No session, redirecting to login!'); return NextResponse.redirect(new URL('/login', request.url)); } return await helmetMiddleware(NextResponse.next()); }; ``` api client ``` const tryRefreshToken = async (tokens: SessionTokens) => { const { isBrowser } = isSsr(); // Proactively refresh if token expired or expiring soon (5 min buffer) const now = Date.now(); const expiresIn = tokens.accessTokenExpires - now; // Token expired or expiring within 5 minutes if (expiresIn < 5 * 60 * 1000) { try { if (isBrowser) { const { data } = await authClient.passwordless.refreshTokens({ refreshToken: tokens.refreshToken, }); if (!data?.session?.tokens) { throw new Error('No tokens returned'); } return data.session.tokens; } else { const { headers } = await import('next/headers'); const headersList = await headers(); const { session } = await auth.api.refreshTokens({ body: { refreshToken: tokens.refreshToken }, headers: headersList, }); if (!session?.tokens) { throw new Error('No tokens returned'); } return session.tokens; } } catch (error) { console.log('ERROR REFRESHING TOKEN,', error); console.log(tokens); await signOut(); return null; } } return tokens; }; export const getAuthSession = async (): Promise<SessionTokens | null> => { if ( process.env['NEXT_PUBLIC_TESTING'] || process.env['API_MODE'] === 'stubs' ) { return { accessToken: 'test-access-token', refreshToken: 'test-refresh-token', accessTokenExpires: Date.now() + 30 * 60 * 1000, // 30 minutes }; } const { isBrowser } = isSsr(); if (isBrowser) { const { data, error } = await authClient.getSession(); if (error || !data) return null; const tokens = data.session.tokens; const refreshedTokens = await tryRefreshToken(tokens); return refreshedTokens; } const { headers } = await import('next/headers'); const headersList = await headers(); const session = await auth.api.getSession({ headers: headersList, }); if (!session) return null; const tokens = session.session.tokens; const refreshedTokens = await tryRefreshToken(tokens); console.log('server refreshed', refreshedTokens); return refreshedTokens; }; ``` ### Current vs. Expected behavior I want to find away to refresh the cookies when i know a new refresh token and access token is available ### What version of Better Auth are you using? 1.4.13 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.0.0: Wed Sep 17 21:41:26 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T6041", "release": "25.0.0", "cpuCount": 12, "cpuModel": "Apple M4 Pro", "totalMemory": "24.00 GB", "freeMemory": "0.30 GB" }, "node": { "version": "v22.19.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.4" }, "frameworks": [ { "name": "next", "version": "16.0.10" }, { "name": "react", "version": "19.2.2" } ], "databases": [ { "name": "better-sqlite3", "version": "12.2.0" }, { "name": "pg", "version": "^8.11.3" } ], "betterAuth": { "version": "^1.4.13", "config": null } ``` ### Which area(s) are affected? (Select all that apply) Other ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ session: { cookieCache: { enabled: true, maxAge: 7 * 24 * 60 * 60, // 7 days cache duration strategy: 'jwt', refreshCache: false, }, }, account: { storeStateStrategy: 'cookie', storeAccountCookie: true, }, plugins: [nextCookies(), passwordlessPlugin()], }); ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-17 19:31:48 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 15, 2026):

It looks like you're having issues with refreshing tokens in Better Auth, particularly regarding the session cookies not being updated with the refreshed tokens.

From your description, the steps you're taking may not be updating the session cookie properly after refreshing the tokens. Here are a few suggestions to help address your issue:

  1. Session Management: Ensure that the session management settings are configured correctly in your betterAuth setup. Specifically, ensure the cookieCache settings allow for refreshing cookies. You can enable the automatic refresh of cookies with refreshCache: true and set the duration appropriately [3].

  2. Check Session Expiration: Remember that the session needs to be fresh for the cookies to be updated. This can be managed through the expiresIn and updateAge settings. Ensure they are set correctly to allow for refreshes without immediate expiration [3].

  3. Refresh Process: Within your refreshTokens endpoint, after obtaining new tokens, ensure that the setSessionCookie is called with the newly created session that includes the updated tokens. The new session should overwrite the existing session cached in cookies [2].

  4. Debugging: If you're in a development environment, add console statements within your refresh logic to log the tokens being set and any errors from the API calls. This will help identify if the issue lies in the token refresh logic or cookie setting.

  5. Current Version Share: You're using Better Auth version 1.4.13, which is good. If you haven't already, check if any bugs related to session cookie refresh have been resolved in more recent versions.

If these steps do not resolve your issue, I recommend tagging @bekacru or @ping__ in your follow-up for more personalized assistance.

Have you been able to get any logging or error messages that could provide more insight into what's going wrong during the refresh process?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3755992130 --> @better-auth-agent[bot] commented on GitHub (Jan 15, 2026): It looks like you're having issues with refreshing tokens in Better Auth, particularly regarding the session cookies not being updated with the refreshed tokens. From your description, the steps you're taking may not be updating the session cookie properly after refreshing the tokens. Here are a few suggestions to help address your issue: 1. **Session Management:** Ensure that the session management settings are configured correctly in your `betterAuth` setup. Specifically, ensure the `cookieCache` settings allow for refreshing cookies. You can enable the automatic refresh of cookies with `refreshCache: true` and set the duration appropriately [[3]](https://www.better-auth.com/docs/concepts/session-management). 2. **Check Session Expiration:** Remember that the session needs to be fresh for the cookies to be updated. This can be managed through the `expiresIn` and `updateAge` settings. Ensure they are set correctly to allow for refreshes without immediate expiration [[3]](https://www.better-auth.com/docs/concepts/session-management). 3. **Refresh Process:** Within your `refreshTokens` endpoint, after obtaining new tokens, ensure that the `setSessionCookie` is called with the newly created session that includes the updated tokens. The new session should overwrite the existing session cached in cookies [[2]](https://github.com/better-auth/better-auth/issues/3064). 4. **Debugging:** If you're in a development environment, add console statements within your refresh logic to log the tokens being set and any errors from the API calls. This will help identify if the issue lies in the token refresh logic or cookie setting. 5. **Current Version Share:** You're using Better Auth version `1.4.13`, which is good. If you haven't already, check if any bugs related to session cookie refresh have been resolved in more recent versions. If these steps do not resolve your issue, I recommend tagging @bekacru or @ping__ in your follow-up for more personalized assistance. Have you been able to get any logging or error messages that could provide more insight into what's going wrong during the refresh process? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@sergio-milu commented on GitHub (Jan 16, 2026):

hey @Bekacru i tried all the approaches above but none workes

to add more context i want to migrate this use case -> https://authjs.dev/guides/refresh-token-rotation#jwt-strategy

seems like authjs was forwading headres in RCS request but better auth is not? seems like my use case fails when the requests start in the server

<!-- gh-comment-id:3759409311 --> @sergio-milu commented on GitHub (Jan 16, 2026): hey @Bekacru i tried all the approaches above but none workes to add more context i want to migrate this use case -> https://authjs.dev/guides/refresh-token-rotation#jwt-strategy seems like authjs was forwading headres in RCS request but better auth is not? seems like my use case fails when the requests start in the server
Author
Owner

@TomJD commented on GitHub (Jan 17, 2026):

@sergio-milu Also waiting for help on this. Similar discussion

<!-- gh-comment-id:3762889157 --> @TomJD commented on GitHub (Jan 17, 2026): @sergio-milu Also waiting for help on this. [Similar discussion](https://github.com/better-auth/better-auth/discussions/5786)
Author
Owner

@Ahmeddsamy commented on GitHub (Jan 17, 2026):

hello @sergio-milu @TomJD

i have been debugging this issue for the past 4 or 5 hours, this is bug is valid i think, iam using next js 16 here is a workaround opus 4.5 managed to find and the documentation it made:

`# Token Refresh Loop in Stateless Mode with Next.js App Router

Issue Summary

When using Better Auth's stateless configuration (storeAccountCookie: true) with Next.js App Router, calling auth.api.getAccessToken() during Server Component rendering causes an infinite token refresh loop.

Environment

  • Better Auth: v1.4.x
  • Next.js: 15.x / 16.x (App Router)
  • OAuth Provider: Keycloak (likely affects all providers)
  • Configuration: Stateless mode with storeAccountCookie: true

Root Cause

When auth.api.getAccessToken() is called in a Server Component and the token needs refreshing:

  1. Better Auth successfully refreshes the token from the OAuth provider
  2. Better Auth calls setAccountCookie() with the new token data
  3. Problem: In Next.js App Router, HTTP response headers are already committed when streaming begins
  4. The Set-Cookie header is silently dropped
  5. Browser keeps using the stale cookie with the old accessTokenExpiresAt
  6. Next request: token appears expired again → refresh loop
Request → Server Component → getAccessToken() → REFRESH → setCookie() ❌
                                                            ↓
                                            Headers already committed
                                                            ↓
                                            Set-Cookie never reaches browser
                                                            ↓
                                            Next request uses stale cookie → LOOP

Evidence

Looking at the cookie data across requests:

Request 1: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH
Request 2: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH (same!)
Request 3: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH (still same!)

The accessTokenExpiresAt never updates in the browser despite successful refreshes server-side.

Workaround

Refresh tokens in Next.js Middleware/Proxy (before SSR begins) instead of during Server Component rendering.

1. Create a helper to decode/refresh tokens directly

// lib/auth/proxy-auth.ts
import { jwtDecrypt, EncryptJWT, base64url, calculateJwkThumbprint } from 'jose';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';

// Better Auth's encryption constants
const ENCRYPTION_INFO = new TextEncoder().encode('BetterAuth.js Generated Encryption Key');

async function deriveEncryptionKey(secret: string, salt: string) {
    return hkdf(sha256, new TextEncoder().encode(secret), 
                new TextEncoder().encode(salt), ENCRYPTION_INFO, 64);
}

export async function decodeAccountCookie(cookieValue: string, secret: string) {
    const encryptionSecret = await deriveEncryptionKey(secret, 'better-auth-account');
    const { payload } = await jwtDecrypt(cookieValue, async ({ kid }) => {
        if (kid === undefined) return encryptionSecret;
        const thumbprint = await calculateJwkThumbprint(
            { kty: 'oct', k: base64url.encode(encryptionSecret) }, 'sha256'
        );
        if (kid === thumbprint) return encryptionSecret;
        throw new Error('no matching decryption secret');
    }, {
        clockTolerance: 15,
        keyManagementAlgorithms: ['dir'],
        contentEncryptionAlgorithms: ['A256CBC-HS512', 'A256GCM'],
    });
    return payload;
}

export async function encodeAccountCookie(data: any, secret: string) {
    const encryptionSecret = await deriveEncryptionKey(secret, 'better-auth-account');
    const thumbprint = await calculateJwkThumbprint(
        { kty: 'oct', k: base64url.encode(encryptionSecret) }, 'sha256'
    );
    return await new EncryptJWT(data)
        .setProtectedHeader({ alg: 'dir', enc: 'A256CBC-HS512', kid: thumbprint })
        .setIssuedAt()
        .setExpirationTime(Math.floor(Date.now() / 1000) + 900)
        .setJti(crypto.randomUUID())
        .encrypt(encryptionSecret);
}

2. Refresh in Middleware/Proxy before rendering

// middleware.ts or proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import { decodeAccountCookie, encodeAccountCookie } from '@/lib/auth/proxy-auth';

const AUTH_SECRET = process.env.BETTER_AUTH_SECRET!;

export async function middleware(request: NextRequest) {
    const accountCookie = request.cookies.get('better-auth.account_data')?.value;
    
    if (accountCookie) {
        const accountData = await decodeAccountCookie(accountCookie, AUTH_SECRET);
        const expiresAt = new Date(accountData.accessTokenExpiresAt).getTime();
        const timeRemaining = expiresAt - Date.now();
        
        // Refresh if expiring within 5 seconds
        if (timeRemaining < 5000 && accountData.refreshToken) {
            // Call your OAuth provider's token endpoint directly
            const tokens = await fetch('https://your-provider/token', {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: new URLSearchParams({
                    grant_type: 'refresh_token',
                    client_id: 'your-client-id',
                    refresh_token: accountData.refreshToken,
                }),
            }).then(r => r.json());
            
            // Update account data
            const updatedData = {
                ...accountData,
                accessToken: tokens.access_token,
                refreshToken: tokens.refresh_token || accountData.refreshToken,
                accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
            };
            
            // Encode and set cookie
            const newCookie = await encodeAccountCookie(updatedData, AUTH_SECRET);
            const response = NextResponse.next();
            response.cookies.set('better-auth.account_data', newCookie, {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'lax',
                maxAge: 900,
            });
            return response;
        }
    }
    
    return NextResponse.next();
}

3. Skip Better Auth's internal refresh in Server Components

When reading the token in Server Components, read directly from the cookie instead of calling auth.api.getAccessToken() (which triggers internal refresh):

// Instead of:
const tokenResponse = await auth.api.getAccessToken({ headers, body: { providerId: 'xxx' } });

// Read directly from cookie (already refreshed by middleware):
const accountData = await decodeAccountCookie(cookieValue, AUTH_SECRET);
const token = accountData.accessToken;

Why This Works

Layer Can Set Cookies? When Runs
Middleware/Proxy Yes Before routing/rendering
Route Handler Yes API routes only
Server Component No After headers committed

By refreshing in middleware, cookies are set before Server Components run and headers are committed.

Suggested Fix for Better Auth

The library should:

  1. Detect Next.js App Router context and warn when setCookie won't work
  2. Provide a middleware helper for proactive token refresh
  3. Document this limitation clearly for Next.js App Router users
  • This is a limitation of Next.js App Router streaming, not a bug in cookie handling itself
  • Route Handlers (/api/*) CAN set cookies correctly - only Server Components have this issue
  • The same issue likely affects any library that sets cookies during SSR rendering
    `
<!-- gh-comment-id:3763895949 --> @Ahmeddsamy commented on GitHub (Jan 17, 2026): hello @sergio-milu @TomJD i have been debugging this issue for the past 4 or 5 hours, this is bug is valid i think, iam using next js 16 here is a workaround opus 4.5 managed to find and the documentation it made: > `# Token Refresh Loop in Stateless Mode with Next.js App Router ## Issue Summary When using Better Auth's stateless configuration (`storeAccountCookie: true`) with Next.js App Router, calling `auth.api.getAccessToken()` during Server Component rendering causes an **infinite token refresh loop**. ## Environment - **Better Auth**: v1.4.x - **Next.js**: 15.x / 16.x (App Router) - **OAuth Provider**: Keycloak (likely affects all providers) - **Configuration**: Stateless mode with `storeAccountCookie: true` ## Root Cause When `auth.api.getAccessToken()` is called in a Server Component and the token needs refreshing: 1. Better Auth successfully refreshes the token from the OAuth provider 2. Better Auth calls `setAccountCookie()` with the new token data 3. **Problem**: In Next.js App Router, HTTP response headers are already committed when streaming begins 4. The `Set-Cookie` header is silently dropped 5. Browser keeps using the **stale cookie** with the old `accessTokenExpiresAt` 6. Next request: token appears expired again → refresh loop ``` Request → Server Component → getAccessToken() → REFRESH → setCookie() ❌ ↓ Headers already committed ↓ Set-Cookie never reaches browser ↓ Next request uses stale cookie → LOOP ``` ## Evidence Looking at the cookie data across requests: ``` Request 1: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH Request 2: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH (same!) Request 3: accessTokenExpiresAt: '2024-01-17T13:51:45.098Z' → REFRESH (still same!) ``` The `accessTokenExpiresAt` never updates in the browser despite successful refreshes server-side. ## Workaround **Refresh tokens in Next.js Middleware/Proxy (before SSR begins) instead of during Server Component rendering.** ### 1. Create a helper to decode/refresh tokens directly ```typescript // lib/auth/proxy-auth.ts import { jwtDecrypt, EncryptJWT, base64url, calculateJwkThumbprint } from 'jose'; import { hkdf } from '@noble/hashes/hkdf.js'; import { sha256 } from '@noble/hashes/sha2.js'; // Better Auth's encryption constants const ENCRYPTION_INFO = new TextEncoder().encode('BetterAuth.js Generated Encryption Key'); async function deriveEncryptionKey(secret: string, salt: string) { return hkdf(sha256, new TextEncoder().encode(secret), new TextEncoder().encode(salt), ENCRYPTION_INFO, 64); } export async function decodeAccountCookie(cookieValue: string, secret: string) { const encryptionSecret = await deriveEncryptionKey(secret, 'better-auth-account'); const { payload } = await jwtDecrypt(cookieValue, async ({ kid }) => { if (kid === undefined) return encryptionSecret; const thumbprint = await calculateJwkThumbprint( { kty: 'oct', k: base64url.encode(encryptionSecret) }, 'sha256' ); if (kid === thumbprint) return encryptionSecret; throw new Error('no matching decryption secret'); }, { clockTolerance: 15, keyManagementAlgorithms: ['dir'], contentEncryptionAlgorithms: ['A256CBC-HS512', 'A256GCM'], }); return payload; } export async function encodeAccountCookie(data: any, secret: string) { const encryptionSecret = await deriveEncryptionKey(secret, 'better-auth-account'); const thumbprint = await calculateJwkThumbprint( { kty: 'oct', k: base64url.encode(encryptionSecret) }, 'sha256' ); return await new EncryptJWT(data) .setProtectedHeader({ alg: 'dir', enc: 'A256CBC-HS512', kid: thumbprint }) .setIssuedAt() .setExpirationTime(Math.floor(Date.now() / 1000) + 900) .setJti(crypto.randomUUID()) .encrypt(encryptionSecret); } ``` ### 2. Refresh in Middleware/Proxy before rendering ```typescript // middleware.ts or proxy.ts import { NextRequest, NextResponse } from 'next/server'; import { decodeAccountCookie, encodeAccountCookie } from '@/lib/auth/proxy-auth'; const AUTH_SECRET = process.env.BETTER_AUTH_SECRET!; export async function middleware(request: NextRequest) { const accountCookie = request.cookies.get('better-auth.account_data')?.value; if (accountCookie) { const accountData = await decodeAccountCookie(accountCookie, AUTH_SECRET); const expiresAt = new Date(accountData.accessTokenExpiresAt).getTime(); const timeRemaining = expiresAt - Date.now(); // Refresh if expiring within 5 seconds if (timeRemaining < 5000 && accountData.refreshToken) { // Call your OAuth provider's token endpoint directly const tokens = await fetch('https://your-provider/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: 'your-client-id', refresh_token: accountData.refreshToken, }), }).then(r => r.json()); // Update account data const updatedData = { ...accountData, accessToken: tokens.access_token, refreshToken: tokens.refresh_token || accountData.refreshToken, accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), }; // Encode and set cookie const newCookie = await encodeAccountCookie(updatedData, AUTH_SECRET); const response = NextResponse.next(); response.cookies.set('better-auth.account_data', newCookie, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 900, }); return response; } } return NextResponse.next(); } ``` ### 3. Skip Better Auth's internal refresh in Server Components When reading the token in Server Components, read directly from the cookie instead of calling `auth.api.getAccessToken()` (which triggers internal refresh): ```typescript // Instead of: const tokenResponse = await auth.api.getAccessToken({ headers, body: { providerId: 'xxx' } }); // Read directly from cookie (already refreshed by middleware): const accountData = await decodeAccountCookie(cookieValue, AUTH_SECRET); const token = accountData.accessToken; ``` ## Why This Works | Layer | Can Set Cookies? | When Runs | |-------|-----------------|-----------| | Middleware/Proxy | ✅ Yes | Before routing/rendering | | Route Handler | ✅ Yes | API routes only | | Server Component | ❌ No | After headers committed | By refreshing in middleware, cookies are set **before** Server Components run and headers are committed. ## Suggested Fix for Better Auth The library should: 1. **Detect Next.js App Router context** and warn when `setCookie` won't work 2. **Provide a middleware helper** for proactive token refresh 3. **Document this limitation** clearly for Next.js App Router users ## Related - This is a limitation of Next.js App Router streaming, not a bug in cookie handling itself - Route Handlers (`/api/*`) CAN set cookies correctly - only Server Components have this issue - The same issue likely affects any library that sets cookies during SSR rendering ` >
Author
Owner

@bytaesu commented on GitHub (Feb 2, 2026):

Hi, I'll check this 🧐

<!-- gh-comment-id:3835458364 --> @bytaesu commented on GitHub (Feb 2, 2026): Hi, I'll check this 🧐
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28124