Optimizely CMS Mixed Auth - Okta + ASP.NET Identity
Configuring mixed authentication and authorization in Optimizely CMS using Okta and ASP.NET Identity.

Securing an Optimizely CMS has always been a key consideration in nearly every project. While the built-in ASP.NET Identity provider is available, many organizations prefer cloud-based identity solutions that can centrally manage users and roles.
In some cases, these two approaches need to work together — combining local ASP.NET Identity with an external provider in a coordinated way. The good news is that, within the .NET world, such workflows can be configured quite easily. However, this flexibility can also lead to unnecessary complexity if not handled carefully.
Below is a step-by-step configuration guide for one way to integrate authentication (and handle authorization for roles) in Optimizely CMS.
The flow
In this scenario, frontend users are authenticated via Okta, and they access the site using JWT tokens, as the frontend is headless. CMS users also authenticate through Okta using the MVC approach. Additionally, fallback login via ASP.NET Identity remains available for CMS access.
Defining the authentication scheme names used in the application at the outset is essential. In this specific case, the following were required.
internal static class AuthScheme
{
internal const string Cms = "CMS-Auth";
internal const string Api = "API-Auth";
internal const string OktaMvc = "Okta-MVC";
internal const string OktaJwt = "Okta-JWT";
internal const string ContentApi = OpenIDConnectOptionsDefaults.AuthenticationScheme;
internal static readonly string Identity = IdentityConstants.ApplicationScheme;
}Two main authentication schemas are defined at a high level:
CMS: for user access in CMS edit mode, serves as the default schema
API: for auth of API endpoint requests
Okta authentication uses two schemas:
Okta MVC: linked to the CMS schema
Okta JWT: linked to the API schema
Additionally, there are:
Content API: supports headless architecture by enabling CMS as an OpenIddict server for content previews
Identity: represents the default ASP.NET database identity
This schema structure improves the organization and simplifies managing authentication policies across the application.
services
.AddPolicyScheme(AuthScheme.Cms, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.ContainsIdentityAuthCookie()
? AuthScheme.Identity
: AuthScheme.OktaMvc;
})
.AddPolicyScheme(AuthScheme.Api, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.IsSwaggerContext()
? AuthScheme.Cms
: AuthScheme.OktaJwt;
});The AddPolicyScheme method allows conditional logic to determine which auth schema to use. For the default CMS schema, the decision depends on whether the user has an Identity cookie. If present, the site allows fallback to ASP.NET Identity authentication; otherwise, an Okta challenge is triggered.
private static bool ContainsIdentityAuthCookie(this HttpContext context)
{
var config = context.RequestServices.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
var identityCookieName = config.Get(AuthScheme.Identity).Cookie.Name;
if (string.IsNullOrWhiteSpace(identityCookieName))
{
return false;
}
return context.Request.Cookies.ContainsKey(identityCookieName);
}The API schema primarily uses Okta JWT tokens and controls authorization for API endpoints using the [Authorize(AuthScheme.API)] attribute. To simplify developer testing with Swagger, a conditional policy detects this scenario and falls back to the CMS schema. This enables API access through a valid browser session.
Configuration
The above outlines how authentication should function in different cases. What remains is the detailed configuration for each auth schema.
Starting with the basics, the first is ASP.NET Identity, which requires a simple, one-line configuration.
services.AddCmsAspNetIdentity<ApplicationUser>();Additionally, for documentation purposes, the Content API auth flow configuration is included, though it will not be described in detail. This is necessary for the headless architecture to function, but it is not involved in the core business logic of the application.
/// <summary>
/// Secures the Content API (meaning Content Delivery and Content Management APIs),
/// using an OpenIddict server and client credentials flow.
/// </summary>
internal static IServiceCollection AddContentApiAuth(this IServiceCollection services, ContentApiAuthOptions? options, bool isDevelopment)
{
services.AddOpenIDConnect<ApplicationUser>(
useDevelopmentCertificate: true,
signingCertificate: null,
encryptionCertificate: null,
createSchema: true,
o =>
{
o.RequireHttps = !isDevelopment;
// OIDC application for the content management API
// uses the client credentials flow
o.Applications.Add(new OpenIDConnectApplication
{
ClientId = options?.ClientId,
ClientSecret = options?.ClientSecret,
Scopes = { "openid", "offline_access", "profile", "email", "roles", ContentManagementApiOptionsDefaults.Scope },
});
}
);
services.AddOpenIDConnectUI();
return services;
}
/// <summary>
/// Secures the Content API (meaning Content Delivery and Content Management APIs),
/// using an OpenIddict server and client credentials flow.
/// </summary>
internal static IServiceCollection AddContentApiAuth(this IServiceCollection services, ContentApiAuthOptions? options, bool isDevelopment)
{
services.AddOpenIDConnect<ApplicationUser>(
useDevelopmentCertificate: true,
signingCertificate: null,
encryptionCertificate: null,
createSchema: true,
o =>
{
o.RequireHttps = !isDevelopment;
// OIDC application for the content management API
// uses the client credentials flow
o.Applications.Add(new OpenIDConnectApplication
{
ClientId = options?.ClientId,
ClientSecret = options?.ClientSecret,
Scopes = { "openid", "offline_access", "profile", "email", "roles", ContentManagementApiOptionsDefaults.Scope },
});
}
);
services.AddOpenIDConnectUI();
return services;
}Okta MVC
Finally, the focus shifts to configuring Okta authentication, beginning with the Okta MVC setup.
internal static AuthenticationBuilder AddOktaAuth(this AuthenticationBuilder authenticationBuilder, OktaAuthOptions? options)
{
ArgumentNullException.ThrowIfNull(options);
authenticationBuilder
.AddOktaMvc(AuthScheme.OktaMvc, new OktaMvcOptions
{
OktaDomain = options.Domain,
AuthorizationServerId = OktaWebOptions.DefaultAuthorizationServerId,
ClientId = options.ClientId,
ClientSecret = options.ClientSecret,
CallbackPath = options.CallbackPath,
PostLogoutRedirectUri = options.SignOutRedirectUrl,
Scope = new List<string> { "openid", "profile", "email" },
GetClaimsFromUserInfoEndpoint = true,
OpenIdConnectEvents = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = context => {
// Prevent caching
context.Response.Headers.CacheControl = "no-store, no-transform, no-cache";
context.Response.Headers["CDN-Cache-Control"] = "no-store, no-transform, no-cache";
// Prevent redirect loop
if (context.Response.StatusCode == StatusCodes.Status401Unauthorized) {
context.HandleResponse();
}
//Do not redirect in API context
if (context.Request.Path.StartsWithSegments($"/{ApiControllerBase.ApiPrefix}"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.HandleResponse();
}
return Task.CompletedTask;
},
OnAuthenticationFailed = async context => {
context.HandleResponse();
var messageBytes = Encoding.ASCII.GetBytes(context.Exception.Message);
await context.Response.BodyWriter.WriteAsync(messageBytes);
},
OnTokenValidated = context =>
{
EnsureInternalRedirection(context);
return Task.CompletedTask;
},
OnTicketReceived = async context =>
{
OktaClaimsTransformer.Transform(context.Principal);
await SynchronizeRoles(context.HttpContext, context.Principal);
}
}
})
.AddApiAuth(options);
return authenticationBuilder;
}The Okta.AspNetCore NuGet package is used here, providing the AddOktaMvc extension method to configure authorization server details along with the required scopes, which at minimum include openid, profile, and email. Additionally, the getClaimsForUserInfoEndpoint flag ensures that the userInfo endpoint is called to retrieve all available claims.
Notably, several authentication events handle specific modifications and edge cases. The OnRedirectToIdentityProvider event prevents caching and redirect loops and ensures that a 401 status code is returned instead of redirecting to the login page in the Swagger context.
Other event handlers manage errors by returning relevant messages and avoiding internal redirections when authentication succeeds.
/// <summary>
/// Ensures the redirect URL remains within the application by stripping the domain from the absolute URL,
/// preventing potential open redirect vulnerabilities.
/// </summary>
private static void EnsureInternalRedirection(TokenValidatedContext context)
{
if (string.IsNullOrEmpty(context.Properties?.RedirectUri))
{
return;
}
try
{
var redirectUri = new Uri(context.Properties.RedirectUri, UriKind.RelativeOrAbsolute);
if (redirectUri.IsAbsoluteUri)
{
context.Properties.RedirectUri = redirectUri.PathAndQuery;
}
}
catch (UriFormatException)
{
context.Properties.RedirectUri = "/";
}
}The final event triggers once the token is fully received. It is used to modify the claims coming from Okta based on specific business requirements. More importantly, this step synchronizes the necessary roles with Optimizely CMS, which is crucial so that users authenticated via Okta have appropriate access rights to the CMS interface.
private static async Task SynchronizeRoles(HttpContext httpContext, ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity claimsIdentity)
{
return;
}
var optiClaims = new List<Claim>
{
new(ClaimTypes.Role, "WebAdmins", ClaimValueTypes.String), //TODO get roles from Okta
};
claimsIdentity.AddClaims(optiClaims);
var syncService = httpContext
.RequestServices
.GetRequiredService<ISynchronizingUserService>();
await userSyncService.SynchronizeAsync(claimsIdentity);
}Ideally, all roles would be provided directly from Okta. However, this is not always feasible. In such cases, a custom solution can be introduced here. The userSyncService is an instance of ISynchronizingUserService from EPiServer.Security namespace. Its core responsibility is to add new records to the tblSynchedUser, tblSyncedUserRelations, and tblSynchedUserRole tables in the database, which map users to specific Optimizely roles.
There is a small additional configuration required for Okta MVC that is not handled within the AddOktaMvc method. This configuration must be performed during the post-configuration step after services have been registered.
internal static IServiceCollection ConfigureOktaTokenValidation(this IServiceCollection services, OktaAuthOptions? options)
{
ArgumentNullException.ThrowIfNull(options);
return services.PostConfigureAll<OpenIdConnectOptions>(o =>
{
var validationParameters = GetTokenValidationParameters(AuthScheme.OktaMvc);
validationParameters.ValidIssuer = options.Domain;
validationParameters.ValidAudience = options.ClientId;
o.TokenValidationParameters = validationParameters;
});
}It defines how the token should be validated by setting essential parameters. It also configures a tolerance window (ClockSkew) that allows the token to be considered valid for a short period despite minor time discrepancies across multiple servers.
private static TokenValidationParameters GetTokenValidationParameters(string authenticationType) =>
new()
{
RoleClaimType = SiteClaimTypes.Role,
NameClaimType = SiteClaimTypes.Username,
AuthenticationType = authenticationType,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
ValidateIssuerSigningKey = true
};Okta JWT
With Okta MVC configured, the next step is to set up Okta JWT. The setup is very similar, with a few small differences, such as building the issuer URL.
Setting the MapInboundClaims flag to false disables automatic claim mapping, giving full control over how claims are transformed. This allows custom logic to ensure claims for both MVC and JWT authentication flows are consistently aligned. Manual mapping is performed on the source claims, rather than relying on default conventions.
Only one event handler is specified here - to synchronize user roles using the same logic as for MVC. The key difference is the use of OnTokenValidated instead of OnTokenReceived. This choice is based on testing, which revealed that combining these events provided a complete claim set. Using OnTokenValidated alone in the MVC schema resulted in missing claims, which were present in OnTokenReceived.
private static AuthenticationBuilder AddApiAuth(this AuthenticationBuilder authenticationBuilder, OktaAuthOptions options) =>
authenticationBuilder
.AddJwtBearer(AuthScheme.OktaJwt, o =>
{
o.Authority = UrlHelper.CreateIssuerUrl(options.Domain, AuthorizationServerId);
o.Audience = options.ApiAudience;
o.RequireHttpsMetadata = true;
o.TokenValidationParameters = GetTokenValidationParameters(AuthScheme.OktaJwt);
o.MapInboundClaims = false;
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
OktaClaimsTransformer.Transform(context.Principal);
await SynchronizeRoles(context.HttpContext, context.Principal);
}
};
});Gluing all pieces together
With all schemas configured, the final step is to assemble the complete authentication system.
private static IServiceCollection AddMixedAuth(this IServiceCollection services, AuthOptions? options, bool isDevelopment)
{
ArgumentNullException.ThrowIfNull(options);
services
.AddCmsAspNetIdentity<ApplicationUser>()
.ConfigureApplicationCookie(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddSession(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.IsEssential = true;
})
.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = AuthScheme.Cms;
o.DefaultChallengeScheme = AuthScheme.Cms;
})
.AddOktaAuth(options.Okta)
.AddPolicyScheme(AuthScheme.Cms, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.ContainsIdentityAuthCookie()
? AuthScheme.Identity
: AuthScheme.OktaMvc;
})
.AddPolicyScheme(AuthScheme.Api, null, o =>
{
o.ForwardDefaultSelector = httpContext =>
httpContext.IsSwaggerContext()
? AuthScheme.Cms
: AuthScheme.OktaJwt;
});
services.ConfigureOktaTokenValidation(options.Okta);
services.AddContentApiAuth(options?.ContentApi, isDevelopment);
return services;
}The AddMixedAuth method consolidates all the auth configurations. It sets up the cookie and session handling, defines policies, and establishes the default schema. This method serves as a single, centralized point where every part of the setup is connected. It can then be used conveniently at a higher level, such as in the startup file, to activate the complete mixed authentication configuration.
services.AddMixedAuth(options, isDevelopment);An additional helpful piece of code is the MapLogoutRoute extension method, which is executed within the Configure method of the startup file. Its role is to detect the current schema context and perform the appropriate sign-out procedure.
internal static IApplicationBuilder MapLogoutRoute(this IApplicationBuilder app, AuthOptions? options)
{
app.MapWhen(context => context.Request.Path.StartsWithSegments("/Util/Logout"), appBuilder =>
{
appBuilder.Run(async httpContext =>
{
if (httpContext.ContainsIdentityAuthCookie())
{
var signInManager = httpContext.RequestServices.GetRequiredService<UISignInManager>();
await signInManager.SignOutAsync();
}
else
{
await httpContext.SignOutAsync();
}
httpContext.Response.Redirect(options?.Okta?.SignOutRedirectUrl ?? "/", false);
});
});
return app;
}Final words
The approach described above helped to clearly configure a fairly complex and interesting authentication and authorization setup in one of the projects I worked on. It is the result of many hours of investigation, reading documentation, and designing a well-structured configuration setup. This structure is easy to extend and can be enabled or disabled smoothly, making it practical for real-world scenarios.
More articles

Cancelling CMS Scheduled Jobs
From flags to tokens: making Optimizely CMS scheduled jobs more elegant with .NET cancellation tokens.

Exposing Color Picker to Content Graph
A guide on how to consume custom CMS property backing types in a headless architecture, using a color picker as an example.

Getting 404 when expecting 401
A short story about the mysterious behavior of an API in headless architecture.