Secure Your Sitecore XM Cloud App with NextAuth.js: Authentication & Authorization

Modern digital experiences demand secure, personalized, and seamless user access. In projects built with Sitecore XM Cloud and Next.js, combining NextAuth.js with Azure AD B2C offers a robust solution for authentication, while custom middleware provides fine-grained authorization. In this blog post, I’ll walk you through how we implemented both in a Sitecore XM Cloud project.


Authentication with Azure AD B2C and NextAuth.js

We’re using NextAuth.js for handling authentication via Azure AD B2C. This provider allows us to manage sign-in, profile editing, and password reset flows while integrating additional user metadata from Salesforce.

Key Features

  • Azure AD B2C handles user identity and federation.

  • NextAuth.js supports multiple user flows as separate providers.

  • JWT-based sessions allow for stateless authentication across SSR and API routes.

Here’s the core setup in /pages/api/auth/[...nextauth].ts:



interface AzureADB2CProfile {
  sub: string;
  given_name: string;
  family_name: string;
  email: string;
  country?: string;
  companyName?: string;
  postalCode?: string;
  phone_number?: string;
  roles?: string[];
  salesforceUserId?: string;

  contactId?: string;
  accountId?: string;
  auth_time?: number;
}

declare module 'next-auth' {
  interface Session extends DefaultSession {
    user?: {
      phoneNumber?: string;
      givenName?: string;
      familyName?: string;
      country?: string;
      companyName?: string;
      zipCode?: string;
      roles?: string[];
      salesforceUserId?: string;
      contactId?: string;
      accountId?: string;
      authTime?: Date | null;
    } & DefaultSession['user'];
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    phoneNumber?: string;
    accessToken?: string;
    roles?: string[];
    salesforceUserId?: string;
    contactId?: string;
    accountId?: string;
    auth_time?: number;
  }
}

export const authOptions: AuthOptions = {
  providers: [
    AzureADB2CProvider({
      clientId: process.env.AZURE_AD_B2C_CLIENT_ID!,
      clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!,
      tenantId: process.env.AZURE_AD_B2C_TENANT!,
      primaryUserFlow: process.env.AZURE_AD_B2C_USER_FLOW!,
      ...(process.env.AZURE_AD_B2C_ISSUER && {
        issuer: (() => {
          const issuerValue = process.env.AZURE_AD_B2C_ISSUER!;
          return `https://${issuerValue}/${issuerValue}/${process.env.AZURE_AD_B2C_USER_FLOW}/v2.0/`;
        })(),
      }),
      authorization: { params: { scope: 'openid profile offline_access' } },
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.given_name + ' ' + profile.family_name,
          email: profile.email,
        };
      },
    }),
    AzureADB2CProvider({
      id: 'azure-ad-b2c-edit-profile',
      clientId: process.env.AZURE_AD_B2C_CLIENT_ID!,
      clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!,
      tenantId: process.env.AZURE_AD_B2C_TENANT!,
      primaryUserFlow: 'B2C_1A_PROFILEEDIT',
      ...(process.env.AZURE_AD_B2C_ISSUER && {
        issuer: (() => {
          const issuerValue = process.env.AZURE_AD_B2C_ISSUER!;
          return `https://${issuerValue}/${issuerValue}/B2C_1A_PROFILEEDIT/v2.0/`;
        })(),
      }),
      authorization: { params: { scope: 'openid profile offline_access' } },
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.given_name + ' ' + profile.family_name,
          email: profile.email,
        };
      },
    }),
    AzureADB2CProvider({
      id: 'azure-ad-b2c-reset-password',
      clientId: process.env.AZURE_AD_B2C_CLIENT_ID!,
      clientSecret: process.env.AZURE_AD_B2C_CLIENT_SECRET!,
      tenantId: process.env.AZURE_AD_B2C_TENANT!,
      primaryUserFlow: 'B2C_1A_PASSWORDCHANGE',
      ...(process.env.AZURE_AD_B2C_ISSUER && {
        issuer: (() => {
          const issuerValue = process.env.AZURE_AD_B2C_ISSUER!;
          return `https://${issuerValue}/${issuerValue}/B2C_1A_PASSWORDCHANGE/v2.0/`;
        })(),
      }),
      authorization: { params: { scope: 'openid profile offline_access' } },
      profile(profile) {
        return {
          id: profile.sub,
          name: profile.given_name + ' ' + profile.family_name,
          email: profile.email,
        };
      },
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  session: { strategy: 'jwt' },
  cookies: process.env.NEXTAUTH_COOKIE_DOMAIN
    ? {
        sessionToken: {
          name: `__Secure-next-auth.session-token`,
          options: {
            httpOnly: true,
            sameSite: 'lax',
            path: '/',
            secure: true,
            domain: process.env.NEXTAUTH_COOKIE_DOMAIN,
          },
        },
      }
    : undefined,
  callbacks: {
    async signIn({ profile }) {
      if (profile) {
        const azureProfile = profile as AzureADB2CProfile;

        // Check if we have the required user data
        if (!azureProfile.salesforceUserId || !azureProfile.contactId) {
          console.error('Authentication failed: Required user data is missing', {
            salesforceUserId: azureProfile.salesforceUserId,
            contactId: azureProfile.contactId,
          });
          return '/login-error';
        }

        // Validate user if we have both salesforceUserId and contactId
        const userValidationService = UserValidationService.getInstance();
        const validation = await userValidationService.validateUser(
          azureProfile.salesforceUserId,
          azureProfile.contactId
        );

        if (!validation.isValid || !validation.accountId) {
          return '/login-error';
        }

        // Store the validated accountId in the profile
        azureProfile.accountId = validation.accountId;
      }
      return true; // Allow sign-in
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.phoneNumber = token.phoneNumber as string;
        session.user.givenName = token.givenName as string;
        session.user.familyName = token.familyName as string;
        session.user.country = token.country as string;
        session.user.companyName = token.companyName as string;
        session.user.zipCode = token.postalCode as string;
        session.user.roles = token.roles as string[];
        session.user.salesforceUserId = token.salesforceUserId as string;
        session.user.contactId = token.contactId as string;
        session.user.accountId = token.accountId as string;
        session.user.authTime = token.auth_time ? new Date(token.auth_time * 1000) : null;
      }
      return session;
    },
    async jwt({ token, account, profile }) {
      if (profile) {
        const azureProfile = profile as AzureADB2CProfile;
        token.phoneNumber = azureProfile.phone_number;
        token.givenName = azureProfile.given_name;
        token.familyName = azureProfile.family_name;
        token.country = azureProfile.country;
        token.companyName = azureProfile.companyName;
        token.postalCode = azureProfile.postalCode;
        token.roles = azureProfile.roles;
        token.salesforceUserId = azureProfile.salesforceUserId;
        token.contactId = azureProfile.contactId;
        token.accountId = azureProfile.accountId;
        token.auth_time = azureProfile.auth_time;
      }
      if (account) {
        token.accessToken = account.access_token;
      }
      return token;
    },
  },
};

export default NextAuth(authOptions);


We are enriching the session with custom user claims like:

  • salesforceUserId

  • contactId

  • accountId

  • roles

This happens through the jwt and session callbacks, where we map Azure AD B2C claims to our application-specific session structure.

User Validation

Before allowing sign-in, we validate the Salesforce IDs using a custom service. If validation fails, the user is redirected to a custom error page (/login-error). This adds an extra layer of business rule enforcement at login.


Authorization Middleware:

Once the user is authenticated, we use middleware to control access to protected pages under a specific path. This is required because we need to be mindful of the performance impact checking security might have on each request. 

How It Works:

  1. We fetch metadata for the requested page via Sitecore’s PageService.

  2. If roles are defined in Sitecore, we check whether the user has any of the required roles stored in the session.

  3. If the user is:

    • Unauthenticated → redirect to /not-authenticated.

    • Authenticated but unauthorized → redirect to /not-authorized.

Here’s an overview of the logic:


class AuthorizationPlugin implements MiddlewarePlugin {
  order = 3;

  notAuthorizedPage = '/not-authorized';
  notAuthenticatedPage = '/not-authenticated';
  private hasPrerenderBypass(req: NextRequest): boolean {
    const bypassCookie = req.cookies.get('__prerender_bypass');
    return bypassCookie?.value != null && bypassCookie.value !== '';
  }
  async exec(req: NextRequest, res: NextResponse): Promise<NextResponse> {
    res = res || NextResponse.next();

   
    const customPathRegex = /^\/([a-z]{2}(-[a-z]{2})?\/)?yourpathgoeshere\//i;
    if (!customPathRegex .test(req.nextUrl.pathname)) {
      return res;
    }

    const hasPrerenderBypass = this.hasPrerenderBypass(req);

    if (hasPrerenderBypass) {
      return res;
    }

    const hostName = req.headers.get('host')?.split(':')[0] || 'localhost';
    const site = siteResolver.getByHost(hostName);

    const currentPage = (await GetPageItem(
      req.nextUrl.pathname,
      site.language,
      site.name
    )) as SecurityPageItem;

    const pageRoles =
      currentPage?.securityRole?.jsonValue?.map((role) =>
        role?.fields?.value?.value.toLowerCase()
      ) ?? [];
    const isProtected = pageRoles.length > 0;

    // If page is not protected, allow access
    if (!isProtected) {
      return res;
    }

    const session = await getToken({ req, secret: process.env.AUTH_SECRET });
    const isUserAuthenticated = session != null;

    // If user is not authenticated but page requires any auth, redirect to login
    if (!isUserAuthenticated) {
      const redirectUrlParam = req.url.includes(this.notAuthorizedPage)
        ? '?' + req.url.split('?')
        : '?redirectUrl=' + req.url;
      return NextResponse.redirect(new URL(this.notAuthenticatedPage + redirectUrlParam, req.url));
    }

    // If page allows all authenticated users and user is authenticated, allow access
    if (pageRoles.includes('authenticated') && isUserAuthenticated) {
      return res;
    }

    // Check if user has required role
    const sessionRoles = session?.roles as string[] | undefined;
    const hasAccess = sessionRoles?.some((role: string) => pageRoles.includes(role.toLowerCase()));

    if (!hasAccess) {
      return NextResponse.redirect(new URL(this.notAuthorizedPage, req.url));
    }

    return res;
  }
}

// Initialize cache client with 5 minute TTL
const pageCache = new MemoryCacheClient<SecurityPageItem>({
  cacheTimeout: 300, // 5 minutes in seconds
  cacheEnabled: true,
});

const GetPageItem = async (datasource: string, language: string, site: string) => {
  const cacheKey = `${datasource}-${language}-${site}`;
  const cachedItem = pageCache.getCacheValue(cacheKey);

  if (cachedItem) {
    return cachedItem;
  }

  try {
    const securityService = new PageService();
    const pageItem = await securityService.getSecurityPageItem(datasource, language, site);

    if (pageItem) {
      pageCache.setCacheValue(cacheKey, pageItem);
    }

    return pageItem;
  } catch (error) {
    console.error('Error fetching page item:', error);
    return null;
  }
};

export const authorizationPlugin = new AuthorizationPlugin();


Pages with Role-Based Security in Sitecore

In Sitecore XM Cloud, editors define which roles can access a page using a custom securityRole field. This integrates beautifully with our middleware, giving content authors the power to gate content without requiring developer intervention.


Best of Both Worlds

By combining Azure AD B2C + NextAuth.js for identity and custom middleware + Sitecore content structure for access control, we get:

  • Enterprise-grade authentication with minimal complexity

  • Fully decoupled authorization from code to content

  • Maintainable and scalable security across multi-tenant environments

Comments