#9928·next-auth

Keycloak: 'State cookie was missing' if user started login in Keycloak and is waiting for 10 minutes or more.

Author: AliakseiPaseishviliSyntheticabioCreated Feb 6, 2024Updated Jul 17, 2026
Labelsbugproviderstriage

Provider type

Keycloak

Environment

My package.json:

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "prepare": "husky install",
    "test": "jest"
  },
  "dependencies": {
    "@floating-ui/react": "^0.26.2",
    "@headlessui/react": "^1.7.16",
    "@heroicons/react": "^2.0.18",
    "@hookform/resolvers": "^3.2.0",
    "@lexical/react": "^0.12.5",
    "@tanstack/react-query": "^5.4.3",
    "@tanstack/react-query-devtools": "^5.4.3",
    "@types/node": "20.5.0",
    "@types/react": "18.2.20",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.15",
    "classnames": "^2.3.2",
    "dayjs": "^1.11.10",
    "eslint": "8.47.0",
    "eslint-config-next": "13.4.13",
    "eslint-plugin-json-es": "^1.5.7",
    "i18next": "^23.4.5",
    "jwt-decode": "^4.0.0",
    "lexical": "^0.12.5",
    "lodash": "^4.17.21",
    "next": "14.1.0",
    "next-auth": "^4.24.5",
    "postcss": "8.4.31",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-error-boundary": "^4.0.11",
    "react-hook-form": "^7.45.4",
    "react-i18next": "^13.2.0",
    "react-toastify": "^9.1.3",
    "react-virtualized-auto-sizer": "^1.0.20",
    "react-window": "^1.8.9",
    "reactflow": "^11.8.1",
    "sass": "^1.67.0",
    "tailwindcss": "3.3.3",
    "typescript": "5.1.6",
    "yup": "^1.2.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.1.5",
    "@testing-library/react": "^14.1.2",
    "@types/jest": "^29.5.11",
    "@types/lodash": "^4.14.199",
    "@types/react-window": "^1.8.6",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-import": "^2.28.0",
    "eslint-plugin-no-loops": "^0.3.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.33.1",
    "eslint-plugin-react-hooks": "^5.0.0-canary-7118f5dd7-20230705",
    "eslint-plugin-simple-import-sort": "^10.0.0",
    "eslint-plugin-unused-imports": "^3.0.0",
    "husky": "^8.0.3",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "lint-staged": "^14.0.0",
    "prettier": "3.0.1",
    "ts-node": "^10.9.2"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx,json}": [
      "npx eslint --quiet --fix"
    ]
  }
}

config for next-auth:

bash
import dayjs from 'dayjs';
import { jwtDecode } from 'jwt-decode';
import { AuthOptions } from 'next-auth';
import { JWT } from 'next-auth/jwt';
import KeycloakProvider from 'next-auth/providers/keycloak';

declare module 'next-auth/jwt' {
  interface JWT {
    access_token: string;
    id_token: string;
    expires_at: number;
    refresh_token: string;
    error?: string;
    roles: string[];
  }
}

declare module 'jwt-decode' {
  export interface JwtPayload {
    realm_access: { roles: string[] };
  }
}

declare module 'next-auth' {
  interface Session {
    error?: string;
    roles: string[];
    sub?: string;
  }
}

const refreshAccessToken = async (token: JWT) => {
  const url = `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/token`;
  const resp = await fetch(url, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.KEYCLOACK_CLIENT_ID,
      client_secret: process.env.KEYCLOACK_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: token.refresh_token,
    }),
    method: 'POST',
  });
  const refreshToken = await resp.json();
  if (!resp.ok) throw refreshToken;

  const tokenData = jwtDecode(refreshToken.access_token);

  return {
    ...token,
    access_token: refreshToken.access_token,
    id_token: refreshToken.id_token,
    expires_at: Math.floor(Date.now() / 1000) + refreshToken.expires_in,
    refresh_token: refreshToken.refresh_token,
    roles: tokenData.realm_access.roles,
  };
};

export const authOptions: AuthOptions = {
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOACK_CLIENT_ID,
      clientSecret: process.env.KEYCLOACK_CLIENT_SECRET,
      issuer: process.env.KEYCLOACK_ISSUER,
    }),
  ],
  callbacks: {
    async jwt({ token, account, trigger }) {
      const nowTimeStamp = dayjs();

      try {
        // we refresh token on update action
        if (trigger === 'update') {
          return await refreshAccessToken(token);
        }

        // we get intial token on sign in
        if (
          account &&
          account.access_token &&
          account.id_token &&
          account.expires_at &&
          account.refresh_token
        ) {
          const tokenData = jwtDecode(account.access_token);

          token.roles = tokenData.realm_access.roles;
          token.access_token = account.access_token;
          token.id_token = account.id_token;
          token.expires_at = account.expires_at;
          token.refresh_token = account.refresh_token;
          return token;
          // if time of expires is more then current date then we return existing token.
        } else if (
          token &&
          nowTimeStamp.isBefore(dayjs.unix(token.expires_at))
        ) {
          return token;
          // this happens after user session is expired, so in this case we try to update user session
        } else {
          return await refreshAccessToken(token);
        }
        // if session update is failed we return error and on client we are doing logout(TokenExpireController).
      } catch (error) {
        return { ...token, error: 'RefreshAccessTokenError' };
      }
    },
    async session({ session, token }) {
      session.error = token.error;
      session.roles = token.roles;
      session.sub = token.sub;

      return session;
    },
  },
  events: {
    async signOut({ token }) {
      const logOutUrl = new URL(
        `${process.env.KEYCLOACK_ISSUER}/protocol/openid-connect/logout`,
      );
      logOutUrl.searchParams.set('id_token_hint', token.id_token!);

      await fetch(logOutUrl);
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: '/auth/signin', // we added this page to manually cover issue with missed state
  },
};

Reproduction URL

https://github.com/AliakseiPaseishviliSyntheticabio/test-repo

Describe the issue

Sometime user doing login to our app for too long and in this case we are navigated to sign-in page with next error, instead of giving access to the app.: image Pressing on sign in with keycloak will navigate our user to app without any keycloak login pages.

UPD: repo is not here. This is private code and I can't share env details to you.

How to reproduce

Steps to reproduce:

  1. Open your app with keycloak.
  2. Start login with keycloak
  3. wait for 10+ minutes.
  4. finish login with keycloak.
  5. App will redirect you to api/signin?error='OAuthCallback'
  6. pressing on keycloak button will do fast signin without doing step 2. in logs we got error: 'State cookie was missing'.

Expected behavior

It does login without any issues even in 10 or 15 minutes, because default login time in keycloak is set to 30 minutes.