WalkthroughsNextAuth & session flow

NextAuth & session flow

Every login, every session cookie, every server-side “who is the current user?” call in the web app goes through one file: apps/web/app/api/auth/[...nextauth]/route.ts. This walkthrough breaks it into six slices and explains what each does — especially for readers coming from ASP.NET Core Identity, where the equivalent concepts live under different names.

The high-level picture: NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials. is a library that exposes a single handler to handle all /api/auth/* routes (sign-in, sign-out, callback, providers, session). You hand it a configuration object called authOptions — analogous to an AuthenticationBuilder chain — and it returns a request handler. This file wraps the base authOptions to add audit logging and Sentry reporting around the three most interesting callbacks.

1. Imports

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 1–9View full file ↗
import * as Sentry from "@sentry/nextjs";
import NextAuth from "next-auth";
import { logger } from "@hivecfm/logger";
import { ENTERPRISE_LICENSE_KEY, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { getAppSsoCredentials } from "@/modules/ee/sso/lib/sso-config";

The catch-all route name [...nextauth] is Next.jsNext.jsReact framework used by HiveCFM Core. Handles routing, server rendering, and API routes in one bundle.’s App Router syntax for “match any sub-path under /api/auth.” Inside, we pull in Sentry and our structured logger, import the base authOptions from the auth feature module, and grab the audit-log helper.

fetchCache = "force-no-store" turns off Next’s response cache for this route. Auth responses contain tokens, cookies, and per-user data — caching them even for a moment would be a correctness and security bug. Treat it the same way you would [ResponseCache(NoStore = true)] in .NET.

2. Handler shell

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 11–17View full file ↗
export const fetchCache = "force-no-store";

const handler = async (req: Request, ctx: any) => {
  const eventId = req.headers.get("x-request-id") ?? undefined;

  // Resolve DB-stored Azure credentials; fall back to env vars inside getSSOProviders
  let dbAzureCreds: { clientId: string; clientSecret: string; tenantId: string } | undefined;

NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials.’s v5 handler takes (req, ctx) and returns a Response. We wrap it in our own async function so we can extend the authOptions per request — specifically to pull x-request-id off the incoming headers and thread it through audit events.

The spread of baseAuthOptions.callbacks then override-and-wrap pattern is the key idiom: we keep everything the module defined (providers, session strategy, adapters) and only wrap the three callbacks where we want to add cross-cutting logging.

3. The jwt callback — token lifecycle

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 18–55View full file ↗
  if (ENTERPRISE_LICENSE_KEY) {
    try {
      const creds = await getAppSsoCredentials();
      if (creds) dbAzureCreds = creds;
    } catch {
      // Non-fatal: fall back to env vars
    }
  }

  const authOptions = {
    ...baseAuthOptions,
    ...(ENTERPRISE_LICENSE_KEY && {
      providers: [
        ...baseAuthOptions.providers.filter((p: any) => p.id !== "azure-ad"),
        ...getSSOProviders(dbAzureCreds),
      ],
    }),
    callbacks: {
      ...baseAuthOptions.callbacks,
      async jwt(params: any) {
        let result: any = params.token;
        let error: any = undefined;

        try {
          if (baseAuthOptions.callbacks?.jwt) {
            result = await baseAuthOptions.callbacks.jwt(params);
          }
        } catch (err) {
          error = err;
          logger.withContext({ eventId, err }).error("JWT callback failed");

          if (SENTRY_DSN && IS_PRODUCTION) {
            Sentry.captureException(err);
          }
        }

        // Audit JWT operations (token refresh, updates)
        if (params.trigger && params.token?.profile?.id) {

The jwt callback fires every time NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials. needs to mint or refresh a JWTJWTA compact, signed token that carries identity between services. HiveCFM issues one per authenticated user. — on sign-in, on every session check that extends the token, and on explicit updates. The base module’s version of this callback is where profile data is loaded onto the token; our wrapper is the audit hook.

Three moving parts to notice:

  1. Delegation: we call the base jwt callback inside a try. If it throws, we capture the error, log it with the request id, and push it to Sentry in production — but we still rethrow afterwards so NextAuth’s error handling runs.
  2. Audit trigger detection: params.trigger is non-empty when the token is created/refreshed/updated. Only then do we emit a jwtTokenCreated audit event. Transient refreshes do fire one audit event per refresh, which is what compliance needs.
  3. queueAuditEventBackground: audit logs go onto an async queue rather than blocking the auth response. If you need to trace why an audit event is late, start in that queue, not here.

4. The session callback — what the client sees

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 56–75View full file ↗
          const status: TAuditStatus = error ? "failure" : "success";
          const auditLog = {
            action: "jwtTokenCreated" as const,
            targetType: "user" as const,
            userId: params.token.profile.id,
            targetId: params.token.profile.id,
            organizationId: UNKNOWN_DATA,
            status,
            userType: "user" as const,
            newObject: { trigger: params.trigger, tokenType: "jwt" },
            ...(error ? { eventId } : {}),
          };

          queueAuditEventBackground(auditLog);
        }

        if (error) throw error;
        return result;
      },
      async session(params: any) {

session is what your ReactReactThe component-based UI library every HiveCFM frontend is written in. Components are TypeScript functions returning JSX. code sees when it calls useSession() or what getServerSession(authOptions) returns on the server. The callback’s job is to translate the JWTJWTA compact, signed token that carries identity between services. HiveCFM issues one per authenticated user. (or database session) into the public-facing session shape — trimming private fields, adding organization memberships, and so on.

Our wrapper is strictly a pass-through with error reporting. We don’t add audit entries here: session can fire on nearly every server component render, and auditing each one would drown the log stream. The try/catch/rethrow pattern matches jwt so Sentry gets everything, but the caller’s behavior is unchanged.

When a server component needs the current user, it calls getServerSession(authOptions). That reads the cookie, triggers this callback, and hands back the session object — the same mental model as HttpContext.User in .NET, except it’s explicit per call instead of ambient.

5. The signIn callback — the gate

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 76–133View full file ↗
        let result: any = params.session;
        let error: any = undefined;

        try {
          if (baseAuthOptions.callbacks?.session) {
            result = await baseAuthOptions.callbacks.session(params);
          }
        } catch (err) {
          error = err;
          logger.withContext({ eventId, err }).error("Session callback failed");

          if (SENTRY_DSN && IS_PRODUCTION) {
            Sentry.captureException(err);
          }
        }

        if (error) throw error;
        return result;
      },
      async signIn({ user, account, profile, email, credentials }) {
        let result: boolean | string = true;
        let error: any = undefined;
        let authMethod = "unknown";

        try {
          if (baseAuthOptions.callbacks?.signIn) {
            result = await baseAuthOptions.callbacks.signIn({
              user,
              account,
              profile,
              email,
              credentials,
            });
          }

          // Determine authentication method for more detailed logging
          if (account?.provider === "credentials") {
            authMethod = "password";
          } else if (account?.provider === "token") {
            authMethod = "email_verification";
          } else if (account?.provider && account.provider !== "credentials") {
            authMethod = "sso";
          }
        } catch (err) {
          error = err;
          result = false;

          logger.withContext({ eventId, err }).error("User sign-in failed");

          if (SENTRY_DSN && IS_PRODUCTION) {
            Sentry.captureException(err);
          }
        }

        const status: TAuditStatus = result === false ? "failure" : "success";
        const auditLog = {
          action: "signedIn" as const,
          targetType: "user" as const,

signIn is the only auth callback whose return value can reject the attempt — return false or a string redirect target and NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials. refuses the login. This is where the base module decides whether the email is verified, whether the user is in an allowed org, whether the SSO provider is permitted.

Our wrapper adds two things on top:

  1. Provider classification: we look at account.provider to label the attempt as password, email_verification, or sso. That label goes into the audit log so security reviews can answer “how did this user actually sign in?” in one query.
  2. Always-on audit: unlike jwt (only on trigger) and session (never), every single signIn attempt produces an audit event — success or failure. If the base callback throws, we still emit a failure audit with the error message and the eventId before rethrowing. This is the compliance baseline: no silent failures.

A .NET mental model: treat this like the OnSignedIn/OnValidatePrincipal events on the cookie authentication handler — but with a hard return type that gates the flow instead of just decorating it.

6. The HTTP export

hivecfm-core/apps/web/app/api/auth/[...nextauth]/route.tslines 137–141View full file ↗
          status,
          userType: "user" as const,
          newObject: {
            ...user,
            authMethod,

Finally, the handler is bound to the two HTTP methods NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials. needs: GET for provider redirects and session reads, POST for the actual credential exchange. Next.jsNext.jsReact framework used by HiveCFM Core. Handles routing, server rendering, and API routes in one bundle. App Router dispatches on those named exports; there is no separate routing config.

If you ever wonder “where does /api/auth/callback/credentials actually land?” — the answer is here, with NextAuthNextAuthThe auth library HiveCFM Core uses to handle sessions, OAuth providers, and credentials.’s internal routing then picking up the sub-path from the catch-all params.