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.WithDataVolumegives both containers a named Docker volume, making the data survivedotnet runrestarts 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
- The browser opens
/login. - The
Loginaction callsChallengewith the OIDC scheme, redirecting the browser to Keycloak's authorization endpoint. - The user enters their credentials in Keycloak's login page.
- Keycloak redirects back to
/signin-oidcwith an authorization code. - The OIDC middleware exchanges the code for tokens, fetches user info, builds the
ClaimsPrincipaland issues a cookie. - 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
- A service (or a developer testing with an HTTP client) requests an access token directly from Keycloak using the
client_credentialsgrant. - Keycloak validates the client ID and secret and returns a JWT access token.
- The service calls
GET /userinfowith anAuthorization: Bearer <token>header. - The policy scheme detects the
Bearerprefix and forwards authentication to the JWT Bearer handler. - The handler validates the token signature, issuer and lifetime, then builds the
ClaimsPrincipalfrom 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.jsonand referenced inhttp-client.env.json
Security note: The credentials in realm-export.json, http-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.
Comments