Secured Pages SXA Module for Next.js - Authentication Middleware Implementation

When building headless Sitecore implementations with Next.js, securing specific pages or sections of your website is a common requirement. Whether it's user profiles, admin areas, or premium content, you need a robust way to verify authentication and redirect unauthorized users to a login page—without writing custom server-side code for each route.

This post walks through implementing a Secured Pages SXA Module using Next.js middleware architecture, similar to Sitecore's out-of-the-box redirect module. The solution provides a flexible, configuration-driven approach to protect pages based on path patterns stored in Sitecore.


The Challenge

In headless Sitecore implementations, traditional server-side authentication checks aren't always feasible. You need:

  • Flexible path matching: Support for both exact paths (/Profile) and regex patterns (/securedTree*)
  • Sitecore-driven configuration: Content authors should be able to configure secured paths without code deployments
  • Middleware-based protection: Leverage Next.js middleware for efficient request interception
  • GraphQL integration: Fetch security mappings from Sitecore's GraphQL endpoint
  • Performance: Cache security mappings to avoid repeated API calls

Solution Overview

The Secured Pages SXA Module consists of three main components:

  1. SecuredPagesMiddleware: Next.js middleware that intercepts requests and checks authentication
  2. GraphQLSecuredPagesService: Service that fetches security mappings from Sitecore via GraphQL
  3. Sitecore Content Structure: Templates and items that store the security configuration

Architecture Flow

Request → Middleware → Check Authentication Token
                          ↓
                    Is Authorized?
                    /           \
                  Yes            No
                   ↓              ↓
            Allow Request    Check Sitecore Mappings
                                  ↓
                          Path Matches Secured Pattern?
                          /                    \
                        Yes                     No
                         ↓                       ↓
                  Redirect to Login        Allow Request

Sitecore Setup

Step 1: Create the Secured Pages Template

First, create a template in Sitecore to store security mappings:

  1. Navigate to /sitecore/templates/Project/YourProject/Security

  2. Create a new template named Secured Pages

  3. Add the following fields:

    • UrlMapping (Multi-Line Text)
      • Description: "Enter relative paths or regex patterns (one per line). Examples: /Profile, /securedTree*"
    • LoginRedirectUrl (Single-Line Text)
      • Description: "Relative URL to redirect unauthorized users (e.g., /login)"
  4. Set the template ID and note it for use in your constants file:

// lib/constants.ts
export const securedPagesTemplateId = '{YOUR-TEMPLATE-ID-HERE}';

Step 2: Create Security Mappings Container

  1. Navigate to your site's root item (e.g., /sitecore/content/YourSite)
  2. Create a folder named Settings (if it doesn't exist)
  3. Under Settings, create a folder named Security Mappings
  4. Set insert options on Security Mappings to allow Secured Pages template

Step 3: Create Security Mapping Items

For each set of secured paths:

  1. Navigate to Settings/Security Mappings
  2. InsertSecured Pages
  3. Configure:
    • UrlMapping: Enter paths (one per line):
      /Profile
      /Account/*
      /Admin/.*
      
    • LoginRedirectUrl: /login

Code Implementation

Step 1: Create Constants File

// lib/constants.ts
export const securedPagesTemplateId = '{YOUR-TEMPLATE-ID-HERE}';

Step 2: GraphQL Service Implementation

The GraphQLSecuredPagesService fetches security mappings from Sitecore:

// lib/middleware/graphql-auth-service.ts
import {
  GraphQLRequestClient,
  GraphQLRequestClientFactory,
} from '@sitecore-jss/sitecore-jss-nextjs/graphql';
import { securedPagesTemplateId } from '../constants';

export const GetItemUrl = /* GraphQL */ `
  query GetItemIdByPath($path: String, $lang: String = "en") {
    item(path: $path, language: $lang) {
      id
    }
  }
`;

const SiteRootQuery = /* GraphQL */ `
  query SiteRootQuery($siteName: String!) {
    site {
      siteInfo(site: $siteName) {
        rootPath
      }
    }
  }
`;

const defaultQuery = /* GraphQL */ `
  query SearchSecuredPages($pathId: String!, $templateId: String!) {
    search(
      where: {
        AND: [
          { name: "_path", value: $pathId, operator: CONTAINS }
          { name: "_templates", value: $templateId, operator: CONTAINS }
        ]
      }
      first: 1
    ) {
      total
      pageInfo {
        endCursor
        hasNext
      }
      results {
        UrlMapping: field(name: "UrlMapping") {
          value
        }
        LoginRedirectUrl: field(name: "LoginRedirectUrl") {
          value
        }
      }
    }
  }
`;

export type GraphQLSecuredPagesServiceConfig = {
  endpoint?: string;
  apiKey?: string;
  fetch?: typeof fetch;
  clientFactory?: GraphQLRequestClientFactory;
  cacheEnabled?: boolean;
  cacheTimeout?: number;
};

export type SecuredPagesQueryResult = {
  search: {
    total: number;
    results: resultEntry[] | null;
  };
};

export type resultEntry = {
  UrlMapping: resultField;
  LoginRedirectUrl: resultField;
};

export type resultField = {
  value: string;
};

type SiteRootQueryResult = {
  site: { siteInfo: { rootPath: string } | null };
};

type TItem = {
  id: string;
};

type TGetItemId = {
  item: TItem;
};

export class GraphQLSecuredPagesService {
  private graphQLClient: GraphQLRequestClient;
  private cache: Map<string, { value: SecuredPagesQueryResult; expiresAt: number }> = new Map();

  protected get siteRootQuery(): string {
    return SiteRootQuery;
  }

  protected get query(): string {
    return defaultQuery;
  }

  constructor(private options: GraphQLSecuredPagesServiceConfig) {
    this.graphQLClient = this.getGraphQLClient();
  }

  async fetchSecuredPages({ siteName }: { siteName: string }): Promise<SecuredPagesQueryResult> {
    if (!siteName) {
      throw new Error('The siteName cannot be empty');
    }

    // Check cache first
    const cacheKey = `auth-${siteName}`;
    const cached = this.cache.get(cacheKey);
    
    if (cached && Date.now() < cached.expiresAt) {
      return cached.value;
    }

    // Fetch from Sitecore
    const siteRoot = await this.graphQLClient.request<SiteRootQueryResult>(this.siteRootQuery, {
      siteName,
    });
    
    if (!siteRoot?.site?.siteInfo?.rootPath) {
      return { search: { total: 0, results: [] } };
    }

    const settingItemPath = siteRoot.site.siteInfo.rootPath + '/Settings';
    const settingItemData = await this.graphQLClient.request<TGetItemId>(GetItemUrl, {
      path: settingItemPath,
    });
    
    if (!settingItemData?.item?.id) {
      return { search: { total: 0, results: [] } };
    }

    const pathId = settingItemData.item.id;
    const securedPages = await this.graphQLClient.request<SecuredPagesQueryResult>(
      this.query,
      {
        pathId,
        templateId: securedPagesTemplateId,
      }
    );
    
    // Cache the result
    if (securedPages) {
      const cacheTimeout = (this.options.cacheTimeout || 10) * 1000;
      this.cache.set(cacheKey, {
        value: securedPages,
        expiresAt: Date.now() + cacheTimeout,
      });
      return securedPages;
    }
    
    return { search: { total: 0, results: [] } };
  }

  protected getGraphQLClient(): GraphQLRequestClient {
    if (!this.options.endpoint) {
      if (!this.options.clientFactory) {
        throw new Error('You should provide either an endpoint and apiKey, or a clientFactory.');
      }

      return this.options.clientFactory({
        fetch: this.options.fetch,
      });
    }

    return new GraphQLRequestClient(this.options.endpoint, {
      apiKey: this.options.apiKey,
      fetch: this.options.fetch,
    });
  }
}

Key Features:

  • Caching: Uses in-memory cache to avoid repeated GraphQL calls (default: 10 seconds)
  • Path Resolution: Dynamically resolves the site's Settings folder path
  • Template Filtering: Searches for items using the Secured Pages template ID

Note: The caching implementation uses a simple in-memory Map. For production environments with multiple instances, consider implementing a distributed cache solution.

Step 3: Middleware Implementation

The middleware intercepts requests and enforces authentication:

// lib/middleware/secured-pages-middleware.ts
import { SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs';
import { MiddlewareBase, MiddlewareBaseConfig } from '@sitecore-jss/sitecore-jss-nextjs/middleware';
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
import regexParser from 'regex-parser';
import {
  GraphQLSecuredPagesService,
  GraphQLSecuredPagesServiceConfig,
  resultEntry,
} from './graphql-auth-service';

export type SecuredPagesMiddlewareConfig = Omit<GraphQLSecuredPagesServiceConfig, 'fetch'> &
  MiddlewareBaseConfig & {
    protectedRoutes?: string[];
    defaultLoginPage?: string;
    locales: string[];
  };

export class SecuredPagesMiddleware extends MiddlewareBase {
  private securedPagesService: GraphQLSecuredPagesService;

  constructor(protected config: SecuredPagesMiddlewareConfig) {
    super(config);
    this.securedPagesService = new GraphQLSecuredPagesService({ ...config, fetch: fetch });
  }

  public getHandler(): (req: NextRequest, res?: NextResponse) => Promise<NextResponse> {
    return async (req, res) => {
      return await this.handler(req, res);
    };
  }

  private handler = async (req: NextRequest, res?: NextResponse): Promise<NextResponse> => {
    // Skip if disabled or in preview mode
    if (
      (this.config.disabled && this.config.disabled(req, res || NextResponse.next())) ||
      this.isPreview(req) ||
      this.excludeRoute(req.nextUrl.pathname)
    ) {
      return res || NextResponse.next();
    }

    const site = this.getSite(req, res);
    const isSecured = this.config.protectedRoutes?.includes(req.nextUrl.pathname);
    const existsSecuredPageMapping = await this.getExistsSecuredPages(req, site.name);

    // Allow if not secured
    if (!isSecured && !existsSecuredPageMapping) {
      return res || NextResponse.next();
    }

    // Check authorization
    if (await this.isAuthorized(req)) {
      return res || NextResponse.next();
    }

    // Redirect to login
    const url = req.nextUrl.clone();
    url.pathname =
      existsSecuredPageMapping?.LoginRedirectUrl?.value || this.config.defaultLoginPage || '/';
    url.locale = req.nextUrl.locale;

    return NextResponse.redirect(decodeURIComponent(url.href));
  };

  private async isAuthorized(req: NextRequest): Promise<boolean> {
    const secret = process.env.NEXTAUTH_JWT_SECRET;
    const token = await getToken({ req, secret });
    return !!token;
  }

  private async getExistsSecuredPages(
    req: NextRequest,
    siteName: string
  ): Promise<resultEntry | undefined> {
    const securedPages = await this.securedPagesService.fetchSecuredPages({ siteName });

    if (!securedPages?.search?.results || securedPages.search.total === 0) {
      return undefined;
    }

    const pathname = req.nextUrl.pathname.toLowerCase();
    const pathnameWithLocale = `/${req.nextUrl.locale}${req.nextUrl.pathname}`.toLowerCase();

    // Check each secured page mapping
    for (const result of securedPages.search.results) {
      const urlMappings = result?.UrlMapping?.value;
      if (!urlMappings) continue;

      const paths = urlMappings.split(/\r?\n/);
      
      for (const urlMapping of paths) {
        if (!urlMapping.trim()) continue;

        const pattern = regexParser(urlMapping.toLowerCase());
        const matches = pattern.test(pathname) || pattern.test(pathnameWithLocale);

        if (matches && result.LoginRedirectUrl) {
          return result;
        }
      }
    }

    return undefined;
  }
}

Key Features:

  • Token Verification: Uses next-auth/jwt to verify authentication tokens
  • Path Matching: Supports both exact paths and regex patterns
  • Locale Awareness: Matches paths with and without locale prefixes
  • Preview Mode: Skips authentication checks in Sitecore preview mode
  • Configurable Routes: Supports both Sitecore-driven and code-based protected routes

Configuration

Environment Variables

Add to your .env file:

# Enable/disable the secured pages module
Enable_Auth_Secured_Pages=true

# NextAuth JWT secret (required for token verification)
NEXTAUTH_JWT_SECRET=your-secret-key-here

Middleware Integration

Integrate the middleware in your Next.js middleware file:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { createGraphQLClientFactory } from '@sitecore-jss/sitecore-jss-nextjs';
import { SecuredPagesMiddleware } from './lib/middleware/secured-pages-middleware';

const securedPagesMiddleware = new SecuredPagesMiddleware({
  // GraphQL configuration
  clientFactory: createGraphQLClientFactory({
    endpoint: process.env.GRAPH_QL_ENDPOINT!,
    apiKey: process.env.SITECORE_API_KEY!,
  }),

  // Middleware configuration
  defaultHostname: process.env.SITECORE_DEFAULT_HOSTNAME || 'localhost',
  locales: ['en', 'es', 'fr'], // Add your locales
  defaultLoginPage: '/login',
  
  // Optional: Code-based protected routes (in addition to Sitecore config)
  protectedRoutes: ['/admin', '/dashboard'],
  
  // Cache configuration
  cacheEnabled: true,
  cacheTimeout: 10, // seconds

  // Exclude routes from authentication checks
  excludeRoute: (pathname: string) => {
    return pathname.startsWith('/api') || pathname.startsWith('/_next');
  },

  // Disable middleware conditionally
  disabled: (req: NextRequest) => {
    return process.env.Enable_Auth_Secured_Pages !== 'true';
  },
});

export async function middleware(req: NextRequest) {
  // Run other middlewares first (e.g., Sitecore JSS middleware)
  // Then run secured pages middleware
  return securedPagesMiddleware.getHandler()(req);
}

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

Step-by-Step Setup Guide

1. Install Dependencies

npm install regex-parser next-auth

2. Create File Structure

lib/
  ├── constants.ts
  └── middleware/
      ├── secured-pages-middleware.ts
      └── graphql-auth-service.ts

3. Configure Sitecore

  1. Create the Secured Pages template with UrlMapping and LoginRedirectUrl fields
  2. Create Settings/Security Mappings folder under your site root
  3. Create security mapping items as needed

4. Update Environment Variables

Add Enable_Auth_Secured_Pages and NEXTAUTH_JWT_SECRET to your .env file.

5. Integrate Middleware

Add the middleware configuration to middleware.ts as shown above.

6. Test the Implementation

  1. Test Unauthorized Access: Visit a secured path without authentication → should redirect to login
  2. Test Authorized Access: Login and visit secured path → should allow access
  3. Test Regex Patterns: Verify that patterns like /securedTree* match correctly

Advanced Configuration

Multiple Security Mappings

You can create multiple Secured Pages items under Security Mappings, each with different path patterns and redirect URLs. The middleware will check all mappings and use the first match.

Combining Code-Based and Sitecore-Based Routes

The middleware supports both approaches:

  • Code-based: Define protectedRoutes in middleware config
  • Sitecore-based: Configure paths in Sitecore items

Both are checked, and either can trigger authentication.

Custom Authentication Logic

To use a different authentication provider, modify the isAuthorized method:

private async isAuthorized(req: NextRequest): Promise<boolean> {
  // Example: Check custom session cookie
  const sessionCookie = req.cookies.get('session');
  return !!sessionCookie?.value;
  
  // Or: Check custom header
  const authHeader = req.headers.get('Authorization');
  return authHeader?.startsWith('Bearer ') ?? false;
}

Performance Considerations

  • Caching: Security mappings are cached for 10 seconds by default. Adjust cacheTimeout based on your needs.
  • GraphQL Efficiency: The service uses a single GraphQL query to fetch all mappings for a site.
  • Middleware Overhead: Middleware runs on every request. Use excludeRoute to skip static assets and API routes.

Troubleshooting

Mappings Not Found

  • Verify the template ID in constants.ts matches your Sitecore template
  • Check that items exist under Settings/Security Mappings
  • Ensure the UrlMapping field is populated

Redirects Not Working

  • Verify LoginRedirectUrl is set in Sitecore items
  • Check that defaultLoginPage is configured in middleware
  • Ensure the login page route exists

Authentication Not Detected

  • Verify NEXTAUTH_JWT_SECRET is set correctly
  • Check that your authentication provider sets tokens correctly

Final Thoughts

The Secured Pages SXA Module provides a flexible, maintainable solution for protecting pages in headless Sitecore implementations. By leveraging Next.js middleware and Sitecore's content management capabilities, you can:

  • Empower content authors to configure security without code changes
  • Maintain performance through intelligent caching
  • Support complex scenarios with regex pattern matching
  • Integrate seamlessly with existing authentication providers

This approach follows Sitecore best practices by keeping configuration in the CMS while leveraging Next.js's powerful middleware capabilities for request handling.

Ready to secure your headless site?
👉 Follow the step-by-step guide above to implement the module in your Next.js Sitecore project.

Related Posts

Exposing Site Settings from the Settings item in a Headless JSS Next.js Application

In this blog post, we will explore a common requirement in Sitecore Headless SXA, exposing site-specific settings and global data to the front end when working with a JSS headless site. ## Problem S

Read More

Leveraging Sitecore Search SDK Starter Kit into Your Sitecore Next.js Solution [Part 2]

In this blog post, I'll walk you through the process of smoothly leveraging the Search SDK Starter Kit into a Sitecore Next.js solution. ## Prerequisites Before proceeding, ensure the following prer

Read More

Sitecore Search SDK Integration [Part 1]

In today's web development landscape, delivering efficient and intuitive search functionality is crucial to provide an enhanced user experience for your visitors. With the advent of Sitecore Search,

Read More