Table of Contents

Replacing Duende IdentityServer

This server takes Duende IdentityServer 8's public surface — its builder methods, store and service interfaces, models, options and event types — measured against Duende 8.0.1's assemblies, so a Duende host moves by changing its package references and usings. The code on this page is included from samples/Interaction.FromDuende, a host written as a Duende host is, which a test boots, gets a token from and calls the local API with. Where the two servers stand member by member is the Duende parity matrix.

What to change in a Duende 8 host

  1. Remove the Duende NuGet packages.

  2. Add a reference to Fdyay.IdentityServer.AspNetCore — and, for what the host used, Fdyay.IdentityServer.UI (the interactive pages, getting started), Fdyay.IdentityServer.Dapper (the stores in a database, persistence), Fdyay.IdentityServer.Federation and Fdyay.IdentityServer.Saml (external providers), or Fdyay.IdentityServer.AspNetIdentity.

  3. Change the usings: the types are in Duende's namespaces with Fdyay for Duende.

    // Was: using Duende.IdentityServer; using Duende.IdentityServer.Models;
    using Fdyay.IdentityServer;
    using Fdyay.IdentityServer.Models;
    
  4. The builder methods (AddIdentityServer, AddInMemoryClients, and the rest) and the store interfaces match the Duende surface, so most hosts compile without further changes. The sample's configuration and startup are a Duende host's, unchanged but for the usings:

/// <summary>A Duende host's configuration class, unchanged but for its usings.</summary>
public static class Config
{
    /// <summary>The identity resources.</summary>
    public static IEnumerable<IdentityResource> IdentityResources =>
    [
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
    ];

    /// <summary>The API scopes, this server's own local API among them.</summary>
    public static IEnumerable<ApiScope> ApiScopes =>
    [
        new ApiScope("api1", "My API"),
        new ApiScope(IdentityServerConstants.LocalApi.ScopeName),
    ];

    /// <summary>The clients.</summary>
    public static IEnumerable<Client> Clients =>
    [
        new Client
        {
            ClientId = "m2m.client",
            ClientSecrets = { new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) },
            AllowedGrantTypes = GrantTypes.ClientCredentials,
            AllowedScopes = { "api1", IdentityServerConstants.LocalApi.ScopeName },
        },
        new Client
        {
            ClientId = "interactive",
            ClientSecrets = { new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256()) },
            AllowedGrantTypes = GrantTypes.Code,
            RedirectUris = { "https://localhost:44300/signin-oidc" },
            FrontChannelLogoutUri = "https://localhost:44300/signout-oidc",
            PostLogoutRedirectUris = { "https://localhost:44300/signout-callback-oidc" },
            AllowOfflineAccess = true,
            AllowedScopes = { "openid", "profile", "api1" },
        },
    ];
}
// A Duende host's startup: the same builder methods, in the same order.
builder.Services.AddIdentityServerCookieAuthentication();

builder.Services
    .AddIdentityServer(options =>
    {
        options.Events.RaiseErrorEvents = true;
        options.Events.RaiseFailureEvents = true;
        options.Events.RaiseSuccessEvents = true;

        // Automatic key management is on, as in Duende: the server creates its signing key, keeps it in ./keys and
        // rotates it.
    })
    .AddInMemoryIdentityResources(Config.IdentityResources)
    .AddInMemoryApiScopes(Config.ApiScopes)
    .AddInMemoryClients(Config.Clients);

builder.Services.AddSingleton<IEventSink, NameWritingEventSink>();
builder.Services.AddLocalApiAuthentication();

Then check the two sets of renamed values below — both were taken from Duende before 1.0, because a host moving from Duende compares and stores these values, and a value that silently fails to match is worse than a build that stops — and the JSON types of auth_time and amr, which changed in 0.173.0 for every consumer of this server's tokens, and read where this server differs on purpose. The security fixes of 0.176.0 refuse requests that used to succeed.

Two federation fixes (0.179.0)

Found writing the federation gateway guide. Nothing is renamed and a host compiles unchanged; one model property is added.

What Before 0.179.0 From 0.179.0 Duende 8.0.1, measured Who is affected
A SAML service provider's signed redirect-bound AuthnRequest that needs the login page refused on its return (invalid_signature): the kept XML carries no signature, and was checked alone the query it arrived with is kept (SamlSignInState.RedirectQuery) and verified again; answered SingleSignOnCallbackEndpoint does not re-verify; it trusts its stored state Every partner that signs on the redirect binding, which signing being required by default makes most. A host's own ISamlSigninStateStore must keep the new property — one that serialises the whole state does
Signing out of a session from an OpenID Connect provider with no end_session_endpoint (Google) 500 from ASP.NET Core's handler, after the relying parties were told; the session cookie survived the session ends here, a warning names the scheme, the browser goes to the signed-out page its template signs out of any IAuthenticationSignOutHandler scheme and fails the same way A host federating with such a provider through AddExternalOpenIdConnect or a stored OpenID Connect row; a host whose events are an EventsType keeps the old behaviour

Added: SamlSignInState.RedirectQuery.

SAML and external sign-in security fixes (0.178.0)

A security review found that an identity provider could choose which provider's account an external sign-in landed in, and that the SAML service provider believed a response or a logout request in more browsers, more times and in more shapes than SAML 2.0 allows. Each change below refuses something that used to succeed; that is the fix. Nothing is renamed and a host compiles unchanged — one overload and one model property are added.

Changed — what a host, a partner or a browser sees:

What Before 0.178.0 From 0.178.0 Duende 8.0.1, measured Who is affected
External sign-in callback (/Account/ExternalLogin?handler=Callback) with no scheme item the provider taken from the external identity's idp claim, else external refused: UserLoginFailureEvent, external cookie dropped, redirect home its template reads Items["scheme"] only (a missing one throws) A host whose own code challenges a provider and returns to this page without Items["scheme"] — set it, as the page's challenge does
Unsolicited SAML sign-in (AllowIdpInitiatedSignOn) recorded no scheme: linked as the idp attribute said, else under external recorded against the receiving scheme records nothing (its template then throws) A host that relied on unsolicited users being linked under external — they are now linked under the scheme name, so a user provisioned before finds no link and is provisioned again
SAML attributes named sub, idp, amr, auth_time, acr, sid, ClaimTypes.NameIdentifier, .AuthenticationMethod, .AuthenticationInstant, .Actor, or the registration's NameIdClaimType (ignoring case) became claims of that type dropped (logged); NameID and AuthnStatement only passed through by Saml2SecurityTokenHandler.ProcessAttributeStatement (Actor handled specially) A host or IUserSignInService that read one of these from an assertion attribute — map it under another name at the provider
Solicited SAML response without the correlation cookie accepted on RelayState alone refused (correlation_failed) needs its Saml2.{RelayState} cookie A test harness or proxy posting responses itself; a sign-on in flight across the upgrade (started before, finished after) fails once. Over plain http set CorrelationCookie.SecurePolicy to SameAsRequest
The same assertion twice accepted refused (assertion_replayed), in IReplayCache until it would expire an in-process MemoryCache keyed by assertion ID until Conditions/@NotOnOrAfter Nobody legitimate. Several instances share it only with a shared IDistributedCache registered
Bearer assertion without AudienceRestriction, a bearer confirmation, its Recipient or NotOnOrAfter; a response Destination not the reply address each checked only when present, method unread refused (no_audience_restriction, no_bearer_confirmation, no_recipient, no_subject_confirmation_expiry, destination_mismatch) — Ours (security), SAML Profiles §4.1.4.2, Bindings §3.5.5.2 requires the bearer method only; audience optional (RequireAudience false), the rest unread A partner identity provider that omits what the profile requires
SAML response or inbound LogoutRequest longer than SamlOptions.MaxMessageSize at the service provider decoded and inflated without limit refused (message_too_large) — Ours (security) no limit on the service-provider side A partner sending messages over 1 MiB (raise the option)
Inbound LogoutRequest at the service provider: not addressed to SignedOutCallbackPath, past NotOnOrAfter, older than DefaultRequestMaxAge + ClockSkew, seen before ended the session refused with 400 and a SamlLogoutFailureEvent — Ours (security), SAML Core §3.7.1, Bindings §3.4.5.2 none of these checked A partner that sends signed logout requests without Destination, or late
Inbound LogoutRequest naming a subject (or provider) other than the session this browser holds ended that session session kept; answered Requester/UnknownPrincipal — Ours (security), SAML Core §3.7.3.2 ends the session Nobody legitimate
Redirect-bound query carrying both SAMLRequest and SAMLResponse, or a signed parameter twice (IdP single sign-on and logout, SP logout) signature checked over the first message and first copies does not verify; the endpoint refuses as unsigned or badly signed refused (HttpRedirectBinding.ParseQueryString) Nobody legitimate
A refusal at a service provider in a host with no IEventService an exception (500) the refusal — A host using AddSaml2 without an identity server: it now sees the handler's remote failure instead

Added: SamlRedirectSignature.Read(string? rawQueryString, string messageParameter); SamlAssertion.SubjectConfirmationMethod; SamlConstants.StatusCodes.UnknownPrincipal. AddSaml2 registers IReplayCache (DefaultReplayCache) and the distributed memory cache with TryAdd, so a host's own stand.

Corrected: SamlSpOptions.AllowIdpInitiatedSignOn, SamlSpOptions.IdpInitiatedReturnPath and SamlOptions.EnableIdpInitiatedSignOn were documented as inert; all three have been read since 0.59.0.

Security fixes to authorize, request objects, the error store, sign-out and stored rows (0.177.0)

Found by a security review of the whole repository and fixed in one release. Each refuses or changes something that used to succeed; nothing is renamed and every public signature is kept — new members are overloads and constants. Where Duende 8.0.1 does the same as the fix, it was measured; where this server is now stricter, the specification is named.

Behaviour changed:

What Before 0.177.0 From 0.177.0 Who is affected
A request object fetched by request_uri (Endpoints.EnableJwtRequestUri), on its return from the reference this server stores it under validated as a pushed request, so PushedAuthorization.AllowUnregisteredPushedRedirectUris let an unregistered redirect_uri from the link through validated as the front-channel request it was (AuthorizeRequestType.Authorize, as Duende's — which fetches again on every pass — validates it); its redirect_uri must be registered; ValidatedAuthorizeRequest.PushedAuthorizationReferenceValue is null for it A client relying on an unregistered redirect URI with a request object by reference: it pushes instead (PAR), or registers the URI. A custom validator that read the reference value as "this was fetched"
A JAR request_uri naming an unknown or disabled client fetched, then refused refused unauthorized_client, nothing fetched (Duende loads the client first) Nobody legitimate; a monitor that counted those fetches
A JAR request_uri longer than 512 characters fetched refused invalid_request_uri, nothing fetched (Duende's bound) A client publishing its object at a longer URL
A return URL carrying a JAR request_uri (sign-in, consent, GetAuthorizationContextAsync) the URL fetched and the request described not resumed: the context is null, nothing fetched. This server never writes such a return URL — it stores the object behind its own reference first A host that built its own return URLs with a client's request_uri in them
An authorize refusal shown on the error page ErrorMessage.RequestedClientId and RedirectUri stored whatever their length; the failure event carried the same each kept only within InputLengths.ClientId / RedirectUri, else null, in the store and on TokenIssuedFailureEvent.ClientId; the log still records it, truncated An error page or event sink that showed an over-long value
ErrorMessage.RequestedClientId and RedirectUri, whoever writes them (SAML endpoints, a host) kept as given null when longer than ErrorMessage.MaxValueLength (2,048) A host that writes longer values into its own error messages
InMemoryMessageStore<TModel>, the default error and consent store every message kept for its lifetime; each write walked every message held at most DefaultCapacity (10,000) messages, the oldest dropped; each write sweeps only expired ones; a constructor with capacity, and Count An instance taking more than 10,000 errors or consent decisions within a message's lifetime (five minutes): a dropped error shows the generic page, a dropped decision asks the user again. It registers a larger capacity or AddProtectedDataMessageStores()
The logout page with a logoutId that describes another session (another subject, or another session id) that logout id decided which clients were notified (back- and front-channel) and where the user was returned set aside for one of the signed-in session's own; its client return goes with it A link that carried a logout id across sessions. Ours: Duende's template uses the link's
DefaultSignOutService.SignOutAsync with a logout id that describes another session, or names no clients the logout id's session's clients were notified, or nobody the ending session's own clients are notified A host page that passes a stale or foreign logout id
Dapper BackChannelAuthenticationRequests AuthenticationRequestId the auth_req_id itself; Data plain JSON with the id, the notification token and the approving user's claims the key sha256: + Base64Url of the id's SHA-256; Data through IGrantDataProtector (as Duende's ciba grant) Anything reading the table directly. Rows written before are still read
Dapper DeviceFlowCodes.Data, PushedAuthorizationRequests.Parameters plain JSON through IGrantDataProtector, under PersistentGrants.DataProtectData (Duende protects both) Anything reading the tables directly; a host with only AddIdentityServerCore() and no AddDataProtection() (a write now says so); a rollback to an earlier version loses the requests in flight
The interactive pages' Content-Security-Policy form-action 'self' no form-action, as Duende's templates Fixes Chrome and Safari stopping the sign-in and consent posts at the redirect to the client's origin; a host that added content to these pages relying on the directive

Not changed, measured and recorded: a parameter a request object does not carry is still read from the query string — OpenID Connect Core 1.0 §6.3.3's rule and Duende's merge; RFC 9101 §6.3's stricter rule would break clients written to OpenID Connect Core. Device code entry still counts no wrong guesses, as Duende's templates; the default code is twelve readable characters, and a host choosing Numeric rate-limits the device page (approving a device).

Security fixes: tokens and grants (0.176.0)

A security review found five weaknesses in how tokens are validated and grants redeemed; 0.176.0 fixes them. Each refuses a request that used to succeed — that is the fix — and where Duende 8.0.1 behaves the same, measured from its assembly, a host moving from Duende sees no difference. Nothing is renamed and a host compiles unchanged. The new interface members have default bodies, and TokenEndpoint keeps its constructor beside the new one.

Behaviour changed:

What Before 0.176.0 From 0.176.0 Duende 8.0.1 Who is affected
An identity token or a back-channel logout token presented as an access token — at userinfo, introspection, a local API, token exchange, or to ITokenValidator accepted: no typ was read refused, invalid_token: the typ must be AccessTokenJwtType; where that is empty, JWT or the logout token's type, the JWT must name client_id and carry no nonce, azp or events the typ check; with such a type, no client_id check A host with AccessTokenJwtType JWT or empty that presents a JWT it minted itself (IIdentityServerTools.IssueJwtAsync) without client_id: add one. More
Token exchange's subject_token and actor_token any JWT ITokenValidator accepted also refused, invalid_grant, without client_id and scope ships no token exchange A host exchanging a JWT it minted without those claims. More
A code_verifier sent for a code issued without a code_challenge accepted invalid_grant, "Unexpected code_verifier." invalid_grant (no description) A client with RequirePkce = false that sends a verifier but no challenge: send both, or neither. More
A code issued without a challenge, redeemed by a client with RequirePkce accepted invalid_grant, "Missing code_challenge." invalid_grant (no description) Only a code stored before its client was switched to RequirePkce; the authorize endpoint never issues one
A code or refresh token carrying a scope the client may no longer have — withdrawn from AllowedScopes, a disabled or deleted resource, offline_access with AllowOfflineAccess off redeemed, the withdrawn scope issued again invalid_scope, "Invalid scope." the same A user whose client lost a scope since sign-in: their next refresh is refused and the client signs them in again. More
Two requests redeeming one code, or one OneTimeOnly refresh token, at the same moment both could be answered one is; the other is invalid_grant — for a refresh token, met as a replay (grace period, else the lineage revoked and RefreshTokenReplayDetectedEvent) both could be answered A client racing itself with one refresh token, as a replay after it was already treated; ConsumedRefreshTokenGracePeriod is for that. More
A private_key_jwt assertion typed client-authentication+jwt any of its audiences the issuer or the URL it was sent to exactly one audience, the issuer the same A client that types its assertion so and addresses the endpoint URL, or several audiences. More

Added:

Member Default What it does
IdentityServerOptions.StrictClientAssertionAudienceValidation false (Duende's) Holds every private_key_jwt assertion to the strict rule: typed client-authentication+jwt, one audience, the issuer
IPersistedGrantStore.TryRemoveAsync, TryConsumeAsync read, then write — the old two steps Take a one-time grant in one step; the in-memory and Dapper stores implement both
IAuthorizationCodeStore.TryRemoveAuthorizationCodeAsync; IRefreshTokenStore.TryConsumeRefreshTokenAsync, TryRemoveRefreshTokenAsync remove or consume, and answer true Take a code or refresh token through the grant store's members
IRefreshTokenService.TryRedeemAsync; a TokenEndpoint constructor taking it answers true Takes a refresh token for the request the token endpoint is about to answer

Security — stores and services of your own. The default bodies keep a host's own IPersistedGrantStore, IAuthorizationCodeStore, IRefreshTokenStore or IRefreshTokenService compiling and behaving as before 0.176.0, which means as open to the concurrent-redemption race. Implement the new members in one step of your database — a conditional delete or update that reports the rows it changed — to close it.

auth_time a number and amr an array (0.173.0)

OpenID Connect Core 1.0 §2 defines auth_time as a JSON number and amr as a JSON array of strings, and Duende 8.0.1 writes them so, measured from its assembly: DefaultClaimsService.GetStandardSubjectClaims re-creates auth_time as XMLSchema#integer64 whatever the signed-in user's claim says, and TokenExtensions.CreateJwtPayloadDictionary writes amr as a string[] of its distinct values, one or several. This server wrote both as the claims came: the recorded protocol flows showed "auth_time": "1793660261" and "amr": "pwd" in every token about a user. Decided on 2026-11-03 as a recorded breaking change before 1.0. Nothing is renamed and a host compiles unchanged; what changes is the JSON that relying parties, APIs and introspecting callers receive.

Values changed:

What Before 0.173.0 From 0.173.0 (OpenID Connect's, Duende's) Who is affected
auth_time in an identity token and an access token a JSON string whenever the sign-in's claim was typed string — every sign-in through IdentityServerUser (the shipped UI's included), and every grant stored by an earlier version: "auth_time": "1793660261" a JSON number: "auth_time": 1793660261 A relying party or API that reads the raw payload and takes auth_time as a string — GetString(), a string property on a deserialized type, a JSON schema, a script. A consumer that reads it through JsonWebTokenHandler, JwtSecurityTokenHandler or the ASP.NET Core handlers gets a claim with the same value, now with an integer value type
amr in an identity token and an access token a JSON string for one method ("amr": "pwd"), an array for several always a JSON array of strings, each method once: "amr": ["pwd"] A consumer that reads one method as a string — GetString(), a string property, a policy or script comparing amr with "pwd". Through the token handlers above it is one amr claim per method, as before
auth_time and amr at connect/userinfo (when the profile service releases them) and at connect/introspect (and in its signed token_introspection response) as the claims' value types and count made them as in the token The same, for a caller of either endpoint

Stored grants need no migration. A refresh token, reference token, authorization code, device code or CIBA request stored by an earlier version holds auth_time typed string; whatever it issues or answers from now on carries a number, because auth_time is written as a number by name whenever its value is an integer — the access token a refresh hands back as it was kept, too. An access token issued before the upgrade still carries the old shapes until it expires; a consumer that must not fail in that window reads either.

What a consumer must do:

  1. Read auth_time as a number of seconds since the epoch.
  2. Read amr as an array of strings, and test for a method by membership (amr contains "mfa"), never by equality.

A host has nothing to change. Its profile service's own claims are written as their value types say, as before; the profile service is still not the source of a token's auth_time or amr, which come from the signed-in user.

Not changed, measured: the logout token (it carries neither); SAML, whose AuthnInstant is the assertion's time and whose authentication context is still mapped from each amr; the token that token exchange issues, which carries the subject (and act) and neither claim, as before; and the principal IdentityServerUser.CreatePrincipal builds, whose auth_time is still typed string, as Duende's is — the claims service types it.

Ours, recorded: an auth_time that is not an integer is copied and written as its text, and the token is issued, where Duende's long.Parse throws and fails the token request. auth_time is written as a number by its name, in the token, at userinfo and at introspection, so a claim typed string by an earlier version or by the session is a number too, where Duende's writers go by value type alone. Userinfo and introspection answer one amr as an array, where Duende's ToClaimsDictionary answers a string.

Duende's endpoint names, policy name and enum values (0.165.0)

Duende 8.0.1's, measured from its assembly's metadata: plan row 41 of the surface re-measure, found by the final re-measure and decided on 2026-10-15 ("adopt Duende's values"). A host built against 0.164.0 or earlier stops compiling where it names a renamed member; nothing is kept under the old name. These are constants, compiled into the caller: a library built against 0.164.0 keeps the old strings and numbers until it is rebuilt.

Members renamed to Duende's (Core, IdentityServerConstants.EndpointNames); their values change too, below:

Before 0.165.0 From 0.165.0 (Duende's)
EndpointNames.DiscoveryKeys EndpointNames.Jwks
EndpointNames.SamlSingleSignOn EndpointNames.SamlSingleSignOnService
EndpointNames.SamlSingleLogout EndpointNames.SamlSingleLogoutService

Values changed:

What Before 0.165.0 From 0.165.0 (Duende's) Who is affected
EndpointNames.Jwks "Discovery.Jwks" "Jwks" A dashboard, alert or trace query on the telemetry fdyay.endpoint tag or the span name; code that reads MatchedEndpoint.Of or compares an Endpoint.Name; a host's own Endpoint registered under one of these names, which EndpointsOptions now switches
EndpointNames.UserInfo "UserInfo" "Userinfo" the same
EndpointNames.EndSession "EndSession" "Endsession" the same
EndpointNames.CheckSession "CheckSession" "Checksession" the same
EndpointNames.SamlSingleSignOnService "Saml.Sso" "Saml2 SingleSignOnService" the same (SAML package)
EndpointNames.SamlSingleSignOnCallback "Saml.SsoCallback" "Saml2 SingleSignOnCallback" the same
EndpointNames.SamlSingleLogoutService "Saml.Slo" "Saml2 SingleLogoutService" the same
EndpointNames.SamlMetadata "Saml.Metadata" "SamlMetadata" the same
LocalApi.PolicyName — the policy AddLocalApiAuthentication adds "IdentityServerApi" (the scope's name) "IdentityServerAccessToken" (the scheme's name, as Duende's) An endpoint that requires the policy by the literal "IdentityServerApi" — it now fails at the first request with no policy found; RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName) needs only a rebuild. The scope is still IdentityServerApi, and the scheme was already IdentityServerAccessToken
ParsedSecretTypes.JwtBearer — a client assertion's ParsedSecret.Type "JwtBearer" "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" (OidcConstants.ClientAssertionTypes.JwtBearer) A custom ISecretParser or ISecretValidator that writes or compares the literal; an event sink, filter or query on ClientAuthenticationSuccessEvent.AuthenticationMethod (and ApiAuthenticationSuccessEvent's) for a private_key_jwt client
EventTypes Success 0, Failure 1, Information 2, Error 3 Success 1, Failure 2, Information 3, Error 4 A host's own sink that writes Event.EventType as a number (System.Text.Json without a string converter, a database column, a queue message) — rows written before the upgrade read one type off; code that casts the type to or from int
CspLevel (CspOptions.Level) One 1, Two 2 One 0, Two 1 Configuration that sets the level as a number: "1" was One and is now Two; "2" is no longer a defined level (it still behaves as Two); "0" is now One. A level set by name ("One", "Two") or in code (CspLevel.One) binds as before

Nothing this server stores holds one of these values, so nothing here needs migrating. Measured in this tree: the Dapper schema has no column for any of them and stores no events; grants, keys, sessions, server-side sessions and pushed requests do not record an endpoint name, a parsed secret's type, an event type or a CSP level, and a client stored as JSON carries none. What this server emits names them by name, which did not change: Event.ToString() writes enums by name, DefaultEventSink logs the type's name, and the event counter's type tag is the name. The numbers change only for a sink or a configuration a host wrote itself.

What a host must do:

  1. Rename DiscoveryKeys, SamlSingleSignOn and SamlSingleLogout to Jwks, SamlSingleSignOnService and SamlSingleLogoutService, and rebuild everything that references the constants — a library compiled against 0.164.0 carries the old values.

  2. Move a dashboard, alert or trace query keyed on Discovery.Jwks, UserInfo, EndSession, CheckSession or the Saml. names to the right-hand column. No URL moves: every route is where it was.

  3. Require the local API policy through IdentityServerConstants.LocalApi.PolicyName, or by "IdentityServerAccessToken"; a literal "IdentityServerApi" names a policy that is no longer added. Clients and API scopes keep IdentityServerApi.

    // The policy AddLocalApiAuthentication adds is "IdentityServerAccessToken", Duende's — name it by the constant.
    // (Before 0.165.0 it was "IdentityServerApi", the scope's name, which is still the scope a token must carry.)
    app.MapGet("/localapi/whoami", (ClaimsPrincipal user) => Results.Ok(new { client = user.FindFirst("client_id")?.Value }))
        .RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName);
    
  4. Compare a client assertion's type with IdentityServerConstants.ParsedSecretTypes.JwtBearer, not "JwtBearer", in code and in event queries.

  5. A sink that stored EventType as a number: add one to the rows written before the upgrade, or write the name.

    /// <summary>
    /// A sink that stores each event's type by name. Duende's numbers are 1 to 4; this server used 0 to 3 until 0.165.0,
    /// so a column holding the number reads one type off across that upgrade, and a name never does.
    /// </summary>
    public sealed partial class NameWritingEventSink(ILogger<NameWritingEventSink> logger) : IEventSink
    {
        /// <summary>Takes one event.</summary>
        public Task PersistAsync(Event evt, CancellationToken cancellationToken = default)
        {
            ArgumentNullException.ThrowIfNull(evt);
    
            // EventIds are this server's numbers, not Duende's: key queries on Event.Name or on the EventIds constants.
            LogEvent(logger, evt.Name, evt.EventType, evt.Id);
            return Task.CompletedTask;
        }
    
        // The formatted message writes an enum by its name.
        [LoggerMessage(Level = LogLevel.Information, Message = "{EventName} ({EventType}, {EventId})")]
        private static partial void LogEvent(ILogger logger, string eventName, EventTypes eventType, int eventId);
    }
    
  6. A Csp:Level written as a number: write "One" or "Two".

Not changed, measured: the routes (Duende's are the same: .well-known/openid-configuration/jwks, connect/userinfo, connect/endsession, connect/checksession); LocalApi.AuthenticationScheme and LocalApi.ScopeName, already Duende's; EventIds, this server's numbers by their own rule (Duende numbers sixteen of the shared events differently — see the events row of the matrix, and events); and EndpointNames.SamlIdpInitiatedSignOn ("Saml.IdpInitiatedSso"), this server's own door, which Duende has no endpoint and so no name for.

Ours, recorded: the key set still follows EnableDiscoveryEndpoint, as decided with the endpoint switches (#43, matrix §1b), where Duende's IsEndpointEnabled has no case for Jwks — its key set is served whatever the switches say and answers 404 only when DiscoveryOptions.ShowKeySet is off; taking Duende's would start serving keys a host had switched off, a change no row records. CspLevel stays in Fdyay.IdentityServer.Configuration, where Duende's is in Duende.IdentityServer.Models.

Duende's names and stored values (0.157.0)

Duende 8.0.1's, measured from its assembly: decisions 3, 4 and 7 of the surface re-measure, taken before 1.0 for the same reason; rows 35 and 36, found doing it, were decided the same way on 2026-10-14. A host built against 0.156.0 or earlier stops compiling where it names a renamed type or member; nothing is kept under the old name.

Renamed — eight CIBA types to Duende's Backchannel, two SAML types to Duende's Signin; namespaces unchanged:

Before 0.157.0 From 0.157.0 (Duende's) Package
IBackChannelAuthenticationInteractionService IBackchannelAuthenticationInteractionService AspNetCore
IBackChannelAuthenticationThrottlingService IBackchannelAuthenticationThrottlingService Core
DistributedBackChannelAuthenticationThrottlingService DistributedBackchannelAuthenticationThrottlingService Core
IBackChannelAuthenticationUserNotificationService IBackchannelAuthenticationUserNotificationService Core
IBackChannelAuthenticationUserValidator IBackchannelAuthenticationUserValidator Core
BackChannelAuthenticationUserValidationResult BackchannelAuthenticationUserValidationResult Core
BackChannelAuthenticationSuccessEvent BackchannelAuthenticationSuccessEvent Core
BackChannelAuthenticationFailureEvent BackchannelAuthenticationFailureEvent Core
ISamlSignInStateStore ISamlSigninStateStore Saml
InMemorySamlSignInStateStore InMemorySamlSigninStateStore Saml

Members renamed to Duende's Backchannel (row 35):

Before 0.157.0 From 0.157.0 (Duende's) Package
EndpointsOptions.EnableBackChannelAuthenticationEndpoint EndpointsOptions.EnableBackchannelAuthenticationEndpoint Core
EventIds.BackChannelAuthenticationSuccess (5010) EventIds.BackchannelAuthenticationSuccess (still 5010) Core
EventIds.BackChannelAuthenticationFailure (5011) EventIds.BackchannelAuthenticationFailure (still 5011) Core
ProtocolRoutePaths.BackChannelAuthentication ("connect/ciba") ProtocolRoutePaths.BackchannelAuthentication (still "connect/ciba") AspNetCore
IdentityServerConstants.EndpointNames.BackChannelAuthentication IdentityServerConstants.EndpointNames.BackchannelAuthentication — its value changes too, below Core

Values changed:

What Before 0.157.0 From 0.157.0 (Duende's)
IdentityServerConstants.ProtocolTypes.Saml, and with it ErrorMessage.Protocols.Saml and a SAML provider's IConnectedApplication.ProtocolType "saml" "saml2p"; ProtocolTypes.Saml2p, Duende's name, added with the same value
Category of the CIBA events — BackchannelAuthenticationSuccessEvent, …FailureEvent and this server's BackChannelAuthenticationNotificationFailureEvent "Device" (EventCategories.Device) "BackchannelAuthentication" (EventCategories.BackchannelAuthentication, new)
IdentityServerConstants.EndpointNames.BackchannelAuthentication — the CIBA endpoint's name, carried by the telemetry endpoint tag, the request's span name and MatchedEndpoint.Of "BackChannelAuthentication" "BackchannelAuthentication"
A claim whose value type is http://www.w3.org/2001/XMLSchema#double (ClaimValueTypes.Double) — in a JWT, the userinfo response or an introspection response a string a JSON number, as Duende's AddObject and GetValue write it; read with the invariant culture, Float and thousands separators ("1,000.25" is 1000.25); text that does not parse, NaN and infinities stay a string
A claim whose value type is "json", in any case (IdentityServerConstants.ClaimValueTypes.Json, new) — in a JWT, the userinfo response or an introspection response a string holding the JSON (only IdentityModel's "JSON" was read) the JSON it holds, as Duende's AddObject and GetValue write it; text that is not JSON stays a string

What a host must do:

  1. Rename its references to the ten types — a find-and-replace of the left column; an implementation of one of the interfaces needs nothing else.
  2. Compare a protocol type with the constants, not with "saml": code, configuration or a query that holds the literal must say "saml2p". Stored values need no migration. An error message stored by an earlier version — in its message store, or carried in the error link itself by ProtectedDataMessageStore — whose Protocol is "saml" is read as "saml2p" (only that exact value; anything else is kept as written), so the error page still labels it SAML. A client stored with ProtocolType "saml" was refused at every endpoint and still is, as one stored with "saml2p" is: only oidc is served. Nothing else this server persists holds a protocol type: the Dapper schema has no such column (clients are stored as JSON, read as above), and SAML sessions, grants and server-side sessions do not record one. The dynamic identity provider kind "saml" (SamlProvider, IdentityProvider.Type) is a different value, Duende's too, and is unchanged.
  3. Move an event filter, alert or query on CIBA events from category Device to BackchannelAuthentication. Their names and ids (5010–5012, this server's) are unchanged, so a filter on either needs nothing.
  4. Check a profile service that marks a claim "json" or ClaimValueTypes.Double: its value now reaches tokens and responses as JSON or a number. One that should stay a string should say ClaimValueTypes.String.
  5. Rename EnableBackChannelAuthenticationEndpoint and the other four members in code (configuration binding ignores case, so an appsettings.json key Endpoints:EnableBackChannelAuthenticationEndpoint still binds), and move a dashboard, alert or trace query keyed on the endpoint BackChannelAuthentication to BackchannelAuthentication. The URL does not move: Duende's route is connect/ciba too, so discovery's backchannel_authentication_endpoint and every client are unaffected. Event ids are unchanged.

Not renamed, measured: the back-channel logout types (IBackChannelLogoutService and the rest), which Duende spells BackChannel too, as it does BackChannelAuthenticationRequest and IBackChannelAuthenticationRequestStore; the defaults that drop Duende's Default prefix (decision 4: a naming convention, not something a host types), so the CIBA interaction service stays BackChannelAuthenticationInteractionService beside its renamed interface; this server's own CIBA and SAML sign-in types, which Duende does not have (BackChannelAuthenticationRequestContext, BackChannelAuthenticationUserValidationContext, InMemoryBackChannelAuthenticationThrottlingService, the notification HTTP client, BackChannelAuthenticationNotificationFailureEvent and its id EventIds.BackChannelAuthenticationNotificationFailure, SamlSignInState, ISamlSignInDenialService), and DapperStores.BackChannelAuthentication.

Ours, recorded: this server's own JSON claims (cnf, act, token_introspection, the logout token's events) keep IdentityModel's "JSON", which reads the same; "JSON_ARRAY" is read as JSON too, which Duende's is not; JSON that does not parse is written as text in a token, where Duende's AddObject throws (its GetValue, behind userinfo, falls back to the text, as this server does everywhere). The same for a double: Duende's AddObject throws on text Double.Parse refuses, failing the token, where here it is written as text, as an integer or boolean that does not parse has been since 0.111.0; Duende's GetValue reads by the current culture, where this server reads invariantly, as AddObject does; and NaN and infinities, which parse but are not JSON numbers, stay text, where Duende's hand the serializer a value JSON cannot hold.

Where this server differs on purpose

Each guide records, beside what it takes from Duende, what it does differently and why. The differences a moving host is most likely to meet:

  • No event carries a token, only its fingerprint, and there is no option that puts one back; UnhandledExceptionEvent carries no stack trace unless a host asks; EventIds are this server's numbers (events).
  • A custom response member never replaces one of the server's own — at the token endpoint, a custom access_token is dropped where Duende writes the name twice; at the authorize endpoint, a custom parameter cannot replace code, state or iss (your own checks, the response generator).
  • The checks stricter than Duende's cannot be overridden away: the step-runner methods of the opened defaults are not virtual, and a token is read back before it leaves (overriding one step).
  • Only the authorization code flow at the authorize endpoint; Duende's implicit and hybrid steps are non-goals.
  • A page configured as an absolute URL is handed an absolute return URL only under AllowOriginInReturnUrl (return URLs).
  • The default user code is twelve readable characters, not Duende's nine digits; a client opts into Numeric (consent).
  • A OneTimeOnly refresh token is kept as consumed, not deleted, so a replay is detected and its lineage revoked — PersistentGrants.DeleteOneTimeOnlyRefreshTokensOnUse is off here and on in Duende (the options).
  • A token request naming no scope asks for AllowedScopes as registered unless TokenRequestDefaultScopesFromResourceStore takes Duende's rules (a token request that names no scope).
  • The session id goes only to a client registered for it in back-channel logout, and aud is an array even for one audience.

The parity matrix has the rest, member by member, with what is absent and why.