Skip to content

Instantly share code, notes, and snippets.

@degitgitagitya
Last active September 8, 2025 13:00
Show Gist options
  • Save degitgitagitya/db5c4385fc549f317eac64d8e5702f74 to your computer and use it in GitHub Desktop.
Save degitgitagitya/db5c4385fc549f317eac64d8e5702f74 to your computer and use it in GitHub Desktop.
Next JS + Next Auth + Keycloak + AutoRefreshToken
# KEYCLOAK BASE URL
KEYCLOAK_BASE_URL=
# KEYCLOAK CLIENT SECRET
KEYCLOAK_CLIENT_SECRET=
# KEYCLOAK CLIENT ID
KEYCLOAK_CLIENT_ID=
# BASE URL FOR NEXT AUTH
NEXTAUTH_URL=
# JWT SECRET KEY
JWT_SECRET=
# NEXT AUTH SECRET KEY
SECRET=
# JWT SIGNING PRIVATE KEY
JWT_SIGNING_PRIVATE_KEY=
import NextAuth from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak'
import type { JWT } from 'next-auth/jwt';
/**
* Takes a token, and returns a new token with updated
* `accessToken` and `accessTokenExpires`. If an error occurs,
* returns the old token and an error property
*/
/**
* @param {JWT} token
*/
const refreshAccessToken = async (token: JWT) => {
try {
if (Date.now() > token.refreshTokenExpired) throw Error;
const details = {
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
grant_type: ['refresh_token'],
refresh_token: token.refreshToken,
};
const formBody: string[] = [];
Object.entries(details).forEach(([key, value]: [string, any]) => {
const encodedKey = encodeURIComponent(key);
const encodedValue = encodeURIComponent(value);
formBody.push(encodedKey + '=' + encodedValue);
});
const formData = formBody.join('&');
const url = `${process.env.KEYCLOAK_BASE_URL}/token`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: formData,
});
const refreshedTokens = await response.json();
if (!response.ok) throw refreshedTokens;
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpired: Date.now() + (refreshedTokens.expires_in - 15) * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
refreshTokenExpired:
Date.now() + (refreshedTokens.refresh_expires_in - 15) * 1000,
};
} catch (error) {
return {
...token,
error: 'RefreshAccessTokenError',
};
}
};
// If you have the latest version of next-auth
// Please use this next auth provider instead of my custom provider https://next-auth.js.org/providers/keycloak
// const keycloakProvider = KeycloakProvider({
// clientId: process.env.KEYCLOAK_CLIENT_ID,
// clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
// issuer: process.env.KEYCLOAK_ISSUER,
// authorization: {
// params: {
// grant_type: 'authorization_code',
// scope:
// 'openid tts-saas-user-attribute email speech-api profile console-prosa payment-service',
// response_type: 'code'
// }
// },
// httpOptions: {
// timeout: 30000
// }
// })
export default NextAuth({
// providers: [keycloakProvider],
providers: [
{
id: 'keycloak',
name: 'Keycloak',
type: 'oauth',
version: '2.0', // Double check your keycloak version
params: { grant_type: 'authorization_code' },
scope: 'openid email profile console-prosa basic-user-attribute',
accessTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/token`,
requestTokenUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`,
authorizationUrl: `${process.env.KEYCLOAK_BASE_URL}/auth`,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
profileUrl: `${process.env.KEYCLOAK_BASE_URL}/userinfo`,
profile: (profile) => {
return {
...profile,
id: profile.sub,
};
},
authorizationParams: {
response_type: 'code',
},
},
],
session: {
jwt: true,
},
jwt: {
secret: process.env.JWT_SECRET,
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY,
},
secret: process.env.SECRET,
callbacks: {
/**
* @param {object} user User object
* @param {object} account Provider account
* @param {object} profile Provider profile
* @return {boolean|string} Return `true` to allow sign in
* Return `false` to deny access
* Return `string` to redirect to (eg.: "/unauthorized")
*/
async signIn(user, account) {
if (account && user) {
return true;
} else {
// TODO : Add unauthorized page
return '/unauthorized';
}
},
/**
* @param {string} url URL provided as callback URL by the client
* @param {string} baseUrl Default base URL of site (can be used as fallback)
* @return {string} URL the client will be redirect to
*/
async redirect(url, baseUrl) {
return url.startsWith(baseUrl) ? url : baseUrl;
},
/**
* @param {object} session Session object
* @param {object} token User object (if using database sessions)
* JSON Web Token (if not using database sessions)
* @return {object} Session that will be returned to the client
*/
async session(session, token: JWT) {
if (token) {
session.user = token.user;
session.error = token.error;
session.accessToken = token.accessToken;
}
return session;
},
/**
* @param {object} token Decrypted JSON Web Token
* @param {object} user User object (only available on sign in)
* @param {object} account Provider account (only available on sign in)
* @param {object} profile Provider profile (only available on sign in)
* @param {boolean} isNewUser True if new user (only available on sign in)
* @return {object} JSON Web Token that will be saved
*/
async jwt(token, user, account) {
// Initial sign in
if (account && user) {
// Add access_token, refresh_token and expirations to the token right after signin
token.accessToken = account.accessToken;
token.refreshToken = account.refreshToken;
token.accessTokenExpired =
Date.now() + (account.expires_in - 15) * 1000;
token.refreshTokenExpired =
Date.now() + (account.refresh_expires_in - 15) * 1000;
token.user = user;
return token;
}
// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpired) return token;
// Access token has expired, try to update it
return refreshAccessToken(token);
},
},
});
// Client example
import { signIn, useSession } from "next-auth/client";
import { useEffect } from "react";
const HomePage() {
const [session] = useSession();
useEffect(() => {
if (session?.error === "RefreshAccessTokenError") {
signIn('keycloak', {
callbackUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/application`,
}); // Force sign in to hopefully resolve error
}
}, [session]);
return (...)
}
import type { User } from 'next-auth';
declare module 'next-auth' {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context
*/
interface Session {
user: {
sub: string;
email_verified: boolean;
name: string;
preferred_username: string;
given_name: string;
family_name: string;
email: string;
id: string;
org_name?: string;
telephone?: string;
};
error: string;
}
/**
* The shape of the user object returned in the OAuth providers' `profile` callback,
* or the second parameter of the `session` callback, when using a database.
*/
interface User {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
given_name: string;
family_name: string;
email: string;
id: string;
}
/**
* Usually contains information about the provider being used
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
*/
interface Account {
provider: string;
type: string;
id: string;
accessToken: string;
accessTokenExpires?: any;
refreshToken: string;
idToken: string;
access_token: string;
expires_in: number;
refresh_expires_in: number;
refresh_token: string;
token_type: string;
id_token: string;
'not-before-policy': number;
session_state: string;
scope: string;
}
/** The OAuth profile returned from your provider */
interface Profile {
sub: string;
email_verified: boolean;
name: string;
telephone: string;
preferred_username: string;
org_name: string;
given_name: string;
family_name: string;
email: string;
}
}
declare module 'next-auth/jwt' {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
interface JWT {
name: string;
email: string;
sub: string;
name: string;
email: string;
sub: string;
accessToken: string;
refreshToken: string;
accessTokenExpired: number;
refreshTokenExpired: number;
user: User;
error: string;
}
}
@sy6sy2
Copy link

sy6sy2 commented Sep 4, 2025

Thank you for this helper code!

But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, then refreshAccessToken() function is called multiple times concurrently if multiple components call useSession().
I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call to refreshAccessToken()? This avoid having Keycloak being flooded by multiple refresh token requests?
What do you think?

Edit: Related discussion: nextauthjs/next-auth#3940

@degitgitagitya
Copy link
Author

degitgitagitya commented Sep 4, 2025

Thank you for this helper code!

But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, then refreshAccessToken() function is called multiple times concurrently if multiple components call useSession(). I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call to refreshAccessToken()? This avoid having Keycloak being flooded by multiple refresh token requests? What do you think?

Hi, you're welcome.

It's generally fine, since Keycloak is designed to handle thousands (and even millions) of user requests, depending on the Keycloak server's capacity.

Optimizing it can be a bit tricky. Since most Next.js servers are deployed on serverless platforms, it's hard to sync each request because they might be running on different edge or serverless functions.

However, you could apply a technique called deduplication (you can research this). What I'm thinking is using Redis to dedupe the requests, so only one refresh token process is performed.

Alternatively, you could move the logic for refreshing the token to the client (browser) by creating a Next.js API endpoint to handle the refresh. This way, you can have more control over when the refresh token method is called. However, you should still keep the client ID and secret on the server.

@sy6sy2
Copy link

sy6sy2 commented Sep 4, 2025

Thank you for your fast answer.

I'm fine with the "Keycloak is designed to handle thousands (and even millions) of user requests" argument.
But, on the web app side, any change to fall in a race condition? I am not sure about that but I think of something like "refreshAccessToken is called concurrently with he same refresh token, the first call to Keycloak works but the second one failed because the same refresh token is used multiple times in a short period"?

Edit: what I use currently is a lock (using a promise) to be sure that refreshAccessToken is called sequentially, but maybe it's not useful ...

@degitgitagitya
Copy link
Author

Thank you for this helper code!
But, if user leave the web app (change browser tab), go back to the web app while the access token is expired, then refreshAccessToken() function is called multiple times concurrently if multiple components call useSession(). I do not have any problem at moment, but maybe it should be a good idea to prevent any concurrent call to refreshAccessToken()? This avoid having Keycloak being flooded by multiple refresh token requests? What do you think?

Hi, you're welcome.

It's generally fine, since Keycloak is designed to handle thousands (and even millions) of user requests, depending on the Keycloak server's capacity.

Optimizing it can be a bit tricky. Since most Next.js servers are deployed on serverless platforms, it's hard to sync each request because they might be running on different edge or serverless functions.

However, you could apply a technique called deduplication (you can research this). What I'm thinking is using Redis to dedupe the requests, so only one refresh token process is performed.

Alternatively, you could move the logic for refreshing the token to the client (browser) by creating a Next.js API endpoint to handle the refresh. This way, you can have more control over when the refresh token method is called. However, you should still keep the client ID and secret on the server.

Thank you for your fast answer.

I'm fine with the "Keycloak is designed to handle thousands (and even millions) of user requests" argument. But, on the web app side, any change to fall in a race condition? I am not sure about that but I think of something like "refreshAccessToken is called concurrently with he same refresh token, the first call to Keycloak works but the second one failed because the same refresh token is used multiple times in a short period"?

Edit: what I use currently is a lock (using a promise) to be sure that refreshAccessToken is called sequentially, but maybe it's not useful ...

Ahh, okay. The old access token will not become expired on token refresh, since you have to revoke the access token to make it invalid.

Sorry, I misunderstood your question.

@sy6sy2
Copy link

sy6sy2 commented Sep 4, 2025

Ok so everything should be good!
Thank you!

@degitgitagitya
Copy link
Author

Ok so everything should be good! Thank you!

You're welcome :)

@sy6sy2
Copy link

sy6sy2 commented Sep 8, 2025

Hello, another question:

On some tutorial of Keycloak-js one can see the usage of a setInterval() with the kc.updateToken() method in order to have the access token automatically refreshed even if we not do any "action" on the web app. This way (I guess), even after a long period of inactivity we can expect to have a valid access token.

On the other side (if I understand correctly), with the code given here, we do not automatically update the access token thanks to the refresh token, even, we only try to update an expired access token when we need this access token, meaning that after a long period of inanity we are in a case where both the access token and the refresh token are expired so we need to login from scratch.

Is the setInterval trick a good approach? Should we adapt a similar trick with next-auth. Or, is it the right way to do with the current behavior?
Thank you

@degitgitagitya
Copy link
Author

Hello, another question:

On some tutorial of Keycloak-js one can see the usage of a setInterval() with the kc.updateToken() method in order to have the access token automatically refreshed even if we not do any "action" on the web app. This way (I guess), even after a long period of inactivity we can expect to have a valid access token.

On the other side (if I understand correctly), with the code given here, we do not automatically update the access token thanks to the refresh token, even, we only try to update an expired access token when we need this access token, meaning that after a long period of inanity we are in a case where both the access token and the refresh token are expired so we need to login from scratch.

Is the setInterval trick a good approach? Should we adapt a similar trick with next-auth. Or, is it the right way to do with the current behavior? Thank you

Hi,

It depends on your authentication policies. The best practice is to set the access token expiration time to about 5 to 15 minutes and the refresh token expiration time to one day or even one month. This way, the user experience will be smooth but still secure (similar to Google, where we rarely have to log in again). The short lifespan of the access token is a security measure in case it's intercepted, while the long-lived refresh token ensures a seamless user experience by allowing the application to get new access tokens without requiring a full re-login.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment