[GH-ISSUE #6443] Getting jwt tokens from keycloack #27841

Open
opened 2026-04-17 19:04:16 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @SokSashaa on GitHub (Dec 1, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6443

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Installed better-auth
    pnpm add better-auth

  2. Create A Better Auth Instance
    export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ keycloak({ clientId: process.env.CLIENT_ID || '', clientSecret: process.env.CLIENT_SECRET || '', issuer: process.env.AUTHORITY_URL || '', }), ], }), ], });

  3. Create API Route
    `import {toNextJsHandler} from 'better-auth/next-js';

import {auth} from '@/shared/config/better-auth';

export const {GET, POST} = toNextJsHandler(auth);
`

  1. Create a client
    export const {signIn, signOut, useSession, getSession, getAccessToken} = createAuthClient({ plugins: [genericOAuthClient()], });

  2. Write proxy,ts
    `const ACCESS_ROLE = 'test';

export async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const session = await authOptions.api.getSession({headers: await headers()});

if (!session) {
    if (path === routes.start) {
        return NextResponse.next();
    }
    return NextResponse.redirect(new URL(routes.start, req.url));
}
const decodedToken = (i dont know) ? (???) as JwtPayload) : undefined;
const userRoles = decodedToken ? (decodedToken.role as string[]) : [];
const hasAccess = userRoles.includes(ACCESS_ROLE);


if (hasAccess) {
    if (path === routes.start || path === routes.accessDenied) {
        return NextResponse.redirect(new URL(routes.home, req.url));
    }
    return NextResponse.next();
}

if (!hasAccess && path !== routes.accessDenied) {
    return NextResponse.redirect(new URL(routes.accessDenied, req.url));
}

return NextResponse.next();

}

export const config = {
matcher: ['/((?!api|_next|favicon.ico|manifest|icon-).*)'],
};`

Current vs. Expected behavior

I use: Next 16, Keycloack. I used next-auth to authenticate users (I presented the next-auth configuration and a screenshot of the debug demonstrating the presence of jwt tokens below.) I decided to try better-auth, but I had a problem getting jwt tokens to get the user role stored in the access token in proxy.tx (formerly middleware.ts). Pease tell me how to get jwt tokens.

next-auth config:
`export const authOptions: AuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.CLIENT_ID!,
clientSecret: process.env.CLIENT_SECRET!,
issuer: process.env.AUTHORITY_URL!,
}),
],

session: {
    maxAge: 24 * 60 * 60,
},

secret: process.env.CLIENT_SECRET,
callbacks: {
    async jwt({token, account}) {
        if (account) {
            const accessToken = account.access_token;
            const decodedToken = accessToken ? (jwt.decode(accessToken) as JwtPayload) : undefined;

            return {
                ...token,
                accessToken,
                refreshToken: account.refresh_token,
                expiresToken: decodedToken?.exp ? decodedToken.exp * 1000 : undefined,
            } as typeof token;
        }

        const currentTime = Date.now();
        if (token.expiresToken && currentTime > token.expiresToken - 1200000) {
            return refreshToken(token);
        }
        return token;
    },
    async session({session, token}) {
        return {
            ...session,
            accessToken: token.accessToken,
            refreshToken: token.refreshToken,
            expiresToken: token.expiresToken,
        };
    },
},
pages: {
    signIn: routes.start,
},

};`

debug screen:
Image

What version of Better Auth are you using?

1.4.4

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Home Single Language",
    "release": "10.0.26100",
    "cpuCount": 8,
    "cpuModel": "11th Gen Intel(R) Core(TM) i7-11370H @ 3.30GHz",
    "totalMemory": "31.75 GB",
    "freeMemory": "14.47 GB"
  },
  "node": {
    "version": "v24.11.1",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.6.2"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.0.4"
    },
    {
      "name": "react",
      "version": "19.2.0"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "^1.4.4",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

import {betterAuth} from 'better-auth';
import {genericOAuth, keycloak} from 'better-auth/plugins';

export const auth = betterAuth({
    plugins: [
        genericOAuth({
            config: [
                keycloak({
                    clientId: process.env.CLIENT_ID || '',
                    clientSecret: process.env.CLIENT_SECRET || '',
                    issuer: process.env.AUTHORITY_URL || '',
                }),
            ],
        }),
    ],
});

Additional context

No response

Originally created by @SokSashaa on GitHub (Dec 1, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6443 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Installed better-auth `pnpm add better-auth` 2. Create A Better Auth Instance `export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ keycloak({ clientId: process.env.CLIENT_ID || '', clientSecret: process.env.CLIENT_SECRET || '', issuer: process.env.AUTHORITY_URL || '', }), ], }), ], });` 3. Create API Route `import {toNextJsHandler} from 'better-auth/next-js'; import {auth} from '@/shared/config/better-auth'; export const {GET, POST} = toNextJsHandler(auth); ` 4. Create a client `export const {signIn, signOut, useSession, getSession, getAccessToken} = createAuthClient({ plugins: [genericOAuthClient()], }); ` 5. Write proxy,ts `const ACCESS_ROLE = 'test'; export async function proxy(req: NextRequest) { const path = req.nextUrl.pathname; const session = await authOptions.api.getSession({headers: await headers()}); if (!session) { if (path === routes.start) { return NextResponse.next(); } return NextResponse.redirect(new URL(routes.start, req.url)); } const decodedToken = (i dont know) ? (???) as JwtPayload) : undefined; const userRoles = decodedToken ? (decodedToken.role as string[]) : []; const hasAccess = userRoles.includes(ACCESS_ROLE); if (hasAccess) { if (path === routes.start || path === routes.accessDenied) { return NextResponse.redirect(new URL(routes.home, req.url)); } return NextResponse.next(); } if (!hasAccess && path !== routes.accessDenied) { return NextResponse.redirect(new URL(routes.accessDenied, req.url)); } return NextResponse.next(); } export const config = { matcher: ['/((?!api|_next|favicon.ico|manifest|icon-).*)'], };` ### Current vs. Expected behavior I use: Next 16, Keycloack. I used next-auth to authenticate users (I presented the next-auth configuration and a screenshot of the debug demonstrating the presence of jwt tokens below.) I decided to try better-auth, but I had a problem getting jwt tokens to get the user role stored in the access token in proxy.tx (formerly middleware.ts). Pease tell me how to get jwt tokens. next-auth config: `export const authOptions: AuthOptions = { providers: [ KeycloakProvider({ clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET!, issuer: process.env.AUTHORITY_URL!, }), ], session: { maxAge: 24 * 60 * 60, }, secret: process.env.CLIENT_SECRET, callbacks: { async jwt({token, account}) { if (account) { const accessToken = account.access_token; const decodedToken = accessToken ? (jwt.decode(accessToken) as JwtPayload) : undefined; return { ...token, accessToken, refreshToken: account.refresh_token, expiresToken: decodedToken?.exp ? decodedToken.exp * 1000 : undefined, } as typeof token; } const currentTime = Date.now(); if (token.expiresToken && currentTime > token.expiresToken - 1200000) { return refreshToken(token); } return token; }, async session({session, token}) { return { ...session, accessToken: token.accessToken, refreshToken: token.refreshToken, expiresToken: token.expiresToken, }; }, }, pages: { signIn: routes.start, }, };` debug screen: <img width="447" height="449" alt="Image" src="https://github.com/user-attachments/assets/7100d480-e778-464d-9d0d-6504e47421ca" /> ### What version of Better Auth are you using? 1.4.4 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Home Single Language", "release": "10.0.26100", "cpuCount": 8, "cpuModel": "11th Gen Intel(R) Core(TM) i7-11370H @ 3.30GHz", "totalMemory": "31.75 GB", "freeMemory": "14.47 GB" }, "node": { "version": "v24.11.1", "env": "development" }, "packageManager": { "name": "npm", "version": "11.6.2" }, "frameworks": [ { "name": "next", "version": "16.0.4" }, { "name": "react", "version": "19.2.0" } ], "databases": null, "betterAuth": { "version": "^1.4.4", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import {betterAuth} from 'better-auth'; import {genericOAuth, keycloak} from 'better-auth/plugins'; export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ keycloak({ clientId: process.env.CLIENT_ID || '', clientSecret: process.env.CLIENT_SECRET || '', issuer: process.env.AUTHORITY_URL || '', }), ], }), ], }); ``` ### Additional context _No response_
GiteaMirror added the platformoauth labels 2026-04-17 19:04:16 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 1, 2025):

It looks like your Keycloak configuration is not automatically decoding the JWT for you. In Better Auth, when using the generic OAuth plugin with Keycloak, the access token is returned as part of the session data, but you may need to decode it manually to extract properties like the user roles. Here are some troubleshooting steps:

• Verify that your Keycloak issuer URL and credentials are correctly set in your configuration so that the token returned is a valid JWT. Sometimes misconfiguration can lead to unexpected token formats.

• After signing in, confirm that the session object contains the accessToken. You can then decode the token (using a library like jsonwebtoken) on your proxy/middleware to extract the JwtPayload including your roles.

• Double-check that Keycloak’s client settings are configured to include the proper claims (such as the “role” claim) in the access token.

If after reviewing these steps the issue persists, have you tried logging the raw session data to confirm the token is present and correctly formatted? Tag @bekacru or @ping__ if you’d like further assistance. Tag @better-auth-agent if you would like an answer from the Agent. What additional details can you share about the token content or any error messages you’re seeing?

If you need more help, tag @Skyvern in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3595724952 --> @better-auth-agent[bot] commented on GitHub (Dec 1, 2025): It looks like your Keycloak configuration is not automatically decoding the JWT for you. In Better Auth, when using the generic OAuth plugin with Keycloak, the access token is returned as part of the session data, but you may need to decode it manually to extract properties like the user roles. Here are some troubleshooting steps: • Verify that your Keycloak issuer URL and credentials are correctly set in your configuration so that the token returned is a valid JWT. Sometimes misconfiguration can lead to unexpected token formats. • After signing in, confirm that the session object contains the accessToken. You can then decode the token (using a library like jsonwebtoken) on your proxy/middleware to extract the JwtPayload including your roles. • Double-check that Keycloak’s client settings are configured to include the proper claims (such as the “role” claim) in the access token. If after reviewing these steps the issue persists, have you tried logging the raw session data to confirm the token is present and correctly formatted? Tag @bekacru or @ping__ if you’d like further assistance. Tag @better-auth-agent if you would like an answer from the Agent. What additional details can you share about the token content or any error messages you’re seeing? _If you need more help, tag @Skyvern in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@SokSashaa commented on GitHub (Dec 1, 2025):

@better-auth-agent

<!-- gh-comment-id:3595758908 --> @SokSashaa commented on GitHub (Dec 1, 2025): @better-auth-agent
Author
Owner

@dosubot[bot] commented on GitHub (Dec 1, 2025):

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

With better-auth, the raw Keycloak access token (JWT) is not included in the session object by default—unlike next-auth, which lets you customize the session to include it. Instead, better-auth stores the access token on the account object, which is only accessible if you use a database. In stateless mode (no DB), there’s no built-in way to get the access token in middleware or proxy logic (details).

Options:

  • Production (recommended): Use a minimal database. Then you can fetch the access token using the account ID (via getAccessToken or by querying the account record).
  • Stateless (no DB): You must customize the authentication flow. For example, use a callback or plugin to extract the access token during sign-in and store it in the session or a secure cookie. This requires you to manage token storage and refresh manually (details).

Example: Customizing the session to include the access token
You can use better-auth’s session customization to add the access token to the session object (be careful with security and cookie size):

import { betterAuth } from 'better-auth';
import { genericOAuth, keycloak } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        keycloak({
          clientId: process.env.CLIENT_ID || '',
          clientSecret: process.env.CLIENT_SECRET || '',
          issuer: process.env.AUTHORITY_URL || '',
        }),
      ],
    }),
  ],
  session: {
    // Add a custom field to the session
    additionalFields: {
      accessToken: {
        type: 'string',
        get: async ({ account }) => account?.accessToken,
      },
    },
  },
});

Then in your middleware/proxy:

import jwtDecode from 'jwt-decode';

const session = await auth.api.getSession({ headers: await headers() });
const accessToken = session?.session?.accessToken;
const decodedToken = accessToken ? jwtDecode(accessToken) : undefined;
const userRoles = decodedToken?.role || [];

Caveats:

  • If you store the access token in the session/cookie, keep the session small and secure (prefer HTTP-only cookies).
  • You are responsible for refreshing tokens if you go this route.

If you want to check user roles in middleware, you can also use getCookieCache if session cookie caching is enabled and your session is small enough (example).

For robust, production-grade access token handling and refresh, using a database is the recommended path (details).

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:3595763872 --> @dosubot[bot] commented on GitHub (Dec 1, 2025): <!-- Greeting --> Hi @SokSashaa! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> With better-auth, the raw Keycloak access token (JWT) is not included in the session object by default—unlike next-auth, which lets you customize the session to include it. Instead, better-auth stores the access token on the account object, which is only accessible if you use a database. In stateless mode (no DB), there’s no built-in way to get the access token in middleware or proxy logic ([details](https://github.com/better-auth/better-auth/issues/2202#issuecomment-3472212514)). **Options:** - **Production (recommended):** Use a minimal database. Then you can fetch the access token using the account ID (via `getAccessToken` or by querying the account record). - **Stateless (no DB):** You must customize the authentication flow. For example, use a callback or plugin to extract the access token during sign-in and store it in the session or a secure cookie. This requires you to manage token storage and refresh manually ([details](https://github.com/better-auth/better-auth/issues/1796)). **Example: Customizing the session to include the access token** You can use better-auth’s session customization to add the access token to the session object (be careful with security and cookie size): ```ts import { betterAuth } from 'better-auth'; import { genericOAuth, keycloak } from 'better-auth/plugins'; export const auth = betterAuth({ plugins: [ genericOAuth({ config: [ keycloak({ clientId: process.env.CLIENT_ID || '', clientSecret: process.env.CLIENT_SECRET || '', issuer: process.env.AUTHORITY_URL || '', }), ], }), ], session: { // Add a custom field to the session additionalFields: { accessToken: { type: 'string', get: async ({ account }) => account?.accessToken, }, }, }, }); ``` Then in your middleware/proxy: ```ts import jwtDecode from 'jwt-decode'; const session = await auth.api.getSession({ headers: await headers() }); const accessToken = session?.session?.accessToken; const decodedToken = accessToken ? jwtDecode(accessToken) : undefined; const userRoles = decodedToken?.role || []; ``` **Caveats:** - If you store the access token in the session/cookie, keep the session small and secure (prefer HTTP-only cookies). - You are responsible for refreshing tokens if you go this route. If you want to check user roles in middleware, you can also use `getCookieCache` if session cookie caching is enabled and your session is small enough ([example](https://github.com/better-auth/better-auth/issues/3431)). For robust, production-grade access token handling and refresh, using a database is the recommended path ([details](https://github.com/better-auth/better-auth/issues/2202#issuecomment-3472212514)). <!-- 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/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?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=With%20better-auth%2C%20the%20raw%20Keycloak%20access%20token%20%28JWT%29%20is%20not%20included%20in%20the%20session%20object%20by%20default%E2%80%94unlike%20next-auth%2C%20which%20lets%20you%20customize%20the%20session%20to%20include%20it.%20Instead%2C%20better-auth%20stores%20the%20access%20token%20on%20the%20account%20object%2C%20which%20is%20only%20accessible%20if%20you%20use%20a%20database.%20In%20stateless%20mode%20%28no%20DB%29%2C%20there%E2%80%99s%20no%20built-in%20way%20to%20get%20the%20access%20token%20in%20middleware%20or%20proxy%20logic%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/2202%23issuecomment-3472212514%29%29.%0A%0A%2A%2AOptions%3A%2A%2A%0A%0A-%20%2A%2AProduction%20%28recommended%29%3A%2A%2A%20Use%20a%20minimal%20database.%20Then%20you%20can%20fetch%20the%20access%20token%20using%20the%20account%20ID%20%28via%20%60getAccessToken%60%20or%20by%20querying%20the%20account%20record%29.%0A-%20%2A%2AStateless%20%28no%20DB%29%3A%2A%2A%20You%20must%20customize%20the%20authentication%20flow.%20For%20example%2C%20use%20a%20callback%20or%20plugin%20to%20extract%20the%20access%20token%20during%20sign-in%20and%20store%20it%20in%20the%20session%20or%20a%20secure%20cookie.%20This%20requires%20you%20to%20manage%20token%20storage%20and%20refresh%20manually%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/1796%29%29.%0A%0A%2A%2AExample%3A%20Customizing%20the%20session%20to%20include%20the%20access%20token%2A%2A%0AYou%20can%20use%20better-auth%E2%80%99s%20session%20customization%20to%20add%20the%20access%20token%20to%20the%20session%20object%20%28be%20careful%20with%20security%20and%20cookie%20size%29%3A%0A%0A%60%60%60ts%0Aimport%20%7B%20betterAuth%20%7D%20from%20%27better-auth%27%3B%0Aimport%20%7B%20genericOAuth%2C%20keycloak%20%7D%20from%20%27better-auth/plugins%27%3B%0A%0Aexport%20const%20auth%20%3D%20betterAuth%28%7B%0A%20%20plugins%3A%20%5B%0A%20%20%20%20genericOAuth%28%7B%0A%20%20%20%20%20%20config%3A%20%5B%0A%20%20%20%20%20%20%20%20keycloak%28%7B%0A%20%20%20%20%20%20%20%20%20%20clientId%3A%20process.env.CLIENT_ID%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%20%20clientSecret%3A%20process.env.CLIENT_SECRET%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%20%20issuer%3A%20process.env.AUTHORITY_URL%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%7D%29%2C%0A%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%7D%29%2C%0A%20%20%5D%2C%0A%20%20session%3A%20%7B%0A%20%20%20%20//%20Add%20a%20custom%20field%20to%20the%20session%0A%20%20%20%20additionalFields%3A%20%7B%0A%20%20%20%20%20%20accessToken%3A%20%7B%0A%20%20%20%20%20%20%20%20type%3A%20%27string%27%2C%0A%20%20%20%20%20%20%20%20get%3A%20async%20%28%7B%20account%20%7D%29%20%3D%3E%20account%3F.accessToken%2C%0A%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%7D%2C%0A%20%20%7D%2C%0A%7D%29%3B%0A%60%60%60%0A%0AThen%20in%20your%20middleware/proxy%3A%0A%0A%60%60%60ts%0Aimport%20jwtDecode%20from%20%27jwt-decode%27%3B%0A%0Aconst%20session%20%3D%20await%20auth.api.getSession%28%7B%20headers%3A%20await%20headers%28%29%20%7D%29%3B%0Aconst%20accessToken%20%3D%20session%3F.session%3F.accessToken%3B%0Aconst%20decodedToken%20%3D%20accessToken%20%3F%20jwtDecode%28accessToken%29%20%3A%20undefined%3B%0Aconst%20userRoles%20%3D%20decodedToken%3F.role%20%7C%7C%20%5B%5D%3B%0A%60%60%60%0A%0A%2A%2ACaveats%3A%2A%2A%20%20%0A-%20If%20you%20store%20the%20access%20token%20in%20the%20session/cookie%2C%20keep%20the%20session%20small%20and%20secure%20%28prefer%20HTTP-only%20cookies%29.%0A-%20You%20are%20responsible%20for%20refreshing%20tokens%20if%20you%20go%20this%20route.%0A%0AIf%20you%20want%20to%20check%20user%20roles%20in%20middleware%2C%20you%20can%20also%20use%20%60getCookieCache%60%20if%20session%20cookie%20caching%20is%20enabled%20and%20your%20session%20is%20small%20enough%C2%A0%28%5Bexample%5D%28https%3A//github.com/better-auth/better-auth/issues/3431%29%29.%0A%0AFor%20robust%2C%20production-grade%20access%20token%20handling%20and%20refresh%2C%20using%20a%20database%20is%20the%20recommended%20path%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/2202%23issuecomment-3472212514%29%29.)&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/6443)
Author
Owner

@Lookwe69 commented on GitHub (Jan 13, 2026):

A solution ?

<!-- gh-comment-id:3744387976 --> @Lookwe69 commented on GitHub (Jan 13, 2026): A solution ?
Author
Owner

@stoompa commented on GitHub (Jan 28, 2026):

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

With better-auth, the raw Keycloak access token (JWT) is not included in the session object by default—unlike next-auth, which lets you customize the session to include it. Instead, better-auth stores the access token on the account object, which is only accessible if you use a database. In stateless mode (no DB), there’s no built-in way to get the access token in middleware or proxy logic (details).

Options:

* **Production (recommended):** Use a minimal database. Then you can fetch the access token using the account ID (via `getAccessToken` or by querying the account record).

* **Stateless (no DB):** You must customize the authentication flow. For example, use a callback or plugin to extract the access token during sign-in and store it in the session or a secure cookie. This requires you to manage token storage and refresh manually ([details](https://github.com/better-auth/better-auth/issues/1796)).

Example: Customizing the session to include the access token You can use better-auth’s session customization to add the access token to the session object (be careful with security and cookie size):

import { betterAuth } from 'better-auth';
import { genericOAuth, keycloak } from 'better-auth/plugins';

export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
keycloak({
clientId: process.env.CLIENT_ID || '',
clientSecret: process.env.CLIENT_SECRET || '',
issuer: process.env.AUTHORITY_URL || '',
}),
],
}),
],
session: {
// Add a custom field to the session
additionalFields: {
accessToken: {
type: 'string',
get: async ({ account }) => account?.accessToken,
},
},
},
});

Then in your middleware/proxy:

import jwtDecode from 'jwt-decode';

const session = await auth.api.getSession({ headers: await headers() });
const accessToken = session?.session?.accessToken;
const decodedToken = accessToken ? jwtDecode(accessToken) : undefined;
const userRoles = decodedToken?.role || [];

Caveats:

* If you store the access token in the session/cookie, keep the session small and secure (prefer HTTP-only cookies).

* You are responsible for refreshing tokens if you go this route.

If you want to check user roles in middleware, you can also use getCookieCache if session cookie caching is enabled and your session is small enough (example).

For robust, production-grade access token handling and refresh, using a database is the recommended path (details).

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

@dosubot the provided example does not work

<!-- gh-comment-id:3810294735 --> @stoompa commented on GitHub (Jan 28, 2026): > Hi [@SokSashaa](https://github.com/SokSashaa)! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. > > With better-auth, the raw Keycloak access token (JWT) is not included in the session object by default—unlike next-auth, which lets you customize the session to include it. Instead, better-auth stores the access token on the account object, which is only accessible if you use a database. In stateless mode (no DB), there’s no built-in way to get the access token in middleware or proxy logic ([details](https://github.com/better-auth/better-auth/issues/2202#issuecomment-3472212514)). > > **Options:** > > * **Production (recommended):** Use a minimal database. Then you can fetch the access token using the account ID (via `getAccessToken` or by querying the account record). > > * **Stateless (no DB):** You must customize the authentication flow. For example, use a callback or plugin to extract the access token during sign-in and store it in the session or a secure cookie. This requires you to manage token storage and refresh manually ([details](https://github.com/better-auth/better-auth/issues/1796)). > > > **Example: Customizing the session to include the access token** You can use better-auth’s session customization to add the access token to the session object (be careful with security and cookie size): > > import { betterAuth } from 'better-auth'; > import { genericOAuth, keycloak } from 'better-auth/plugins'; > > export const auth = betterAuth({ > plugins: [ > genericOAuth({ > config: [ > keycloak({ > clientId: process.env.CLIENT_ID || '', > clientSecret: process.env.CLIENT_SECRET || '', > issuer: process.env.AUTHORITY_URL || '', > }), > ], > }), > ], > session: { > // Add a custom field to the session > additionalFields: { > accessToken: { > type: 'string', > get: async ({ account }) => account?.accessToken, > }, > }, > }, > }); > > Then in your middleware/proxy: > > import jwtDecode from 'jwt-decode'; > > const session = await auth.api.getSession({ headers: await headers() }); > const accessToken = session?.session?.accessToken; > const decodedToken = accessToken ? jwtDecode(accessToken) : undefined; > const userRoles = decodedToken?.role || []; > > **Caveats:** > > * If you store the access token in the session/cookie, keep the session small and secure (prefer HTTP-only cookies). > > * You are responsible for refreshing tokens if you go this route. > > > If you want to check user roles in middleware, you can also use `getCookieCache` if session cookie caching is enabled and your session is small enough ([example](https://github.com/better-auth/better-auth/issues/3431)). > > For robust, production-grade access token handling and refresh, using a database is the recommended path ([details](https://github.com/better-auth/better-auth/issues/2202#issuecomment-3472212514)). > > _To reply, just mention [@dosu](https://go.dosu.dev/dosubot)._ > > How did I do? [Good](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/84da9ee4-f6fe-48f1-ab68-de06f1e23f9b?feedback_type=other) > > [![Chat with Dosu](https://camo.githubusercontent.com/7c571478962a6b8a5d47cac74b6824c75e4f6731bba52377e6007f9150462923/68747470733a2f2f646f73752e6465762f646f73752d636861742d62616467652e737667)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github) [![Open in Cursor](https://camo.githubusercontent.com/bdf7dbded65ee585887f47e5fd6fa099ea0933088b4b03d02e83b4def50154d4/68747470733a2f2f646f73752e6465762f646f73752d696e2d637572736f722e737667)](https://cursor.com/link/prompt?text=With%20better-auth%2C%20the%20raw%20Keycloak%20access%20token%20%28JWT%29%20is%20not%20included%20in%20the%20session%20object%20by%20default%E2%80%94unlike%20next-auth%2C%20which%20lets%20you%20customize%20the%20session%20to%20include%20it.%20Instead%2C%20better-auth%20stores%20the%20access%20token%20on%20the%20account%20object%2C%20which%20is%20only%20accessible%20if%20you%20use%20a%20database.%20In%20stateless%20mode%20%28no%20DB%29%2C%20there%E2%80%99s%20no%20built-in%20way%20to%20get%20the%20access%20token%20in%20middleware%20or%20proxy%20logic%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/2202%23issuecomment-3472212514%29%29.%0A%0A%2A%2AOptions%3A%2A%2A%0A%0A-%20%2A%2AProduction%20%28recommended%29%3A%2A%2A%20Use%20a%20minimal%20database.%20Then%20you%20can%20fetch%20the%20access%20token%20using%20the%20account%20ID%20%28via%20%60getAccessToken%60%20or%20by%20querying%20the%20account%20record%29.%0A-%20%2A%2AStateless%20%28no%20DB%29%3A%2A%2A%20You%20must%20customize%20the%20authentication%20flow.%20For%20example%2C%20use%20a%20callback%20or%20plugin%20to%20extract%20the%20access%20token%20during%20sign-in%20and%20store%20it%20in%20the%20session%20or%20a%20secure%20cookie.%20This%20requires%20you%20to%20manage%20token%20storage%20and%20refresh%20manually%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/1796%29%29.%0A%0A%2A%2AExample%3A%20Customizing%20the%20session%20to%20include%20the%20access%20token%2A%2A%0AYou%20can%20use%20better-auth%E2%80%99s%20session%20customization%20to%20add%20the%20access%20token%20to%20the%20session%20object%20%28be%20careful%20with%20security%20and%20cookie%20size%29%3A%0A%0A%60%60%60ts%0Aimport%20%7B%20betterAuth%20%7D%20from%20%27better-auth%27%3B%0Aimport%20%7B%20genericOAuth%2C%20keycloak%20%7D%20from%20%27better-auth/plugins%27%3B%0A%0Aexport%20const%20auth%20%3D%20betterAuth%28%7B%0A%20%20plugins%3A%20%5B%0A%20%20%20%20genericOAuth%28%7B%0A%20%20%20%20%20%20config%3A%20%5B%0A%20%20%20%20%20%20%20%20keycloak%28%7B%0A%20%20%20%20%20%20%20%20%20%20clientId%3A%20process.env.CLIENT_ID%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%20%20clientSecret%3A%20process.env.CLIENT_SECRET%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%20%20issuer%3A%20process.env.AUTHORITY_URL%20%7C%7C%20%27%27%2C%0A%20%20%20%20%20%20%20%20%7D%29%2C%0A%20%20%20%20%20%20%5D%2C%0A%20%20%20%20%7D%29%2C%0A%20%20%5D%2C%0A%20%20session%3A%20%7B%0A%20%20%20%20//%20Add%20a%20custom%20field%20to%20the%20session%0A%20%20%20%20additionalFields%3A%20%7B%0A%20%20%20%20%20%20accessToken%3A%20%7B%0A%20%20%20%20%20%20%20%20type%3A%20%27string%27%2C%0A%20%20%20%20%20%20%20%20get%3A%20async%20%28%7B%20account%20%7D%29%20%3D%3E%20account%3F.accessToken%2C%0A%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%7D%2C%0A%20%20%7D%2C%0A%7D%29%3B%0A%60%60%60%0A%0AThen%20in%20your%20middleware/proxy%3A%0A%0A%60%60%60ts%0Aimport%20jwtDecode%20from%20%27jwt-decode%27%3B%0A%0Aconst%20session%20%3D%20await%20auth.api.getSession%28%7B%20headers%3A%20await%20headers%28%29%20%7D%29%3B%0Aconst%20accessToken%20%3D%20session%3F.session%3F.accessToken%3B%0Aconst%20decodedToken%20%3D%20accessToken%20%3F%20jwtDecode%28accessToken%29%20%3A%20undefined%3B%0Aconst%20userRoles%20%3D%20decodedToken%3F.role%20%7C%7C%20%5B%5D%3B%0A%60%60%60%0A%0A%2A%2ACaveats%3A%2A%2A%20%20%0A-%20If%20you%20store%20the%20access%20token%20in%20the%20session/cookie%2C%20keep%20the%20session%20small%20and%20secure%20%28prefer%20HTTP-only%20cookies%29.%0A-%20You%20are%20responsible%20for%20refreshing%20tokens%20if%20you%20go%20this%20route.%0A%0AIf%20you%20want%20to%20check%20user%20roles%20in%20middleware%2C%20you%20can%20also%20use%20%60getCookieCache%60%20if%20session%20cookie%20caching%20is%20enabled%20and%20your%20session%20is%20small%20enough%C2%A0%28%5Bexample%5D%28https%3A//github.com/better-auth/better-auth/issues/3431%29%29.%0A%0AFor%20robust%2C%20production-grade%20access%20token%20handling%20and%20refresh%2C%20using%20a%20database%20is%20the%20recommended%20path%C2%A0%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/2202%23issuecomment-3472212514%29%29.) [![Join Discord](https://camo.githubusercontent.com/7d2066700925db1e370d956834f9ea0e5bc92cecbe84963fbd2a363e45091b4e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6a6f696e2d3538363546323f6c6f676f3d646973636f7264266c6f676f436f6c6f723d7768697465266c6162656c3d)](https://go.dosu.dev/discord-bot) [![Share on X](https://camo.githubusercontent.com/a8bd7f3fcf3f5e3bd124eee9ae50ae2ac17b40971726665ac2121d48f9aee155/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f582d73686172652d626c61636b)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/6443) @dosubot the provided example does not work
Author
Owner

@StillNotUsefull commented on GitHub (Jan 31, 2026):

Im also facing this problem. I cannot get a accessToken with better auth while using the generic oauth with keycloak while using stateless mode.

<!-- gh-comment-id:3828828369 --> @StillNotUsefull commented on GitHub (Jan 31, 2026): Im also facing this problem. I cannot get a accessToken with better auth while using the generic oauth with keycloak while using stateless mode.
Author
Owner

@Charismara commented on GitHub (Apr 2, 2026):

I'm running into the same issue. This makes BetterAuth unusable for us.

Is there any update on this?

<!-- gh-comment-id:4175524501 --> @Charismara commented on GitHub (Apr 2, 2026): I'm running into the same issue. This makes BetterAuth unusable for us. Is there any update on this?
Author
Owner

@Charismara commented on GitHub (Apr 2, 2026):

Ok, so I got it working for our use case. Maybe this helps someone else too:

export const auth = betterAuth({
   plugins: [
      // ... other plugins
      customSession(async ({ user, session }, ctx) => {
         const account = await getAccountCookie(ctx);
         let roles: string[] = [];

         if (account && account.accessToken) {
            const decodedToken = parseJwt(account.accessToken);
            if ("resource_access" in decodedToken) {
               const resourceAccess = decodedToken.resource_access;
               if (env.KEYCLOAK_CLIENT_ID in resourceAccess) {
                  const clientAccess = resourceAccess[env.KEYCLOAK_CLIENT_ID];
                  if ("roles" in clientAccess) {
                     roles = clientAccess.roles;
                  }
               }
            }
         }

         return {
            session,
            user,
            roles,
         };
      }),
   ],
   account: {
      storeStateStrategy: "cookie",
      storeAccountCookie: true,
   },
});

function parseJwt(token: string) {
   const base64Url = token.split(".")[1]!;
   const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
   const jsonPayload = decodeURIComponent(
      atob(base64)
         .split("")
         .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
         .join(""),
   );
   return JSON.parse(jsonPayload);
}

and

export const authClient = createAuthClient({
   plugins: [/* ... other plugins, */ customSessionClient<typeof auth>()],
});
<!-- gh-comment-id:4175927692 --> @Charismara commented on GitHub (Apr 2, 2026): Ok, so I got it working for our use case. Maybe this helps someone else too: ```ts export const auth = betterAuth({ plugins: [ // ... other plugins customSession(async ({ user, session }, ctx) => { const account = await getAccountCookie(ctx); let roles: string[] = []; if (account && account.accessToken) { const decodedToken = parseJwt(account.accessToken); if ("resource_access" in decodedToken) { const resourceAccess = decodedToken.resource_access; if (env.KEYCLOAK_CLIENT_ID in resourceAccess) { const clientAccess = resourceAccess[env.KEYCLOAK_CLIENT_ID]; if ("roles" in clientAccess) { roles = clientAccess.roles; } } } } return { session, user, roles, }; }), ], account: { storeStateStrategy: "cookie", storeAccountCookie: true, }, }); function parseJwt(token: string) { const base64Url = token.split(".")[1]!; const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const jsonPayload = decodeURIComponent( atob(base64) .split("") .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) .join(""), ); return JSON.parse(jsonPayload); } ``` and ```ts export const authClient = createAuthClient({ plugins: [/* ... other plugins, */ customSessionClient<typeof auth>()], }); ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27841