Andreas Ylivainio
Apr 13, 2026
  47
(0 votes)

Keycloak Authentication with Optimizely CMS 12 and .NET Aspire

This post walks through how to wire together Optimizely CMS 12, Keycloak and .NET Aspire to give you a single local development environment that handles both interactive browser login and machine-to-machine (M2M) API calls from the same ASP.NET Core application.

For context read my previous blog post here: Using Scalar with Optimizely CMS

The source code for this blog post is available here: andreas-valtech/Aspire.OptimizelyContentDeliveryAuth

How this project came about

This started as an excuse to play with several things at once: Aspire, Keycloak, and OpenAI Codex — all things I wanted to get familiar with. Rather than building isolated toy demos, I wanted to see how they fit together while a real project was taking shape.

While I was at it, Avantibit published a great post on combining Alloy with an Aspire scaffold. That post is worth reading — it covers similar ground around Aspire and the Optimizely ecosystem. The difference here is that I was not specifically interested in DXP or CMS 13 for this scenario. My goal was narrower and more concrete: get CMS 12, SQL Server and Keycloak running in the same Aspire-orchestrated setup and make the authentication actually work end-to-end, both from a browser and from a machine client.

OpenAI Codex helped shape the implementation and documentation as the project grew, which made it a practical experiment for that too.

Why this combination?

Running Optimizely CMS 12, a SQL Server database, and an identity provider side by side used to mean a lot of manual setup — standing up containers by hand, clicking through Keycloak's admin UI, and maintaining notes about which credentials to use. .NET Aspire solves the orchestration problem: a single dotnet run starts everything and injects connection strings and service URLs into the web application automatically.

Keycloak fills the identity layer because it is a battle-tested, self-hostable OpenID Connect (OIDC) provider. With realm imports it also lets you version-control the entire identity configuration alongside the rest of the code — no clicking required after a fresh clone.


Repository structure

Aspire.OptimizelyContentDeliveryAuth.AppHost/   ← Aspire orchestrator
  AppHost.cs                                    ← wires together SQL Server, Keycloak and the web app
  realm-export.json                             ← versioned Keycloak realm import
  openid-configuration.json                     ← static OIDC discovery doc for local dev

Aspire.OptimizelyContentDeliveryAuth.Web/       ← Optimizely CMS 12 application
  Program.cs                                    ← minimal hosting entry point
  Startup.cs                                    ← authentication and CMS configuration
  UserInformationController.cs                  ← endpoints for login, logout and userinfo

login.http                                      ← test file for interactive login flow
machine-to-machine.http                         ← test file for client credentials flow
http-client.env.json                            ← shared variables for the .http test files
 

Aspire orchestration

The AppHost registers SQL Server, Keycloak, and the web project and wires them together so Aspire can inject service URLs as environment variables at startup:

// Aspire.OptimizelyContentDeliveryAuth.AppHost/AppHost.cs

var builder = DistributedApplication.CreateBuilder(args);

var sqlServer = builder.AddSqlServer("sqlserver")
    .WithDataVolume("aspireoptimizelycontentdeliveryauth-sql")
    .WithLifetime(ContainerLifetime.Persistent);

var cmsDb = sqlServer.AddDatabase("EPiServerDB");

var keyCloakUsername = builder.AddParameter("username", value: "admin");
var keycloakPassword = builder.AddParameter("password", value: "abc123", secret: true);
var keycloak = builder.AddKeycloak("keycloak", 8080, keyCloakUsername, keycloakPassword)
    .WithLifetime(ContainerLifetime.Persistent)
    .WithDataVolume("aspireoptimizelycontentdeliveryauth-keycloak")
    .WithRealmImport("realm-export.json");

builder.AddProject<Aspire_OptimizelyContentDeliveryAuth_Web>("web")
    .WithReference(cmsDb)
    .WithReference(keycloak)
    .WaitFor(keycloak);

builder.Build().Run();
 

Key points:

  • WithRealmImport("realm-export.json") tells Keycloak to import the realm configuration on first start, so no manual admin UI work is needed.
  • WaitFor(keycloak) ensures the web application only starts once Keycloak is healthy and has finished importing the realm.
  • WithDataVolume gives both containers a named Docker volume, making the data survive dotnet run restarts without re-initialisation.

Authentication configuration

The challenge: supporting both browsers and API clients on the same endpoint

A CMS application typically has browser users who log in interactively and back-end services that call APIs with a bearer token. ASP.NET Core's authentication middleware uses a single default scheme, which makes it awkward to support both styles.

The solution is a policy scheme — a lightweight scheme that inspects each request and forwards it to the correct handler:

// Aspire.OptimizelyContentDeliveryAuth.Web/Startup.cs

services.AddAuthentication(options =>
    {
        options.DefaultScheme = "smart";
        options.DefaultAuthenticateScheme = "smart";
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddPolicyScheme("smart", "Bearer or cookie", options =>
    {
        options.ForwardDefaultSelector = context =>
        {
            var authorizationHeader = context.Request.Headers.Authorization.ToString();
            return authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
                ? JwtBearerDefaults.AuthenticationScheme
                : CookieAuthenticationDefaults.AuthenticationScheme;
        };
    })
 

When a request arrives the selector checks for a Bearer header:

  • Present → the request is forwarded to the JWT Bearer handler.
  • Absent → the request is forwarded to the Cookie handler, and if no valid cookie exists, the challenge triggers an OIDC redirect.

JWT Bearer — for machine-to-machine clients

.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.SaveToken = true;
    options.Authority = authority;               // http://localhost:8080/realms/optimizely
    options.MetadataAddress = metadataAddress;  // points to openid-configuration.json during local dev
    options.MapInboundClaims = false;
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateAudience = false,
        ValidateIssuer = true,
        ValidIssuer = authority,
        ValidateLifetime = true,
        NameClaimType = "preferred_username",
        RoleClaimType = "roles"
    };
})
 

MetadataAddress points at a static openid-configuration.json file served locally instead of hitting Keycloak's own discovery endpoint. This makes local development more resilient to timing issues on container startup.

Cookie + OIDC — for interactive browser users

.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
    options.Cookie.Name = "Aspire.OptimizelyContentDeliveryAuth";
    options.ExpireTimeSpan = TimeSpan.FromHours(10);
    options.SlidingExpiration = false;
    options.Cookie.SecurePolicy = webHostingEnvironment.IsDevelopment()
        ? CookieSecurePolicy.None
        : CookieSecurePolicy.Always;
    options.Events.OnRedirectToAccessDenied = ctx =>
    {
        ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
        return Task.CompletedTask;
    };
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Authority = authority;
    options.MetadataAddress = metadataAddress;
    options.ClientId = interactiveClientId;     // "optimizely-web"
    options.ClientSecret = interactiveClientSecret;
    options.RequireHttpsMetadata = false;
    options.ResponseType = "code";
    options.UsePkce = true;
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.MapInboundClaims = false;
    options.CallbackPath = "/signin-oidc";
    options.SignedOutCallbackPath = "/signout-callback-oidc";
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "preferred_username",
        RoleClaimType = "roles"
    };
});
 

PKCE (UsePkce = true) is enabled to meet current security best practices for public and confidential clients alike. GetClaimsFromUserInfoEndpoint = true ensures that profile claims (name, email) are populated even when they are not included in the ID token directly.


The userinfo endpoint

Both authentication paths land on the same controller action — the only thing that changes is whether the ClaimsPrincipal was populated from a cookie session or from a validated bearer token:

// Aspire.OptimizelyContentDeliveryAuth.Web/UserInformationController.cs

[ApiController]
public class UserInformationController : Controller
{
    [AllowAnonymous]
    [Route("login")]
    public IActionResult Login([FromQuery] string returnUrl = "/userinfo")
    {
        return Challenge(
            new AuthenticationProperties { RedirectUri = returnUrl },
            OpenIdConnectDefaults.AuthenticationScheme);
    }

    [Authorize]
    [HttpPost("logout")]
    public IActionResult Logout()
    {
        return SignOut(
            new AuthenticationProperties { RedirectUri = "/" },
            CookieAuthenticationDefaults.AuthenticationScheme,
            OpenIdConnectDefaults.AuthenticationScheme);
    }

    [Authorize]
    [Route("userinfo")]
    public IActionResult Get()
    {
        return new JsonResult(new
        {
            User.Identity?.Name,
            AuthenticationType = User.Identity?.AuthenticationType,
            Claims = User.Claims.Select(c => new { c.Type, c.Value })
        });
    }
}
 

The [Authorize] attribute on Get() is enough — the policy scheme handles routing to the right handler, and both eventually resolve a ClaimsPrincipal that satisfies the authorization requirement.


Interactive login flow

How it works

  1. The browser opens /login.
  2. The Login action calls Challenge with the OIDC scheme, redirecting the browser to Keycloak's authorization endpoint.
  3. The user enters their credentials in Keycloak's login page.
  4. Keycloak redirects back to /signin-oidc with an authorization code.
  5. The OIDC middleware exchanges the code for tokens, fetches user info, builds the ClaimsPrincipal and issues a cookie.
  6. The browser is redirected to the original returnUrl (defaults to /userinfo).

Test it with login.http

@env = dev

### Interactive login through the web app
# Open this request in a browser. The app will challenge with OIDC
# and then redirect back to /userinfo.
GET {{web_base_url}}/login?returnUrl=%2Fuserinfo

### Read user information from the authenticated browser session
GET {{web_base_url}}/userinfo
 

Variables (http-client.env.json):

{
  "dev": {
    "web_base_url": "https://localhost:5000"
  }
}
 

Demo credentials (local development only):

  • Username: editor
  • Password: Passw0rd!

A successful response from /userinfo looks like:

{
  "name": "editor",
  "authenticationType": "Cookies",
  "claims": [
    { "type": "preferred_username", "value": "editor" },
    { "type": "email", "value": "editor@example.com" }
  ]
}
 

Machine-to-machine (client credentials) flow

How it works

  1. A service (or a developer testing with an HTTP client) requests an access token directly from Keycloak using the client_credentials grant.
  2. Keycloak validates the client ID and secret and returns a JWT access token.
  3. The service calls GET /userinfo with an Authorization: Bearer <token> header.
  4. The policy scheme detects the Bearer prefix and forwards authentication to the JWT Bearer handler.
  5. The handler validates the token signature, issuer and lifetime, then builds the ClaimsPrincipal from the token's claims.

Test it with machine-to-machine.http

@env = dev

### Step 1 — Request a client credentials token from Keycloak
POST {{keycloak_base_url}}/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&
client_id={{machine_client_id}}&
client_secret={{machine_client_secret}}

### Step 2 — Call the userinfo endpoint with the bearer token
GET {{web_base_url}}/userinfo
Authorization: Bearer {{access_token}}
 

Variables (http-client.env.json):

{
  "dev": {
    "web_base_url": "https://localhost:5000",
    "keycloak_base_url": "http://localhost:8080/realms/optimizely/protocol/openid-connect",
    "machine_client_id": "optimizely-api",
    "machine_client_secret": "<see realm-export.json>"
  }
}
 

Note: If your HTTP client does not automatically capture access_token from the first response, copy the token value manually into the Authorization header of the second request.

A successful response from /userinfo looks like:

{
  "name": "service-account-optimizely-api",
  "authenticationType": "Bearer",
  "claims": [
    { "type": "preferred_username", "value": "service-account-optimizely-api" },
    { "type": "azp", "value": "optimizely-api" }
  ]
}
 

Keycloak realm configuration

The realm is fully version-controlled in realm-export.json. On first start, Keycloak imports this file automatically — no admin UI setup is needed. The realm contains:

Resource Type Purpose
optimizely-web Public OIDC client Interactive browser login via PKCE authorization code flow
optimizely-api Confidential client Machine-to-machine token requests via client credentials grant
editor User Demo account for testing interactive login locally

optimizely-web — public client

  • Grant type: Authorization code with PKCE
  • Redirect URI: https://localhost:5000/signin-oidc
  • Post-logout redirect URI: https://localhost:5000/signout-callback-oidc
  • Web origins: https://localhost:5000

optimizely-api — confidential client

  • Grant type: Client credentials
  • Service accounts enabled: yes
  • Secret: stored in realm-export.json and referenced in http-client.env.json

Security note: The credentials in realm-export.jsonhttp-client.env.json and AppHost.cs are intentionally hardcoded for local development convenience. They exist only to make the setup reproducible on a fresh clone. Never use these values in a shared, public, or production-adjacent environment. Replace passwords and client secrets with strong, unique values if you expose the environment beyond your local machine.


Prerequisites

Requirement Notes
.NET 10 SDK Required by the AppHost project and Aspire tooling. Aspire's orchestration APIs target .NET 10.
.NET 8 SDK Required by the Web project. Optimizely CMS 12 targets .NET 8, so both SDKs must be installed side by side.
Docker Desktop For SQL Server and Keycloak containers
ASP.NET Core HTTPS dev certificate Run dotnet dev-certs https --trust if not already trusted

Starting the solution

From the repository root:

dotnet run --project .\Aspire.OptimizelyContentDeliveryAuth.AppHost
 

Aspire starts SQL Server, Keycloak and the web application in the right order. The Aspire dashboard (http://localhost:15888 by default) shows the status of all resources and aggregates their logs.


Troubleshooting

Keycloak ignores the realm import Containers use persistent volumes, so stale state from a previous run may survive restarts. Remove the named Keycloak volume (aspireoptimizelycontentdeliveryauth-keycloak) in Docker Desktop or via docker volume rm and restart the AppHost.

Login redirects to the wrong URL Verify that the web application is running on https://localhost:5000 and that the optimizely-web client in Keycloak still lists https://localhost:5000/signin-oidc as an allowed redirect URI.

Bearer token is rejected Confirm that the token was issued by the optimizely realm (check the iss claim) and that you requested it using the optimizely-api client with the client_credentials grant.

Apr 13, 2026

Comments

Please login to comment.
Latest blogs
The Entra ID Surprise

How assumptions can lead to mysterious issues.

Damian Smutek | Apr 13, 2026 |

OptiPowerTools.Hangfire 2.0.0: CMS 13 Support and Sample Jobs

When I released OptiPowerTools.Hangfire back in March, it targeted Optimizely CMS 12. With CMS 13 now out and running on .NET 10, it was time to...

Stanisław Szołkowski | Apr 13, 2026 |

CMS 12 - Optimizely DAM Integration 2.2.0

What's New in Optimizely DAM Integration 2.2.0 Version 2.2.0 of the Optimizely DAM (CMP) integration for CMS 12 is a pretty big release. Many of th...

Robert Svallin | Apr 12, 2026