---
title: Use Opti ID with a separate site-visitor authentication scheme
slug: cms-13/docs/use-opti-id-with-separate-visitor-authentication
createdAt: 2026-10-09T16:38:00.000Z
---

Opti ID authenticates Optimizely product users, such as editors and administrators in the CMS shell. Site visitors are out of scope for Opti ID and are unaffected by it. This topic describes how to run Opti ID for editors alongside a separate authentication scheme for visitors.

## ASP.NET Identity support in CMS 13

- `EPiServer.CMS.UI.AspNetIdentity` ships in CMS 13 and is supported.
- There is no announced removal version.
- Enabling Opti ID does not require you to migrate accounts or password hashes. Existing visitor accounts in `AspNetUsers` keep working.

## Prerequisites

- [Configure Opti ID for CMS 13](https://docs.optimizely.com/opti-id/docs/configure-opti-id-for-cms-13)
- If you are upgrading from CMS 12, see [Upgrade to CMS 13 from CMS 12](https://docs.optimizely.com/cms-13/docs/upgrade-to-cms-13).
- Familiarity with [Mixed-mode authentication](https://docs.developers.optimizely.com/content-management-system/docs/mixed-mode-authentication)

## Visitors use ASP.NET Identity, editors use Opti ID

Register both in `ConfigureServices`. `AddCmsAspNetIdentity<TUser>()` registers the `Identity.Application` cookie scheme for visitors. `AddOptimizelyIdentity()` registers the Opti ID scheme for editors.

```csharp
public void ConfigureServices(IServiceCollection services) {
  // Visitors: ASP.NET Identity (Identity.Application cookie)
  services.AddCmsAspNetIdentity<SiteUser>();

  // Editors and administrators: Opti ID
  services.AddOptimizelyIdentity();

  // Keep the SecurityEntityProvider interceptor from the section below
  // so both populations appear in the admin UI.
  services.TryIntercept<SecurityEntityProvider>((s, inner) =>
    new CombinedSecurityEntityProvider(
      inner,
      s.GetRequiredService<SynchronizingRolesSecurityEntityProvider>()));
}
```

Call `AddOptimizelyIdentity()` without `useAsDefault: true`. Do not pass `useAsDefault: false` either. See [Cookies and  useAsDefault](docId\:m8v_PIH1LsdUVnNxn7EiW).

Sign visitors in with the standard `SignInManager<SiteUser>` APIs. For external visitor logins, call `SignInManager.ExternalLoginSignInAsync(...)`.

## Visitors use a customer-owned OIDC or B2C provider

Use this pattern when visitors authenticate against your own OpenID Connect or Azure AD B2C provider rather than ASP.NET Identity. Register the visitor scheme as the default and keep Opti ID as a separate scheme.

```csharp
public void ConfigureServices(IServiceCollection services) {
  services
    .AddAuthentication(options => {
      options.DefaultScheme = "visitor-cookie";
      options.DefaultChallengeScheme = "visitor-oidc";
    })
    .AddCookie("visitor-cookie", options => {
      options.Cookie.Name = "site.visitor";
      options.LoginPath = "/login";
    })
    .AddOpenIdConnect("visitor-oidc", options => {
      options.Authority = "https://your-b2c-or-oidc-provider";
      options.ClientId = "<visitor-client-id>";
      options.ClientSecret = "<visitor-client-secret>";
      options.ResponseType = "code";
      options.SignInScheme = "visitor-cookie";
      options.SignOutScheme = "visitor-cookie";
    });

  // Editors and administrators: Opti ID, registered without useAsDefault
  services.AddOptimizelyIdentity();
}
```

Opti ID keeps its own cookie, so visitor claims and CMS claims are never mixed. Without `AddCmsAspNetIdentity<TUser>()`, CMS registers implementations that synchronize user information from external providers. To sync visitors and roles on sign-in, see [Sync users and roles from an external authentication provider](https://docs.developers.optimizely.com/content-management-system/docs/mixed-mode-authentication).

## Cookies and useAsDefault

Each scheme owns its own cookie. Opti ID writes only its own CMS cookie. It never writes `Identity.Application`.

| Scheme                                                      | Cookie owner                    | Authenticates                               |
| ----------------------------------------------------------- | ------------------------------- | ------------------------------------------- |
| ASP.NET Identity (`Identity.Application`)                   | `AddCmsAspNetIdentity<TUser>()` | Site visitors                               |
| Customer OIDC/B2C (for example `visitor-cookie`)            | Your registration               | Site visitors                               |
| Opti ID (`OptimizelyIdentityDefaults.AuthenticationScheme`) | `AddOptimizelyIdentity()`       | Editors and administrators in the CMS shell |

| Setting                              | Effect                                                                                                                                                                    |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useAsDefault` omitted (recommended) | Opti ID owns the CMS shell. Your visitor scheme stays the application default, so the public site continues to authenticate visitors.                                     |
| `useAsDefault: true`                 | Opti ID becomes the application default scheme. Visitors can no longer use the default scheme, and the application can fall back to local sign-in.                        |
| `useAsDefault: false`                | Not recommended. Opti ID still writes only its own cookie, so a user signed in through Opti ID is authenticated in the edit or admin UI but anonymous on the public site. |

## Show both populations in the admin UI

When you call `AddCmsAspNetIdentity<TUser>()`, the default `SecurityEntityProvider` returns only ASP.NET Identity users and roles. Opti ID users and roles are synchronized separately. Intercept `SecurityEntityProvider` so both populations appear when you set content access rights.

```csharp
services.TryIntercept<SecurityEntityProvider>((s, inner) =>
  new CombinedSecurityEntityProvider(
    inner,
    s.GetRequiredService<SynchronizingRolesSecurityEntityProvider>()));

public class CombinedSecurityEntityProvider : SecurityEntityProvider {
  private readonly SecurityEntityProvider _inner;
  private readonly SynchronizingRolesSecurityEntityProvider _synchronizing;

  public CombinedSecurityEntityProvider(
    SecurityEntityProvider inner,
    SynchronizingRolesSecurityEntityProvider synchronizing) {
    _inner = inner;
    _synchronizing = synchronizing;
  }

  public override async Task<IEnumerable<string>> GetRolesForUserAsync(string userName) {
    if (_inner is SynchronizingRolesSecurityEntityProvider) {
      return await _synchronizing.GetRolesForUserAsync(userName);
    }

    var results = await Task.WhenAll(
      _synchronizing.GetRolesForUserAsync(userName),
      _inner.GetRolesForUserAsync(userName));

    return results.SelectMany(x => x);
  }

  public override async Task<(IEnumerable<string> users, int totalCount)> FindUsersInRoleAsync(
    string roleName, string usernameToMatch, int startIndex, int maxRows) {
    if (_inner is SynchronizingRolesSecurityEntityProvider) {
      return await _synchronizing.FindUsersInRoleAsync(roleName, usernameToMatch, startIndex, maxRows);
    }

    var results = await Task.WhenAll(
      _synchronizing.FindUsersInRoleAsync(roleName, usernameToMatch, startIndex, maxRows),
      _inner.FindUsersInRoleAsync(roleName, usernameToMatch, startIndex, maxRows));

    return (results.SelectMany(x => x.users), results.Sum(x => x.totalCount));
  }

  public override async Task<IEnumerable<SecurityEntity>> SearchAsync(string partOfValue, string claimType) {
    return (await SearchAsync(partOfValue, claimType, 0, int.MaxValue)).entities;
  }

  public override async Task<(IEnumerable<SecurityEntity> entities, int totalCount)> SearchAsync(
    string partOfValue, string claimType, int startIndex, int maxRows) {
    if (_inner is SynchronizingRolesSecurityEntityProvider) {
      return await _synchronizing.SearchAsync(partOfValue, claimType, startIndex, maxRows);
    }

    var results = await Task.WhenAll(
      _synchronizing.SearchAsync(partOfValue, claimType, startIndex, maxRows),
      _inner.SearchAsync(partOfValue, claimType, startIndex, maxRows));

    return (results.SelectMany(x => x.entities), results.Sum(x => x.totalCount));
  }
}
```

## Protect custom routes

Shell modules authenticate against Opti ID automatically. For custom routes outside shell modules, such as a custom admin page, add the Opti ID scheme to the policy. Otherwise the request is challenged by the default (visitor) scheme and redirected to the local login.

```csharp
services.AddAuthorization(options => {
  options.AddPolicy("CustomAdmin", policy => {
    policy.AddAuthenticationSchemes(OptimizelyIdentityDefaults.AuthenticationScheme);
    policy.RequireAuthenticatedUser();
    policy.RequireRole("CmsAdmins");
  });
});
```

```csharp
[Authorize(Policy = "CustomAdmin")]
public class CustomAdminController : Controller { }
```

## Virtual roles

Opti ID automatically maps the `CmsEditors` and `CmsAdmins` roles. Remove any existing mappings for these roles, such as `appsettings.json` entries or code-based virtual role registrations, because they conflict with the Opti ID mappings.

The following are untouched by Opti ID and stay in `AspNetRoles`:

- Commerce virtual roles
- Bespoke customer roles

For more on virtual roles, see [Virtual roles](https://docs.developers.optimizely.com/content-management-system/docs/virtual-roles).

## Troubleshooting

| Symptom                                                 | Cause and fix                                                                                                                                                                                                                                       |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Signed in to `/ui/cms` but anonymous on the public site | Opti ID writes only its own cookie and does not write `Identity.Application`. This is expected. Authenticate visitors through your visitor scheme. Do not call `AddOptimizelyIdentity` with `useAsDefault: false` expecting it to sign visitors in. |
| `useAsDefault: true` falls back to local sign-in        | Opti ID is replacing the application default scheme. Remove `useAsDefault: true` so your visitor scheme stays the default and Opti ID owns only the CMS shell.                                                                                      |
| A custom admin route redirects to the local login       | The route is challenged by the default scheme. Add `policy.AddAuthenticationSchemes(OptimizelyIdentityDefaults.AuthenticationScheme)` to the policy.                                                                                                |
| Administer Users or Administer Groups returns HTTP 500  | Add the `SecurityEntityProvider` interceptor from [Show both populations in the admin UI](docId\:m8v_PIH1LsdUVnNxn7EiW) and confirm `AddCmsAspNetIdentity<TUser>()` is registered.                                                                  |

## What is not supported

- There is no built-in shared-password or audience-gated page mechanism for visitors.
- Audiences (visitor groups) are for personalization, not access control. Do not use them to restrict access to content.

To restrict content for visitors, use your visitor authentication scheme with authorization policies, or see [Set access rights without Opti ID](https://docs.developers.optimizely.com/content-management-system/docs/set-access-rights-without-opti-id).
