Minyie Diaz
← Back to blog
Apr 17, 2026OktaBetter AuthNext.jsAuthentication

Okta Authentication with Better Auth in Next.js (Including SLO)

One requirement on this project was straightforward on paper and a little trickier in practice: support Okta SSO in a Next.js app and make logout behave like users expect in an enterprise environment.
I used Better Auth as the auth layer. It kept the integration lightweight, and its plugin model made it easy to shape the session data without bolting on extra libraries.
The setup itself was not hard. The part that needed extra care was Single Logout (SLO), because ending the local app session is only half of the job when Okta is the identity provider.

Server Setup

The server config lives in src/lib/auth.ts.
I used three plugins:
  1. jwt()
  2. genericOAuth()
  3. customSession()
jwt() was intentional. Without it, every request checks session state in the database. With it, the session becomes a signed token, which cuts an extra DB read out of most authenticated requests.
genericOAuth() handles the Okta side through the OIDC discovery document:
https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/.well-known/openid-configuration
That lets Better Auth pick up auth/token/userinfo endpoints from Okta directly, so you only configure scopes and client credentials.
customSession() is optional, but it is useful when you want session fields beyond standard profile claims. In my case, I wanted a clean extension point for custom properties like roles.
customSession(async ({ user, session }) => {
  return {
    user: {
      ...user,
      roles: "roles",
    },
    session,
  };
}),
If you do not need enriched fields, you can skip this plugin and keep the base session shape.
Here's what the full server config looks like with all three plugins wired:
export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET!,
  baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',

  account: {
    /**
     * storeAccountCookie is retained so Better Auth sets account_data on the
     * response (used for other internal purposes). The after hook below reads
     * the idToken directly from the internalAdapter rather than from this cookie,
     * because the cookie is lost when the callback throws its redirect.
     */
    storeAccountCookie: true,
  },

  hooks: {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    after: createAuthMiddleware(async (ctx: any) => {
      await storeOktaIdTokenCookie(ctx);
    }),
  },

  plugins: [
    /**
     * jwt plugin – makes sessions stateless.
     * The signed JWT carries the session payload so the server never needs to
     * query the database on every authenticated request.
     */
    jwt(),

    /**
     * customSession extends the /get-session response — i.e. what useSession()
     * returns in React components. Use this (not definePayload) to add extra
     * fields that components need to read from the session.
     *
     * This is async, so external API calls are supported. It fires on every
     * /get-session request, so keep the call fast or add server-side caching.
     */
    customSession(async ({ user, session }) => {
      return {
        user: {
          ...user,
          // Map the fields your external API returns.
          // These are available as session.user.roles, session.user.department, etc.
          roles: 'roles',
        },
        session,
      };
    }),

    /**
     * genericOAuth plugin with the Okta pre-configured helper.
     * The discoveryUrl points at Okta's OIDC well-known endpoint so BetterAuth
     * auto-resolves authorization / token / userinfo / JWKS endpoints.
     */
    genericOAuth({
      config: [
        {
          providerId: 'okta',
          // Use the org-level OIDC endpoint.
          // If you have a custom authorization server (e.g. "default"), set
          // OKTA_AUTH_SERVER_ID=default in .env.local and switch to:
          // `https://${process.env.OKTA_DOMAIN}/oauth2/${process.env.OKTA_AUTH_SERVER_ID}/.well-known/openid-configuration`
          discoveryUrl: `https://${process.env.OKTA_DOMAIN}/oauth2/${process.env.OKTA_AUTH_SERVER_ID}/.well-known/openid-configuration`,
          clientId: process.env.OKTA_CLIENT_ID!,
          clientSecret: process.env.OKTA_CLIENT_SECRET!,
          scopes: ['openid', 'profile', 'email'],
        },
      ],
    }),
  ],
});

The Real SLO Issue: id_token_hint

For Okta logout, you need to call its end_session_endpoint with an id_token_hint. Without that hint, users can appear signed out of your app but still be logged into Okta.
Better Auth can store account data in cookies after OAuth callback, but in this flow I found it more reliable to persist the idToken myself after session creation, then use it during logout.
The pattern that worked consistently:
  1. Use an after hook on auth middleware after callback/session creation.
  2. Resolve the user session.
  3. Read the account record from the adapter.
  4. Extract idToken.
  5. Store it in a dedicated HttpOnly cookie (okta_id_token) with the same general lifetime as the app session.
async function storeOktaIdTokenCookie(ctx: any): Promise<void> {
  if (ctx.path !== '/oauth2/callback/:providerId') return;

  // ctx.params is undefined in global hooks — extract providerId from the request URL.
  const pathname = ctx.request?.url ? new URL(ctx.request.url).pathname : ctx.path;
  const providerId = pathname.split('/').pop();
  if (providerId !== 'okta') return;

  // newSession is populated by the callback handler before the redirect fires.
  const userId = ctx.context.newSession?.user?.id;
  if (!userId) {
    console.log('[Okta login] No newSession.user.id in after hook');
    return;
  }

  // The idToken was persisted on the account record during the OAuth callback.
  const accounts = await ctx.context.internalAdapter.findAccountByUserId(userId);
  const oktaAccount = accounts?.find((a: { providerId: string }) => a.providerId === 'okta');
  const idToken: string | undefined = oktaAccount?.idToken ?? undefined;

  if (!idToken) {
    console.log('[Okta login] idToken not present on account record');
    return;
  }

  const isSecure = (process.env.BETTER_AUTH_URL ?? '').startsWith('https://');
  ctx.setCookie('okta_id_token', idToken, {
    httpOnly: true,
    secure: isSecure,
    sameSite: 'Lax',
    maxAge: 60 * 60 * 8, // 8 hours — matches expected session lifetime
    path: '/',
  });
  console.log('[Okta login] Stored idToken in okta_id_token cookie');
}
That cookie is server-only and used only when logging out.

Logout Endpoint Flow

I added a dedicated endpoint at pages/api/auth/slo.ts (alongside the Better Auth catch-all route) to make logout deterministic.
The endpoint flow is:
  1. Check whether a local Better Auth session exists.
  2. Read okta_id_token from cookies.
  3. Call Better Auth sign-out internally to clear local session cookies.
  4. Expire the okta_id_token cookie.
  5. Redirect to Okta v1/logout with id_token_hint and post_logout_redirect_uri.
Here's the implementation:
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const webHeaders = fromNodeHeaders(req.headers);

  // --- 1. Get the current betterAuth session ---
  const session = await auth.api.getSession({ headers: webHeaders });

  if (session) {
    // --- 2. Read the Okta id_token from the long-lived HttpOnly cookie ---
    // Set by hooks.after in auth.ts during the Okta OAuth callback.
    // Used as id_token_hint so Okta can terminate the Okta session server-side.
    const idToken: string | undefined = req.cookies['okta_id_token'] ?? undefined;

    // --- 3. Sign out of Better Auth (clears session cookie) ---
    // Build a synthetic POST request to Better Auth's /sign-out endpoint so the
    // framework correctly clears the session cookie in the response headers.
    const appURL = process.env.BETTER_AUTH_URL || 'http://localhost:3000';
    const signOutRequest = new Request(new URL('/api/auth/sign-out', appURL).toString(), {
      method: 'POST',
      headers: webHeaders,
    });

    const signOutResponse = await auth.handler(signOutRequest);

    // Clear the custom okta_id_token cookie — Better Auth's sign-out only clears
    // its own session cookie and is unaware of this custom one.
    const isSecure = (process.env.BETTER_AUTH_URL ?? '').startsWith('https://');
    res.appendHeader(
      'Set-Cookie',
      `okta_id_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${isSecure ? '; Secure' : ''}`
    );

    // --- 4. Build the Okta end_session_endpoint URL ---
    const oktaDomain = process.env.OKTA_DOMAIN;
    const authServerId = process.env.OKTA_AUTH_SERVER_ID;

    if (!oktaDomain || !authServerId) {
      // Configuration incomplete – fall back to a simple local logout.
      res.redirect(302, '/');
      return;
    }

    const logoutURL = new URL(`https://${oktaDomain}/oauth2/${authServerId}/v1/logout`);
    if (idToken) {
      logoutURL.searchParams.set('id_token_hint', idToken);
    }
    logoutURL.searchParams.set('post_logout_redirect_uri', appURL);

    res.redirect(302, logoutURL.toString());
  } else {
    // No active session – nothing to log out of, just redirect home.
    res.redirect(302, '/');
  }
}
At that point Okta terminates its own session and returns the user to your app.
One easy thing to miss: the post-logout URL must be whitelisted in Okta under Sign-out redirect URIs, or you will get an Okta-side error after logout.

Client Setup

Client config in src/lib/auth-client.ts mirrors the server plugin stack:
  1. jwtClient()
  2. genericOAuthClient()
  3. customSessionClient()
Typing customSessionClient() against typeof auth keeps session field extensions type-safe in components.
Login remains simple:
signIn.social({ provider: "okta", callbackURL: "/dashboard" });
For logout, use a hard redirect to SLO:
window.location.href = "/api/auth/slo";
I prefer the hard redirect here because the full browser round trip to Okta is required.

Environment Variables

This setup depends on six variables:
  1. BETTER_AUTH_SECRET
  2. BETTER_AUTH_URL
  3. OKTA_DOMAIN
  4. OKTA_AUTH_SERVER_ID
  5. OKTA_CLIENT_ID
  6. OKTA_CLIENT_SECRET
Use a strong random value for BETTER_AUTH_SECRET and keep all secrets in your secure secret manager and deployment environment settings.

Final Notes

The biggest lesson for me was that SSO success is mostly about logout correctness, not just login success. Local sign-out can look fine while the IdP session remains active, and users feel that inconsistency immediately.
Once the id_token_hint flow was wired properly, the behavior became predictable: sign-in works, session enrichment stays flexible, and logout clears both app and Okta sessions in one path.