Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings
Discussion options

Hey all, this has been driving me absolutely insane lately trying to figure out whats going on. For some reason, when the JWT callback on my app runs, and the access/refresh token is refreshed, its not actually updating the properties on the token, user and session objects. Weirdly enough - if I refresh the actual page, the tokens seem to update perfectly fine, this only doesn't work when the JWT callback updates naturally or by making requests

auth-options.ts:

import { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { serverApiPost } from "@/lib/api/helpers/server-api-methods";
import { PostAuthenticationRequestDto } from "@/lib/api/dtos/authentication/requests/post-authentication-request-dto";
import { PostAuthenticationResponseDto } from "@/lib/api/dtos/authentication/responses/post-authentication-response-dto";
import { PostRefreshTokenRequestDto } from "@/lib/api/dtos/authentication/requests/post-refresh-token-request-dto";
import { PostRefreshTokenResponseDto } from "@/lib/api/dtos/authentication/responses/post-refresh-token-response-dto";

const refreshToken = async (refreshToken: string) => {
  console.log(`Refreshing token with refresh token: ${refreshToken}`);

  const body: PostRefreshTokenRequestDto = {
    refreshToken: refreshToken,
  };
  const response = await serverApiPost<
    PostRefreshTokenResponseDto,
    PostRefreshTokenRequestDto
  >("/auth/refresh", body, { auth: false, service: "authentication" });

  console.log(`Recieved refresh token ${response.refreshToken}`)
  return response
}


export const authOptions: AuthOptions = {
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        username: { label: "Username", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.username || !credentials?.password) return null;

        const response = await serverApiPost<
          PostAuthenticationResponseDto,
          PostAuthenticationRequestDto
        >(
          "/auth/login",
          { username: credentials.username, password: credentials.password },
          { auth: false, service: "authentication" }
        );

        console.log(`Authenticated and received refresh token: ${response.refreshToken}`);

        return {
          id: response.userId.toString(),
          accessToken: response.accessToken,
          refreshToken: response.refreshToken,
          expiresAt: response.expiresAt,
        };
      },
    }),
  ],

  session: { strategy: "jwt", maxAge: 60 * 300 },
  callbacks: {
    async jwt({ token, user, account }) {

      if (account && user) {
        token.accessToken = user.accessToken
        token.refreshToken = user.refreshToken
        token.expiresAt = user.expiresAt
      }

      const REFRESH_BUFFER_MS = 5_000;
      if (!token.expiresAt || Date.now() >= token.expiresAt - REFRESH_BUFFER_MS) {
        console.log(`TOKEN ABOUT TO EXPIRE: ${token.expiresAt}`)
        const response = await refreshToken(token.refreshToken || '')
        token.accessToken = response.accessToken
        token.refreshToken = response.refreshToken
        token.expiresAt = response.expiresAt
      }

      return token
    },

    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.refreshToken = token.refreshToken;
      session.expiresAt = token.expiresAt

      return session;
    },
  },
};

next-auth.d.ts:

import NextAuth from "next-auth";
import "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    accessToken?: string;
    refreshToken?: string;
    expiresAt?: number;
  }

  interface User {
    accessToken?: string;
    refreshToken?: string;
    expiresAt?: number;
  }

}

declare module "next-auth/jwt" {
  interface JWT {
    accessToken?: string;
    refreshToken?: string;
    expiresAt?: number;
  }
}

Logs:

 GET /api/user/search?username=wwwwww 200 in 34ms (compile: 1925µs, render: 32ms)
Current access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MThiNjZmYS1mMDg4LTRkYjgtYWYyNC00ZGFlOTAxMDg4MTUiLCJ1bmlxdWVfbmFtZSI6ImN3MDMiLCJqdGkiOiIzYzQyY2Y0Ni0yZWUxLTQxZjQtOWUzNy1hNzdiZjQ4MjAwYmUiLCJlbWFpbCI6ImNocmlzd2VsbHMyMDAxMjNAb3V0bG9vay5jb20iLCJleHAiOjE3NjU4MDE5MjUsImlzcyI6IlRlc3RGbHV4U2VydmVyIiwiYXVkIjoiVGVzdEZsdXhDbGllbnQifQ.NVxLnLYD8tz3Mjez1ZDwgYJgLdqE4x3ZiUKrhfeK_Bo
TOKEN ABOUT TO EXPIRE: 1765801925296
Refreshing token with refresh token: Lo7sO2Nt0Al/RSGtIHBlI9cP6LOkcPMYBeCbJzsAS/4=
Recieved refresh token JNrRIlHcAj6ITB9yrKSSWRkoP4dsmGma40SPGzaTJL0=
New access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MThiNjZmYS1mMDg4LTRkYjgtYWYyNC00ZGFlOTAxMDg4MTUiLCJ1bmlxdWVfbmFtZSI6ImN3MDMiLCJqdGkiOiI1MjNmNWQxZS1kMzZkLTQ4ZDQtOTE1ZC1hN2MyZTU0OWZlZjMiLCJlbWFpbCI6ImNocmlzd2VsbHMyMDAxMjNAb3V0bG9vay5jb20iLCJleHAiOjE3NjU4MDE5ODUsImlzcyI6IlRlc3RGbHV4U2VydmVyIiwiYXVkIjoiVGVzdEZsdXhDbGllbnQifQ.2qXTPpYAVGZikGC-Ehn2xNvXrFTzln8PQ8YkDuobatM
 GET /api/user/search?username=wwwwwww 200 in 49ms (compile: 1709µs, render: 47ms)
Current access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MThiNjZmYS1mMDg4LTRkYjgtYWYyNC00ZGFlOTAxMDg4MTUiLCJ1bmlxdWVfbmFtZSI6ImN3MDMiLCJqdGkiOiIzYzQyY2Y0Ni0yZWUxLTQxZjQtOWUzNy1hNzdiZjQ4MjAwYmUiLCJlbWFpbCI6ImNocmlzd2VsbHMyMDAxMjNAb3V0bG9vay5jb20iLCJleHAiOjE3NjU4MDE5MjUsImlzcyI6IlRlc3RGbHV4U2VydmVyIiwiYXVkIjoiVGVzdEZsdXhDbGllbnQifQ.NVxLnLYD8tz3Mjez1ZDwgYJgLdqE4x3ZiUKrhfeK_Bo
TOKEN ABOUT TO EXPIRE: 1765801925296
Refreshing token with refresh token: Lo7sO2Nt0Al/RSGtIHBlI9cP6LOkcPMYBeCbJzsAS/4=
Recieved refresh token 1SCwi8Nib+dPcMDc0VgVZY6dK7/uRUrLcOwh6bsQL5k=
New access token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MThiNjZmYS1mMDg4LTRkYjgtYWYyNC00ZGFlOTAxMDg4MTUiLCJ1bmlxdWVfbmFtZSI6ImN3MDMiLCJqdGkiOiIyNWJlNjRmZS1mMmQ2LTQwZjUtOTg5ZS00OGVlYWJjZTg0ZWQiLCJlbWFpbCI6ImNocmlzd2VsbHMyMDAxMjNAb3V0bG9vay5jb20iLCJleHAiOjE3NjU4MDE5ODUsImlzcyI6IlRlc3RGbHV4U2VydmVyIiwiYXVkIjoiVGVzdEZsdXhDbGllbnQifQ.xHhPG50PfbeTzSUfcG9hknMNHITZqW3ayCMnu-DtD3Y
 GET /api/user/search?username=wwwwwwww 200 in 44ms (compile: 1934µs, render: 42ms)

From the logs, we can clearly see that the old refresh/tokens are still being used in subsequent requests - does anyone have any ideas on this? I'm using getServerSession to make the calls from my frontend server to my backend server.

You must be logged in to vote

Replies: 1 comment

Comment options

The callback is updating the in-memory JWT for that one session lookup, but the updated JWT is not being persisted back to the browser's session cookie in the request path you are using.

The logs make that visible: every request starts again with the same old access token and the same old refresh token, performs another refresh, and obtains a different replacement. A page reload works because it causes a request through the Auth.js session endpoint/client session flow, where the response can actually send a Set-Cookie header.

If these calls originate while rendering a Server Component, getServerSession() should not be your token-rotation persistence mechanism. A Server Component can read the incoming cookie, but it cannot attach a new cookie after rendering has begun. Next.js documents the boundary here: cookies() can write in a Server Function or Route Handler, but a Server Component only reads the incoming cookies.

I would restructure this as follows:

  1. Keep the refresh operation in one place (your jwt callback is fine).
  2. Trigger it through a request that owns an HTTP response and can write the new encrypted session JWT—normally the Auth.js session route/client getSession/useSession flow, or a dedicated Route Handler/BFF endpoint.
  3. Have server-side data access use the token produced by that request. Do not expect multiple independent getServerSession() calls during RSC rendering to rotate and persist the cookie.
  4. Serialize refreshes per session/user. Your logs show the classic race: several requests can all observe the expired JWT and redeem the same refresh token. Providers often rotate refresh tokens once, so one request can invalidate the token still used by the others. Auth.js calls out this race explicitly in its refresh-token rotation guide.
  5. Preserve the old refresh token when the provider does not return a new one:
return {
  ...token,
  accessToken: response.accessToken,
  refreshToken: response.refreshToken ?? token.refreshToken,
  expiresAt: response.expiresAt,
}

There are also two security fixes I would make immediately:

  • Do not copy refreshToken into session in the session callback. The session object is returned to client-side code; keep the refresh token only inside the encrypted HttpOnly Auth.js JWT (or your database). Expose only the data the browser truly needs.
  • The post contains complete access and refresh-token values. Even if the access tokens have expired, revoke/rotate the refresh tokens and redact those log lines.

So this is not a TypeScript augmentation problem, nor is mutating token inside the callback wrong. It is an HTTP persistence and concurrency problem: the refresh must complete in a request that can return the new cookie, and concurrent refreshes need coordination.

If this resolves the repeated-old-token behavior, please mark it as the accepted answer so future readers can find it quickly.

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Help
Labels
None yet
2 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.