Mert Özen Further With Every Line
Security Authentication and Authorization — #15

Choosing an Authorization Server: Comparing OpenIddict, Duende IdentityServer, Keycloak and Microsoft Entra ID

Mert Özen Nov 8, 2026 10 min 2 views
Choosing an Authorization Server: Comparing OpenIddict, Duende IdentityServer, Keycloak and Microsoft Entra ID

The right authorization server depends on what your team can operate: OpenIddict and Duende are frameworks, Keycloak a product, Entra ID a managed service. We run three in the lab, build a capability matrix from discovery documents, show why it must be read with care, and compare licenses, operational load, SAML bridging and why you should not write your own authorization server, as of November 2026.

Version note: The code in this post was built and run with .NET 10, Duende.IdentityServer 8.0.10, OpenIddict 7.8.0 and Keycloak 26.8.2; license and pricing information was checked on the products' own pages in November 2026.

Guess First

Bookstore is growing: web, mobile, two partner apps and an admin panel for enterprise customers. All of them will connect to a single authorization server. Someone on the team says "OpenIddict is free, let's use it", someone else "everything is ready in Entra ID", and a third "we already run Keycloak in Docker". Which one is right? And this: if while choosing you see "no DPoP" in a product's discovery document, does that mean the product doesn't support DPoP?

In posts 10 to 14 of this series, Keycloak played the authorization server. In this post we compare four candidates that could take that role: OpenIddict, Duende IdentityServer, Keycloak and Microsoft Entra ID. The comparison does not come from marketing pages; I brought three of them up in the lab and read their discovery documents with the same tool, and fed the fourth one's public discovery document to the same tool. Product features and licenses change quickly; re-verify everything here at the moment you decide.

What Are You Choosing?

"Authorization server" looks like a single piece but has several responsibilities: speaking the OAuth/OIDC (OpenID Connect) protocol and issuing tokens; storing users and passwords; providing sign-in, registration, password reset, MFA (Multi-Factor Authentication) and passkey screens; federating with Google, a corporate Active Directory or a SAML (Security Assertion Markup Language) IdP (Identity Provider); managing clients, scopes and keys; keeping an audit trail. The candidates take on different parts of this list. The most important distinction: are you getting a framework, a product or a service?

Four Candidates

OpenIddict is a framework. It adds the protocol layer inside your ASP.NET Core app; the user store, sign-in screens, consent screen and admin UI (User Interface) are yours. It is usually used together with ASP.NET Core Identity (posts 03 and 04 of this series). Its license is Apache-2.0; no fee for commercial use. High flexibility, and high responsibility: it handles the protocol's edge cases, but you write everything else.

Duende IdentityServer is also a framework, with the same model: the protocol is Duende's, the users and screens are yours. The difference is the license: it is a commercial product. The product page states clearly that no license is needed for development, testing or trial; in production a license is required. Features such as SAML, multi-tenancy and automatic key management are sold as separate paid add-ons. Running it without a license, it writes this to the log:

fail: Duende.Private.Licencing.V2.LicenseValidator[263521618]
      You do not have a valid license key for the Duende software. This is allowed for development and testing scenarios. If you are running in production you are required to have a licensed version. Please start a conversation with us: https://duend...
warn: Duende.Private.Licencing.V2.LicenseValidator[1549918610]
      Automatic Key Management is being used but no Duende license is configured. Please start a conversation with us: https://duende.link/l/contact

Keycloak is a product. User store, sign-in and registration screens, MFA, passkeys, federation, an admin console, SAML and organization support for multi-tenant scenarios come out of the box. Its license is Apache-2.0 and it is a project under the CNCF (Cloud Native Computing Foundation). The price is operating it: a Java-based server, a database, a cluster, version upgrades. Keycloak has no LTS (Long-Term Support) release; security fixes go to the newest minor version, so regular upgrades are not an option but a requirement.

Microsoft Entra ID is a managed service: Entra ID for employees (workforce), Entra External ID for customers. Microsoft runs the infrastructure, scaling and key rotation. For External ID, the pricing page on Microsoft Learn says the first 50,000 monthly active users are free. The old Azure AD (Active Directory) B2C (business-to-consumer) has not been sold to new customers since 1 May 2025; for existing customers, support is stated to continue until at least May 2030. So for a B2C project starting today, the option is External ID.

A Capability Matrix from Discovery

I ran Duende and OpenIddict in the lab in their smallest form. The Duende side:

// Unlicensed development/trial mode: Duende writes a warning log at startup
builder.Services.AddIdentityServer()
    .AddInMemoryIdentityResources([new IdentityResources.OpenId(), new IdentityResources.Profile()])
    .AddInMemoryApiScopes([new ApiScope("books.read")])
    .AddInMemoryClients(
    [
        new Client
        {
            ClientId = "bookstore-inventory-worker",
            ClientSecrets = [new Secret(builder.Configuration["WorkerSecret"].Sha256())],
            AllowedGrantTypes = GrantTypes.ClientCredentials,
            AllowedScopes = ["books.read"]
        }
    ]);

WebApplication app = builder.Build();
app.UseIdentityServer();

The OpenIddict side; you pick endpoints and allowed flows explicitly, and nothing you don't use is on:

builder.Services.AddOpenIddict()
    .AddCore(options => options.UseEntityFrameworkCore().UseDbContext<AuthDbContext>())
    .AddServer(options =>
    {
        options.SetAuthorizationEndpointUris("connect/authorize")
            .SetPushedAuthorizationEndpointUris("connect/par")
            .SetTokenEndpointUris("connect/token")
            .SetIntrospectionEndpointUris("connect/introspect")
            .SetRevocationEndpointUris("connect/revoke")
            .SetEndSessionEndpointUris("connect/logout")
            .SetDeviceAuthorizationEndpointUris("connect/device")
            .SetEndUserVerificationEndpointUris("connect/verify");
        options.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange()
            .AllowClientCredentialsFlow()
            .AllowDeviceAuthorizationFlow()
            .AllowRefreshTokenFlow();
        // Lab only: non-persistent keys and HTTP
        options.AddEphemeralEncryptionKey().AddEphemeralSigningKey();
        options.UseAspNetCore().DisableTransportSecurityRequirement();
    });

Then I wrote a small console app that reads discovery documents. For Entra ID I downloaded the public document at login.microsoftonline.com/common/v2.0/.well-known/openid-configuration once and had the tool read it from a file:

// Builds a capability matrix from discovery documents: does the server advertise a feature?
(string Label, Func<JsonElement, string> Read)[] rows =
[
    ("PKCE S256", d => Has(d, "code_challenge_methods_supported", "S256")),
    ("PAR (RFC 9126)", d => Present(d, "pushed_authorization_request_endpoint")),
    ("DPoP (RFC 9449)", d => Present(d, "dpop_signing_alg_values_supported")),
    ("mTLS bound tokens", d => Bool(d, "tls_client_certificate_bound_access_tokens")),
    ("private_key_jwt", d => Has(d, "token_endpoint_auth_methods_supported", "private_key_jwt")),
    ("iss in auth response", d => Bool(d, "authorization_response_iss_parameter_supported")),
    ("device flow (RFC 8628)", d => Present(d, "device_authorization_endpoint")),
    ("introspection", d => Present(d, "introspection_endpoint")),
    ("revocation", d => Present(d, "revocation_endpoint")),
    ("end session", d => Present(d, "end_session_endpoint")),
    ("back-channel logout", d => Bool(d, "backchannel_logout_supported")),
    ("CIBA", d => Present(d, "backchannel_authentication_endpoint")),
    ("implicit advertised", d => Has(d, "grant_types_supported", "implicit")),
    ("password advertised", d => Has(d, "grant_types_supported", "password"))
];

static string Present(JsonElement d, string key) => d.TryGetProperty(key, out _) ? "yes" : "-";
static string Bool(JsonElement d, string key) =>
    d.TryGetProperty(key, out JsonElement v) && v.ValueKind == JsonValueKind.True ? "yes" : "-";
static string Has(JsonElement d, string key, string value) =>
    d.TryGetProperty(key, out JsonElement v) && v.EnumerateArray().Any(x => x.GetString() == value) ? "yes" : "-";
$ dotnet run --project ascheck
                        Keycloak 26.8.2    Duende IS 8.0.10   OpenIddict 7.8.0   Entra ID (common)
PKCE S256               yes                yes                yes                -
PAR (RFC 9126)          yes                yes                yes                -
DPoP (RFC 9449)         yes                yes                -                  -
mTLS bound tokens       yes                -                  -                  yes
private_key_jwt         yes                -                  yes                yes
iss in auth response    yes                yes                yes                -
device flow (RFC 8628)  yes                yes                yes                yes
introspection           yes                yes                yes                -
revocation              yes                yes                yes                -
end session             yes                yes                yes                yes
back-channel logout     yes                yes                -                  -
CIBA                    yes                yes                -                  -
implicit advertised     yes                yes                -                  -
password advertised     yes                -                  -                  -

This table says something, but it must be read carefully. A discovery document shows what the server advertises in its current configuration; anything not advertised is not necessarily unsupported. Entra ID supports PKCE (Proof Key for Code Exchange) but does not publish code_challenge_methods_supported. Duende has private_key_jwt, but it is not on in my minimal setup. OpenIddict has no implicit or password because I didn't enable them; Keycloak advertises them at the server level and turns them off per client (we saw that in post 10). Conversely, an advertised feature may not be enabled for your client. Use the matrix as a starting point, then verify every row that looks missing in the product's own documentation and in your own lab. That is also the answer to the Guess First question: a "no DPoP" row only means "this configuration does not advertise DPoP".

Still, the table does show some things clearly. DPoP, which we used in post 13, and back-channel logout from post 12 were advertised here only by Keycloak and Duende. If you need those two features, don't decide on OpenIddict or Entra until you have the answer for them.

Self-Hosted or Managed?

When you host it yourself, the authorization server becomes your most critical service: if it goes down, nobody can sign in. High availability (at least two instances and shared state), database backups, safe storage and rotation of signing keys, tracking security advisories and upgrading fast, penetration tests, retaining audit logs; all of it is your job. With Keycloak that means operating a product; with OpenIddict and Duende it means operating an application you wrote; in the second case the security of the sign-in screen, the password reset flow and the lockout rules is your code too.

With a managed service most of this belongs to the provider; in exchange there are other costs. Pricing by monthly active users can become the biggest line item as the user count grows. Customization is limited: you can change the sign-in screen's look, the claims in the token and the registration flow only as far as the provider allows. Lock-in is real: can you move your users' password hashes to another system? Where is the data stored, and does it count as a transfer abroad under your data protection law? Ask these questions before you buy.

There is a middle path too: buying the authorization server as a managed service while keeping user data in your own system, or the reverse, taking Keycloak as a managed offering from a provider. The choice isn't black and white; but the answer to "who on our team will run this service 24/7?" usually makes the decision.

Federation, SSO and SAML

When enterprise customers arrive, two requests come up right away: "our employees should sign in with their company accounts" (federation) and "they should sign in once and be signed in to all your apps" (SSO, Single Sign-On). We saw SSO in post 12; the same Keycloak session gave codes to two clients without asking for a password. In federation the authorization server trusts another IdP and redirects the user to it.

Most modern IdPs speak OIDC (OpenID Connect), but SAML 2.0 is still common in the enterprise world: an old AD FS (Active Directory Federation Services) setup, some HR (human resources) and accounting SaaS (Software as a Service) apps, government identity systems. SAML is XML (Extensible Markup Language) based, works with signed assertions, and is a format that has historically produced serious signature validation flaws. Your app doesn't need to speak SAML; the authorization server can take SAML in and give it to your app as OIDC. This "protocol bridge" is built into Keycloak, exists in Entra ID, is a paid add-on in Duende, and is absent from OpenIddict.

Decision Matrix

QuestionOpenIddictDuende IdentityServerKeycloakEntra ID / External ID
What you getFrameworkFrameworkProductManaged service
LicenseApache-2.0Commercial; free for dev/testApache-2.0Usage-based; External ID free up to 50,000 MAU (Monthly Active Users)
User screensYou write themYou write themBuilt in, themableBuilt in, limited customization
Operational loadYour own appYour own appJava server + databaseOn the provider
SAML bridgeNonePaid add-onBuilt inAvailable
Fit for a .NET teamVery highVery highMedium (Java, SPI (Service Provider Interface) extensions)High (SDKs)

Roughly: if you want to write the authorization server in .NET as part of your application and control every screen, OpenIddict or Duende; the choice between them is often license budget versus advanced features that come ready. If you want a product with ready screens and SAML that you run yourself, Keycloak. If you don't want to operate infrastructure, your users already come with Microsoft accounts, or enterprise SSO is the priority, Entra.

There is also an option not on the list: writing your own authorization server from scratch. Don't. The security of OAuth and OIDC is in the details: redirect URI (Uniform Resource Identifier) matching, single-use codes and token revocation on reuse (we saw how Keycloak does it in post 11), PKCE downgrade, mix-up, refresh token rotation, key rotation, the kinds of logout. Every post in this series was one item from that list, and the list isn't finished. Frameworks give you code tested against these edge cases for years; picking a product that has passed the OpenID Foundation's certification tests is another good filter.

In Production

Whichever you choose, keep clients and configuration as code: a realm export or a setup script like the one in this series for Keycloak, seed code for Duende and OpenIddict, a tool like Terraform or Bicep for Entra. With an authorization server set up by clicking, you can't know when, by whom or why it changed. Use separate realms/tenants for test, staging and production; the audience rule from post 13 should also stop tokens from moving between environments.

Think about portability from the start: keep user identities in your app as iss + sub, and access authorization-server-specific claims (Keycloak's realm_access, Entra's oid) behind a single adapter. That is why the RequireScope extension method from post 13 and the role-reading adapter we will see in post 16 live in separate places. If you chose a licensed product, license expiry must be in the runbook as an outage scenario.

Common Pitfalls

First, treating a discovery document or a comparison table (including this post's) as a feature list. Second, reading "free" only as license cost; the licenses of OpenIddict and Keycloak are free, operating and developing them is not. Third, shipping Duende to production unlicensed; the warning in the log says that is not allowed. Fourth, trying to build a new project on Azure AD B2C.

Fifth, running the authorization server as a single instance. Sixth, learning about an enterprise customer's SAML requirement after the contract is signed. Seventh, reading product features and license terms once and deciding with that information for years; the information in this post is from November 2026.

A Small Experiment

★☆☆ Add a row to ascheck: does token_endpoint_auth_methods_supported contain client_secret_post? Interpret the result for the four servers.

★★☆ Add private_key_jwt client authentication to the Duende setup (the JWT (JSON Web Token) bearer client authentication setting in Duende's documentation) and show the matrix change. Research the same for DPoP in OpenIddict: is it supported in version 7.8.0, and how is it enabled?

★★★ Prepare a weighted decision matrix for Bookstore with your own team's constraints (team size, budget, number of enterprise customers, data residency); give each criterion a 1-5 score and a weight, and compare the result with the rough recommendations in this post.

We have talked about who signs in and who issues the token. In the next post we move to what the signed-in user may do: RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control), ReBAC (Relationship-Based Access Control), permission-based design and multi-tenant scenarios.