Federated Authentication with Sitecore XP and Azure Entra ID

There are several posts out there about how to set up a Sitecore login process with Identity Server and Azure Entra ID (or, Azure AD). Recently I had to implement this for a customer and discovered that there was no one post that covered everything from end-to-end. So, we’ll do that here.

Here’s a quick diagram to demonstrate the login flow with Azure Entra ID and Sitecore:

Azure Entra ID and Sitecore XP diagram

There are a few pieces of this:

Azure Entra ID Application setup:
Group definitions
User group assignment
Redirect URI

Sitecore Identity Server configuration:
Entra ID app configuration
Group claim to Sitecore Role mapping

Sitecore CM Configuration:
Virtual user configuration
Custom user builder
User Claim to User Profile mapping

Entra ID App Setup

First, you need an Azure Entra ID app set up that will allow logins. That is outside of the scope of this post, we’re going to assume that the IT department of your org is handling that.

Ideally, each Sitecore environment requires a corresponding Entra ID App in Azure. This is primarily because of the Redirect URI parameter, which issues a redirect to Sitecore’s Identity Server after authentication, along with the claims in a JWT (JSON Web Token).

You’ll need the following information:
Client ID and Tenant ID: These GUIDs map to the specific instance of Azure Entra ID your app will connect to.
Redirect URI: Provide this to the Azure Entra ID app. This should be the URL of Identity Server, plus the sign-in endpoint. For example: https://identityserver.dev.local/signin-oidc

You’ll need to determine what groups in Azure will map to what roles in Sitecore, and note the GUIDs of these groups, as they’ll be sent over as claims for the user that you’ll need to map.

Identity Server Configuration

Next we’ll configure Sitecore Identity Server. The good news is that Sitecore 10.2 and later ships with an example configuration you can reference. But we’ll need to make some changes.

First, it’s very common for User Preferred Name to be sent across as a claim from Azure, as upn. Sitecore, be default, doesn’t accept this claim, so we need to configure it. To configure this. update the identityServer.xml file to add the upn claim as follows:

<IdentityResources>
  <SitecoreIdentityResource>
    <Name>sitecore.profile</Name>
    <UserClaims>
      <UserClaim0>upn</UserClaim0>
      <UserClaim1>name</UserClaim1>
      <UserClaim2>email</UserClaim2>
      <UserClaim3>role</UserClaim3>
      <UserClaim4>
        <http://www.sitecore.net/identity/claims/isAdmin</UserClaim4>
      <UserClaim5>
        <http://www.sitecore.net/identity/claims/originalIssuer
      </UserClaim5>
    </UserClaims>
    <Required>true</Required>
  </SitecoreIdentityResource>
</IdentityResources>

Next, you must enable the Azure AD (Azure Entra ID is a rebranding of Azure AD) configuration in Identity Server and add the app keys and group claim IDs. In this example, I’m mapping 3 groups to 3 different roles in Sitecore, Admin, Developer, and Author:

<?xml version="1.0" encoding="utf-8"?>
<Settings>
  <Sitecore>
    <ExternalIdentityProviders>
      <IdentityProviders>
        <AzureAd type="Sitecore.Plugin.IdentityProviders.IdentityProvider, Sitecore.Plugin.IdentityProviders">
          <AuthenticationScheme>IdS4-AzureAd</AuthenticationScheme>
          <DisplayName>Stewart SSO</DisplayName>
          <Enabled>true</Enabled>
          <ClientId>[Azure Entra ID App ClientId]</ClientId>
          <TenantId>[Azure Entra ID App TenantId]</TenantId>
          <MetadataAddress></MetadataAddress>
          <ClaimsTransformations>
            <!--Place transformation rules here. -->
            <ClaimsTransformation1 type="Sitecore.Plugin.IdentityProviders.DefaultClaimsTransformation, Sitecore.Plugin.IdentityProviders">
              <SourceClaims>
                <Claim1 type="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              </SourceClaims>
              <NewClaims>
                <Claim1 type="upn" />
              </NewClaims>
            </ClaimsTransformation1>
            <ClaimsTransformation2 type="Sitecore.Plugin.IdentityProviders.DefaultClaimsTransformation, Sitecore.Plugin.IdentityProviders">
              <SourceClaims>
                <Claim1 type="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" />
              </SourceClaims>
              <NewClaims>
                <Claim1 type="email" />
              </NewClaims>
            </ClaimsTransformation2>
            <!--Transformation samples-->
            <!--AzureAD user group to Sitecore role transformation.-->
            <AzureGroupTransformation1 type="Sitecore.Plugin.IdentityProviders.DefaultClaimsTransformation, Sitecore.Plugin.IdentityProviders">
              <SourceClaims>
                <Claim1 type="groups" value="[Author Group Claim ID]" />
              </SourceClaims>
              <NewClaims>
                <Claim1 type="role" value="sitecore\Author" />
              </NewClaims>
            </AzureGroupTransformation1>
			<AzureGroupTransformation2 type="Sitecore.Plugin.IdentityProviders.DefaultClaimsTransformation, Sitecore.Plugin.IdentityProviders">
              <SourceClaims>
                <Claim1 type="groups" value="[Developer Group Claim ID]" />
              </SourceClaims>
              <NewClaims>
                <Claim1 type="role" value="sitecore\Developer" />
              </NewClaims>
            </AzureGroupTransformation2>
            <!--AzureAD user in specific group to Sitecore admin user transformation-->
            <AzureADUserToAdminUser type="Sitecore.Plugin.IdentityProviders.DefaultClaimsTransformation, Sitecore.Plugin.IdentityProviders">
              <SourceClaims>
                <Claim1 type="groups" value="[Admin Group Claim ID]" />
              </SourceClaims>
              <NewClaims>
                <Claim1 type="http://www.sitecore.net/identity/claims/isAdmin" value="true"/>
              </NewClaims>
            </AzureADUserToAdminUser>
          </ClaimsTransformations>
        </AzureAd>
      </IdentityProviders>
    </ExternalIdentityProviders>
  </Sitecore>
</Settings>

Example path on local env: C:\inetpub\wwwroot\identityserver.dev.local\sitecore\Sitecore.Plugin.IdentityProvider.AzureAd\Config\Sitecore.Plugin.IdentityProvider.AzureAd.xml

Once these edits are complete, you must stop and start Identity Server for the changes to take effect.

Sitecore CM Configuration

Role mappings are sent over as claims from Identity Server. Virtual user creation and user profile claims mappings are handled by the Sitecore CMS app’s federated authentication pipelines. To accomplish this, a custom user builder was needed.

The custom user builder is necessary to map the upn claim to the virtual user’s username. Without this, Sitecore generates a random string of characters for the username, which is not useful for seeing what authors are doing in the CMS. Here is an example class, AzureEntraIdExternalUserBuilder.cs

namespace Thread.Foundation.Account.Services
{
	public class AzureEntraIdExternalUserBuilder : DefaultExternalUserBuilder
	{
		private readonly IHashEncryption _hashEncryption;
		public AzureEntraIdExternalUserBuilder(ApplicationUserFactory applicationUserFactory, IHashEncryption hashEncryption) 
			: base(applicationUserFactory, hashEncryption)
		{
			_hashEncryption = hashEncryption;
		}

		protected override string CreateUniqueUserName(UserManager<ApplicationUser> userManager, ExternalLoginInfo externalLoginInfo)
		{
			Assert.ArgumentNotNull(userManager, "userManager");
			Assert.ArgumentNotNull(externalLoginInfo, "externalLoginInfo");
			ClaimsIdentity externalIdentity = externalLoginInfo.ExternalIdentity;
			IdentityProvider identityProvider = base.FederatedAuthenticationConfiguration.GetIdentityProvider(externalIdentity);
			if (identityProvider == null)
			{
				throw new InvalidOperationException("Unable to retrieve an identity provider for the given identity");
			}

			string domain = identityProvider.Domain;
			string upn = externalIdentity.Claims.FirstOrDefault((Claim c) => c.Type == "upn")?.Value;

			if (string.IsNullOrWhiteSpace(upn))
			{
				// No UPN claim found, fallback to default behavior
				return base.CreateUniqueUserName(userManager, externalLoginInfo);
			}

			return $"{domain}\\{upn}";
		}
	}
}

To enable this custom user builder, you must update the federated user mapping configuration:

Foundation.Account.AzureEntraId.config

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
	<sitecore role:require="Standalone or ContentManagement">
		<federatedAuthentication>
			<identityProvidersPerSites>
				<mapEntry name="all sites" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication" resolve="true">
					<externalUserBuilder patch:instead="externalUserBuilder" type="MyProject.Foundation.Account.Services.AzureEntraIdExternalUserBuilder, MyProject.Foundation.Account" resolve="true">
						<IsPersistentUser>false</IsPersistentUser>
					</externalUserBuilder>
				</mapEntry>
			</identityProvidersPerSites>
			<propertyInitializer>
				<maps>
					<map name="set IsAdministrator" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
						<data hint="raw:AddData">
							<source name="http://www.sitecore.net/identity/claims/isAdmin" value="true" />
							<target name="IsAdministrator" value="true" />
						</data>
					</map>
					<map name="set Email" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
						<data hint="raw:AddData">
							<source name="email" />
							<target name="Email" />
						</data>
					</map>
					<map name="set Name" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
						<data hint="raw:AddData">
							<source name="name" />
							<target name="FullName" />
						</data>
					</map>
				</maps>
			</propertyInitializer>
		</federatedAuthentication>
	</sitecore>
</configuration>

Note we’re not mapping the upn claim to a profile property here. We’re only using that for the custom user builder to make the username in Sitecore match the username in Azure.

Also note the line IsPersistentUser. This setting determines if the user will be created as a virtual user or not. Toggling this to true will persist the user in the Core database, and is generally not considered best-practice.

Troubleshooting

When debugging this feature, you will likely need to inspect the claims sent to Sitecore. There are 2 steps here. The first set of claims is sent from Azure to Sitecore Identity Server. The second set of claims is sent from Sitecore Identity server to Sitecore CM.

Analyzing claims from Azure

To do this you need to capture the requests in your browser’s network tab. Look for a request to the Redirect URI defined in the Azure app. (E.g. On your local environment, this will be https://identityserver.dev.local/signin-oidc)

Look at the request’s Payload for the id_token. This is the JWT containing all the claims passed from Azure to Sitecore Identity Server. You can copy/paste this value into a tool that decodes JWT tokens, such as JWT.IO

Analyzing Claims from Sitecore Identity Server

Install and use my friend Robbert Hock’s “SI Snitch” module: GitHub – KayeeNL/SI-Snitch: A small utility for inspecting claims on a Sitecore instance that are coming from a Sitecore Identity Server.

This module will log all the claims sent to Sitecore in the log file, in the following format:

21060 10:33:33 INFO  oO SI Snitch Oo -- Claim: iss || Value: https://identityserver.dev.local
21060 10:33:33 INFO  oO SI Snitch Oo -- Claim: nbf || Value: ...
...
*claims data, omitted *
...
21060 10:33:33 INFO  oO SI Snitch Oo -- Claim: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name || Value: Chris Sulham
21060 10:33:33 INFO  oO SI Snitch Oo -- Claim: http://www.sitecore.net/identity/claims/cookieExp || Value: 1759503513
14916 10:33:35 INFO  AUDIT (sitecore\Anonymous): Login successful: sitecore\Chris.Sulham@velir.com.

Avoiding DLL issues in Sitecore XP projects

If you’ve ever worked on a Sitecore XP project, especially using the Helix patterns, you’ve probably experienced assembly version conflicts with your nuget package dependencies. Essentially, a package referenced in nuget is pulls in a dependency that overwrites an assembly that ships with Sitecore with a newer version, leading to an error.

Sometimes you just need to reference an assembly in your code, but not actually deploy it. At first glance there seems to be no way to do this in the nuget package manager. You can, however, do this directly in the .csproj file.

In this example, I needed to reference Microsoft.AspNet.Owin to work on a feature using federated authentication. When I published my project, it pulled in a bunch of other DLLs, the package’s transitive dependencies, that overwrote the ones Sitecore shipped with. In this example, the easiest solution is to tell MSBuild to not publish this package, since it’s already in the /bin folder. But, we still need to include this package for compilation.

The solution is to add <IncludeAssets>compile</IncludeAssets> to the .csproj in the package declaration. For example, with my Microsoft.AspNet.Owin package, the package declaration would look like this:

<PackageReference Include="Microsoft.AspNet.Identity.Owin">
  <Version>2.2.4</Version>
  <IncludeAssets>compile</IncludeAssets>
</PackageReference>

Now when I publish my project, the assembly and its dependencies (Newtonsoft.Json anyone?) do not publish and overwrite the stock assemblies. For mor info on how to configure package behavior, see this article: https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files

Throttling API endpoints in Sitecore XP with MvcThrottle

In a recent Sitecore XP project, we built a controller route that generates PDFs of the pages on demand. While this worked well functionally, we realized that without any rate limiting, users (or bots) could easily overload the server by repeatedly hitting the endpoint. To prevent performance issues by overloading the server, we needed a simple way to throttle requests to this route.

The MvcThrottle Module

After exploring a few options, we landed on the MvcThrottle module that allows you to limit the number of HTTP requests a client can make to your Web API or MVC endpoints over a specified time window.

MvcThrottle tracks incoming requests based on client IP, endpoint, or custom keys, and enforces rate limits using in-memory or distributed stores. You can configure limits per second, minute, hour, or day, and when a limit is exceeded return a 429 Too Many Requests response.

Implementation in Sitecore XP

Here’s how we integrated MvcThrottle into my Sitecore XP solution:

  1. Add the NuGet Package
    I added the MvcThrottle package to the Foundation layer of my solution, in this case the Foundation module handling Mvc customizations, using NuGet:
Install-Package MvcThrottle
  1. Register the MvcThrottle Module
    MvcThrottle is typically registered in Global.asax, but since this is a Sitecore solution, I registered it via the Sitecore initialize pipeline for modularity and to avoid global configurations
using System.Web.Mvc;
using Sitecore.Pipelines;
using MvcThrottle;

namespace MyProject.Foundation.Mvc.Pipelines.Initialize
{
  public class RegisterMvcThrottle
  {
    public void Process(PipelineArgs args)
    {
      var throttleFilter = new ThrottlingFilter
      {
        Policy = new ThrottlePolicy(
          // Required parameter, set to null if not needed
          perSecond: null,    
          perMinute: 5,
          perHour: 100)
        {
          IpThrottling = true
        },
        Repository = new CacheRepository()
      };

      GlobalFilters.Filters.Add(throttleFilter);
    }
  }
}
  1. Apply the MvcThrottle Attribute
    Next, I decorated the PDF generation controller action with the [EnableThrottling] attribute to enforce rate limits:
[HttpGet]
[EnableThrottling]
public ActionResult Pdf(string id)
{
  // Pdf generation logic...
}

In this case we’re using the defaults configured in the module registration. If desired, you can set custom throttling parameters on each action by passing parameters with the EnableThrottling attribute.

  1. Handle 429 Errors
    Finally, we need to handle the error thrown when a throttling limit is reached. This should map to a page in your site, like the 404 page, with a useful error. If you have a Foundation error handling module, you can handle it there. The simplest approach is to configure a custom error page for HTTP 429 responses in web.config:

<system.webServer>
  <httpErrors errorMode="Custom">
    <error statusCode="429" path="/error/ratelimit" responseMode="Redirect" />
  </httpErrors>
</system.webServer>

Summary

MvcThrottle provided a quick and effective way to protect my PDF generation route from abuse. It’s easy to integrate, works well with Sitecore, and offers enough flexibility for most throttling scenarios. In this example we’re throttling by IP. It’s possible to configure this differently by changing the caching strategy. For example, if your site requires authentication, you could throttle by user id instead.