The Cognitive Cost of Extensibility
Your security team hands you a one-sentence policy: a public client — one that authenticates with no secret — must never be granted the payments:write scope. The sentence fits in a commit message. Enforcing it means adding one validation step to the authorization endpoint of your OpenID Connect server.
Any of the three OpenID Connect server frameworks for .NET compared here — Duende IdentityServer, OpenIddict, Abblix OIDC Server — can enforce it. If the question is can it be done, the comparison is over before it starts: all three are extensible enough for almost anything a deployment demands. The question that separates them is a different one: how much of the product's internal model must you learn before your one-sentence rule runs?
Call that the cognitive cost of extension. You pay it in two currencies. The first is domain knowledge — OAuth 2.0 and OpenID Connect themselves — and that bill is identical everywhere; no server can waive it. The second is product knowledge: the extension model each server asks you to think in. That part varies, and it varies far more than feature tables suggest.
Full disclosure: we build one of the three. So instead of scoring difficulty — a number we would be inventing — this article implements the same rule in all three products against their real, shipped APIs, flagging the one place a cleaner form arrives in a future release, and lets you count the concepts yourself.
TL;DR
One security rule, three implementations — and the rule reads the resolved client, which is the fact that separates the products. In Duende IdentityServer it is a custom validator in the fixed slot at the end of validation, where the loaded client is already waiting on the request: one interface, one builder call, nothing to resolve. In OpenIddict it is a scoped handler that fetches the client through the application manager and classifies it — the most ceremony of the three, and the price of a pipeline where every step is addressed the same way. In Abblix OIDC Server it is one more member of the endpoint's rule family, resolving the client through the same provider the built-in validator uses; the rule stays in protocol terms — the requested scope, client id, client type — and the wrinkle you learn is that its default slot runs before the client is resolved, so you resolve it yourself (or, from 2.4, seat the rule after the built-in client validator in one line). What differs is not the size of the code — it is the model you hold in your head to write it.
The rule, and why it exists
Reject an authorization request that asks for payments:write when the client is public. The rule is in no specification, so no product ships it as a switch — but its motivation is old and well documented. A public client — a single-page app, a mobile app, a CLI tool — cannot keep a secret: its bytes run on a device the user controls, so it authenticates with nothing stronger than its client_id. The OAuth 2.0 Security Best Current Practice (RFC 9700) and the Financial-grade API profiles draw the obvious line from there — a high-value capability, moving money or changing account state, must sit behind a client the authorization server can actually authenticate. It is the same segregation-of-duties instinct that keeps a payment-initiation role apart from an account-information role in open banking: a token that can move money, held by a client that cannot protect a secret, is a stolen token waiting to happen.
The server already checks that a scope is registered for a client. What it does not do out of the box is enforce this cross-cutting policy — public clients never get the money-moving scope, whatever their registration says. That gap is where your one line of policy becomes one class of code. And because the policy is about the client, not just the request, every product must first answer the same question: where do I get the resolved client, and what does that cost me?
Duende IdentityServer: the client is already on the request
Duende's extension language is domain services — named interfaces, each owning one area of behavior, customized by implementing the one whose name matches your problem. For custom checks on the authorize request that name is ICustomAuthorizeRequestValidator, and its defining property is where it runs: last, after Duende's own validation has resolved and loaded the client.
public class ConfidentialClientPaymentsRule : ICustomAuthorizeRequestValidator
{
public Task ValidateAsync(CustomAuthorizeRequestValidationContext context, CancellationToken ct)
{
var request = context.Result.ValidatedRequest;
if (request.RequestedScopes.Contains("payments:write") && !request.Client.RequireClientSecret)
{
context.Result = new AuthorizeRequestValidationResult(
request,
OidcConstants.AuthorizeErrors.AccessDenied,
"payments:write requires a confidential client.");
}
return Task.CompletedTask;
}
}
Registration is one call on the builder:
builder.Services
.AddIdentityServer(options => { /* ... */ })
.AddCustomAuthorizeRequestValidator<ConfidentialClientPaymentsRule>();
What you had to know: the interface, the builder method, and that request.Client is already the fully loaded client — because the fixed slot runs after client resolution. A public client is the one that requires no secret (RequireClientSecret == false). There is no ordering to arrange and no client to fetch: the single, fixed-position slot hands you everything the built-in validation has assembled, and this is the lightest of the three here. The change is exactly the shape Duende's model is best at — a check that runs once, at a known point, over a fully resolved request. The trade of that fixed slot is its mirror image: you take the request as validation left it, at the one point the model offers, and cannot place your check between two built-in steps.
OpenIddict: fetch the client through the application manager
OpenIddict's extension language is the pipeline: every request walks an ordered chain of handler descriptors — about 270 across the server — and your rule becomes one of them. A rule reading only the request would be barely more than a lambda — AddEventHandler<ValidateAuthorizationRequestContext>(builder => builder.UseInlineHandler(context => { /* ... */ })), no class at all. Reading the client is what turns it into a scoped handler: an inline handler takes no constructor dependency, so resolving the application manager requires a class.
public sealed class ConfidentialClientPaymentsHandler : IOpenIddictServerHandler<ValidateAuthorizationRequestContext>
{
private readonly IOpenIddictApplicationManager _applications;
public ConfidentialClientPaymentsHandler(IOpenIddictApplicationManager applications)
=> _applications = applications;
public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
{
if (!context.Request.HasScope("payments:write"))
return;
var application = await _applications.FindByClientIdAsync(context.ClientId);
if (application is not null &&
await _applications.HasClientTypeAsync(application, ClientTypes.Public))
{
context.Reject(
error: Errors.AccessDenied,
description: "payments:write requires a confidential client.");
}
}
}
Registration adds the handler to the pipeline:
options.AddEventHandler<ValidateAuthorizationRequestContext>(builder =>
builder.UseScopedHandler<ConfidentialClientPaymentsHandler>());
What you had to know is a longer list. Which event context carries authorization validation — ValidateAuthorizationRequestContext. The handler contract and how rejection works. The descriptor builder and the lifetime choice it forces — here a scoped handler, because the rule injects a service. And the application-manager API to fetch the client (FindByClientIdAsync) and classify it (HasClientTypeAsync). No SetOrder is needed — an unordered custom handler runs after the built-in validation by default — so ordering enters only when a rule must sit between two specific built-in steps. What the extra ceremony buys is uniformity: this rule is a descriptor like every other, so the same knowledge that placed it can remove, reorder, or replace any built-in step, and the same handler model runs on OWIN and .NET Framework.
Abblix OIDC Server: resolve the client, stay in protocol terms
Abblix's extension language is the protocol rule itself. The authorization endpoint's built-in validation is a family of single-rule validators — client, redirect URI, flow type, nonce, scopes, PKCE, and so on — each about the size of the requirement it enforces, folded into one composite behind IAuthorizationContextValidator when AddOidcCore runs. Your rule joins that family:
public sealed class ConfidentialClientPaymentsRule(IClientInfoProvider clients)
: IAuthorizationContextValidator
{
public async Task<AuthorizationRequestValidationError?> ValidateAsync(AuthorizationValidationContext context)
{
if (!context.Request.Scope.Contains("payments:write") || context.Request.ClientId is not { } clientId)
return null;
var client = await clients.TryFindClientAsync(clientId);
return client is { ClientType: ClientType.Public }
? context.Error(ErrorCodes.AccessDenied, "payments:write requires a confidential client.")
: null;
}
}
Registration is standard dependency injection, before the composing call:
services.AddSingleton<IAuthorizationContextValidator, ConfidentialClientPaymentsRule>();
services.AddOidcCore(options => { /* ... */ });
The one thing to know, the code above already answers. A member registered before AddOidcCore joins at the front of the family — ahead of the built-in ClientValidator, the step that resolves the client and populates context.ClientInfo. So at that position context.ClientInfo is not yet set, and the rule resolves the client itself, through the very same IClientInfoProvider the built-in validator uses. That keeps the rule's logic in protocol terms — the requested scope, client_id, the client type — with IClientInfoProvider the one library seam it touches. Its cost is one ordering fact — the default slot precedes client resolution — and one extra client lookup: negligible against the in-memory default provider, though a storage-backed provider pays a second resolution unless it caches. If you would rather seat the rule after ClientValidator and read context.ClientInfo directly, the 2.4 Decompose<IAuthorizationContextValidator>() cursor does it in one line:
services.Decompose<IAuthorizationContextValidator>()
.AddAfter<ClientValidator>(
ServiceDescriptor.Singleton<IAuthorizationContextValidator, ConfidentialClientPaymentsRule>());
Both positions side by side — self-resolving at the front on 2.3, or seated after ClientValidator by the 2.4 cursor:
On shipped 2.3 that cursor does not exist; there the two paths are to resolve the client yourself, as above, or to pre-seed the family's order before AddOidcCore — advanced DI work, and the reason the self-resolving form is the one to reach for.
What you had to know: which family owns the behavior; that members fold into a composite at AddOidcCore, so a member registers before that call — one registered after it does not extend the family, because singular resolution then returns only your rule and the built-in composite silently stops running; and that the default front position precedes client resolution, so a client-dependent rule resolves the client itself. The library exposes around 140 public interfaces — about the same surface Duende exposes — and finding the seam among them is real work; the extension points map exists to shorten it.
What that buys is a rule that stays a rule. The class above is the policy, in the same shape as the built-in validators beside it and registered with the DI verbs any ASP.NET Core service uses; the one model to learn is where the rule composes — learned once, then reused for every rule you add — because the composition lives in the container, in a small DI toolkit the rest of the library is built with.
Counting the concepts
The tally turns on the same fact the walks did — where each rule runs relative to the moment the client is resolved:
| Product | The rule becomes | What you learn before it runs |
|---|---|---|
| Duende IdentityServer | A custom validator in the fixed final slot | The validator interface and one builder call; the resolved client is already on the request, so nothing about ordering or fetching it |
| OpenIddict | A scoped handler that fetches the client through the application manager | The event and context model, the descriptor builder and its scoped-handler lifetime, and the application-manager API to fetch and classify the client |
| Abblix OIDC Server | One more member of the endpoint's rule family | Which family owns the behavior; that members compose at AddOidcCore; and that the default slot precedes client resolution, so the rule resolves the client itself (or, from 2.4, sits after ClientValidator via the cursor) |
Three qualifiers keep this table honest. First, it is the client-dependence that separates the products: a rule reading only the requested scopes is trivial in all three — a bare inline handler in OpenIddict, a few lines through ValidatedRequest in Duende, two lines over context.Request.Scope in Abblix — so the comparison that reveals anything is the rule that needs the resolved client. Second, the line counts merely echo the concept counts — Duende the fewest, then Abblix, then OpenIddict — and track the model each makes you hold, not raw difficulty: Duende's fixed final slot is built for exactly this and is genuinely the lightest here, not because its code is objectively simpler but because the client arrives already resolved, which is worth saying plainly. Third, a threshold depends on who is crossing it — a team already fluent in OpenIddict's pipeline, or in Microsoft DI internals, pays less than the concept count suggests.
What a unit of extension is
Underneath the three walks lies the real divide: what each product treats as the unit a developer reasons in.
Duende's unit is the domain service — a named interface owning one area of behavior, coarse enough that a handful of them cover most customization territory, documented well enough that the right name is usually easy to find. OpenIddict's unit is the pipeline step — uniform, position-addressable, small, and deliberately domain-agnostic: the same descriptor machinery carries protocol validation and transport processing alike. Abblix's unit is the domain rule — smaller than a service, but still speaking protocol vocabulary rather than machinery vocabulary.
In Abblix these units nest rather than compete. The hierarchy runs from coarse to fine: each endpoint has a handler that orchestrates it — a dozen endpoint areas, each behind its own handler interface; each handler coordinates services — a validator, a processor, the token and client services they draw on; and the services decompose into features and rules — about thirty feature areas plus the single-rule members of the composed families. An extension enters at whichever level matches its size. Replacing an endpoint's whole behavior means implementing its handler. Owning one behavior — token shaping, client storage, session tracking — means replacing one service. Adding one rule means adding one rule. The other two products layer as well — Duende runs from options through named services to its custom-validator hooks, and OpenIddict's descriptors group into well-defined stages — so the distinction is not that one product has layers and the others do not. It is which unit the product hands you at the moment your requirement steps off the covered path: a service to implement, a pipeline address to compute, or a rule to write.
An architecture shaped like the specification
There is a reason the rule-sized unit fits an OpenID Connect server so naturally: the specification itself is written in rule-sized statements. OpenID Connect Core requires that the redirect URI exactly match one of the values the client pre-registered, and that a nonce, when present, round-trips into the ID token; RFC 7636 requires that PKCE parameters obey the method the client declared. Line those requirements up against the authorization endpoint's validator family — RedirectUriValidator, NonceValidator, PkceValidator — and the code reads like the specification's table of contents: roughly one requirement, one class.
That correspondence is more than aesthetics. When the unit of code matches the unit of the spec, spec evolution tends to arrive as addition rather than rewrite — a new draft's new requirement becomes a new family member, not a deeper branch inside an existing method. It also keeps rules honest about their dependencies: an ordering between two rules (flow type must be known before response modes can be judged) is visible in the family's declared order, not buried in a method's control flow. The price of that correspondence is the surface already noted — many rule-sized units mean many seams to learn your way around, which is the tax the extension points map exists to offset.
Two roads to the same summit
Pull back far enough and only two strategies for extensibility remain. One universalizes the execution mechanism: build a pipeline general enough to host anything, and every conceivable change becomes an insertion, removal, or reordering of steps. That is OpenIddict's road, and it is the same road ASP.NET Core middleware walks. Its strength is reach — the mechanism accommodates concerns the original authors never anticipated, at any depth of the stack. Its cost profile is front-loaded: learn the mechanism once, then every extension looks alike.
The other granularizes the domain model: make the units of behavior so small and so domain-shaped that most changes become additions to a model that already speaks your language. Duende took this road at service granularity; Abblix drives it further down, to the granularity of individual protocol rules, with DI as the composition mechanism. The cost profile here is pay-per-lookup: each change starts by finding the owning seam, and each change stays small once found.
Neither road dominates. A universal mechanism will always reach places a domain model has not mapped; a domain model will always keep more of your reasoning in the protocol you were hired to implement. Which bill is cheaper depends on how many extensions you will write, how unusual they are, and who on the team pays the learning cost.
Choosing by the bill you will pay
Do not choose an OIDC server by asking whether it can be extended — all three can, further than most deployments will ever push. Choose by tracing one concrete policy from your own backlog, the way this article traced one: write down what you would need to learn before the rule runs, in each product, given the team you actually have. The exercise takes an hour and tells you more than any feature matrix.
The broader trade-offs — protocol coverage, storage philosophy, licensing, operational tooling — live in the full comparison. The architecture that makes rule-sized extension possible is described in Understanding the Architecture, the seams themselves are cataloged in the extension points map, and the DI toolkit behind Compose and Decompose has an article of its own. The specification will keep growing either way. The only real question is what each new sentence of it will cost you.
API shapes in this article were verified in July 2026 against the shipped sources of Duende IdentityServer 8.0.2, OpenIddict 7.5.0, and Abblix OIDC Server 2.3 — except the Decompose cursor, which lands with Abblix 2.4.