Sharing Clients in Duende IdentityServer: Security Risks

Years ago I built authorization on the open-source IdentityServer4 (I wrote about that here). Its successor, Duende IdentityServer, is a commercial product, and its pricing depends on how many clients you configure. For a company that ships its software for customers to install on their own servers, instead of running it as SaaS, there’s also a separate redistribution license. When we priced it, the business redistribution edition cost about $10,000 a year and covered 5 clients, and unlimited clients cost about $35,000 a year. Prices and editions change, so check the current ones. If you go over your limit, IdentityServer writes a message like this to the log:

Your license for Duende IdentityServer only permits 5 number of clients. You have processed requests for 6. The clients used were: ["client6", "client2", "client5", "client4", "client3", "client1"].

We had about 27 applications. Five clients weren’t enough, and the unlimited plan was hard to justify, so the obvious question came up: could several applications share one client and keep us under the limit? This post is my answer. The short version is no, not if you can avoid it. If you can’t avoid it, share only between applications of the same type and the same trust level, and be honest about the risks you’re accepting. The rest of the post explains why, one client setting at a time.

What a client actually is

In OAuth 2.0 and OpenID Connect, a client isn’t a licensing unit. It’s the identity of an application. The identity server uses it to decide which redirect URIs are allowed, which scopes the application may request, how it proves who it is, how long its tokens last, what the user sees on the consent screen, and what appears in the audit log. When two applications share a client, the identity server can no longer tell them apart. Every risk below follows from that one fact.

What goes wrong when applications share a client

  • One secret in many places. A confidential client authenticates with a secret, or better, a signed JWT (private_key_jwt). If 10 applications share the client, the secret lives in 10 deployments, and a leak from the weakest one lets an attacker impersonate all of them. Rotating the secret means redeploying every one of those applications at the same time.
  • Every redirect URI is valid for every application. The client’s list of allowed redirect URIs becomes the union of all the applications’ URIs. If any one of those sites is compromised, or has an open redirect, an attacker can capture authorization codes that are valid for the whole client. The same goes for post-logout redirect URIs and allowed CORS origins.
  • Every application gets every scope. The client’s allowed scopes have to cover what all the applications need, so any of them can request any of those scopes. A reporting app that only needs read access can ask for the same write scopes as the admin app. That breaks least privilege, and nothing in the identity server can stop it.
  • Tokens work in the wrong application. An ID token’s audience is the client ID, and access tokens carry the client_id claim. If App A and App B share a client, a token issued to A passes B’s validation. A token stolen from the less secure application then works against the more sensitive one.
  • Coarser consent and revocation. Consent and persisted grants are stored per client. Users see one name on the consent screen and agree to the combined scopes. When a user, or an administrator, revokes access, it’s revoked for every application that shares the client. You can’t revoke one application on its own.
  • Logs you can’t use. Every sign-in, token request and error is logged under the same client ID. When something looks wrong in an audit, or a single application starts failing, you can’t tell from the identity server which application it was.
  • One set of settings for everyone. Token lifetimes, refresh token behavior, PKCE and secret requirements are configured per client. A shared client forces one value onto applications that need different ones. The next section goes through that setting by setting.

The client settings, one by one

These are the settings most teams end up storing in IdentityServer’s Clients table. Many of them have the same value for every application, and those are fine to share. The ones that differ are where a shared client causes trouble.

RequireClientSecret

This setting decides whether the client must authenticate when it calls the token endpoint. Server-side web applications and background services are confidential clients. They can keep a secret, so this should be true. Single-page apps and mobile or desktop apps are public clients. Anything shipped to a browser or a device can be extracted, so they can’t keep a secret, and this has to be false. One client can’t be both. Mixing a server application and a SPA in one client either breaks the SPA, or forces the server application to give up authenticating itself.

On the identity server, IdentityServer stores secrets as hashes (new Secret("...".Sha256())). The applications need the secret itself, so keep it in a secret store such as Azure Key Vault, not in appsettings.json. And wherever you can, use private_key_jwt instead of a shared secret.

RequirePkce

PKCE (Proof Key for Code Exchange, RFC 7636) protects the authorization code from being intercepted and used by someone else. For each sign-in, the client generates a random code_verifier and sends a hash of it, the code_challenge, with the authorization request. When it exchanges the code for tokens, it sends the original verifier, and the server checks that it matches the hash. Nothing is encrypted, and no PKCE secret exists to share. A new verifier is created for every sign-in.

So RequirePkce is one of the few settings that’s safe to share, as long as it’s true. That’s Duende’s default, and RFC 9700, the OAuth 2.0 Security Best Current Practice published in 2025, recommends PKCE for every client, confidential ones included. If one of your applications can’t do PKCE, fix that application. Don’t turn PKCE off for everyone who shares its client.

AllowAccessTokensViaBrowser

This setting controls whether IdentityServer may return access tokens directly from the authorize endpoint, in the browser’s URL. That only happens in the old implicit and hybrid flows. Older guidance, and an earlier version of this post, said SPAs need it, but they don’t anymore. The implicit flow is deprecated, and OAuth 2.1 removes it entirely. A SPA uses the authorization code flow with PKCE and gets its tokens from the token endpoint. Better still, it uses the backend-for-frontend (BFF) pattern: a small server-side component holds the tokens and gives the browser an HttpOnly cookie, so no token ever reaches JavaScript. Duende ships a BFF library for exactly this.

Mobile and desktop applications should open the system browser for sign-in, not an embedded web view, and come back to the app through a redirect, as RFC 8252 describes. Then they use the code flow with PKCE as well. For new clients, this setting should be false everywhere. If a shared client needs it for one legacy application, every application on that client is exposed to tokens leaking through browser history and Referer headers.

AllowOfflineAccess, RefreshTokenUsage and RefreshTokenExpiration

AllowOfflineAccess lets the client request the offline_access scope and receive a refresh token. A refresh token lets the application get new access tokens without the user signing in again, including while the user isn’t using the app. That’s what a background sync job or a mobile app that keeps you signed in for weeks needs. It’s also the most valuable thing an attacker can steal, because it keeps working long after the access token it came with has expired.

The decision depends on the application. A server-side app that only calls APIs while the user is signed in doesn’t need refresh tokens. A mobile app usually does. If they share a client, both get refresh tokens.

RefreshTokenUsage decides whether a refresh token can be used once (OneTimeOnly) or many times (ReUse). One-time use means IdentityServer issues a new refresh token with every refresh, which is called rotation. Rotation lets the server detect a stolen token, because the attacker and the real application end up presenting the same token. RFC 9700 requires rotation, or sender-constrained tokens, for public clients. Reusable tokens are simpler, and they’re acceptable for a confidential client that stores its tokens securely.

If you’re reading raw values in the database, check the enum in your IdentityServer version instead of guessing. In the versions I’ve worked with, TokenUsage is ReUse = 0 and OneTimeOnly = 1, which is the reverse of what you might expect. RefreshTokenExpiration chooses between an absolute lifetime and a sliding window that’s extended with each use, capped by the absolute lifetime.

IdentityTokenLifetime, AccessTokenLifetime and AuthorizationCodeLifetime

There are three different tokens here, with different jobs:

  • The identity token tells the client who the user is. Its audience is the client, and it’s only checked once, at sign-in, so its lifetime matters very little afterwards. Never send an ID token to an API.
  • The access token is what the client sends to an API. Its audience is the API, and its lifetime is the window an attacker has if they steal one, since a self-contained JWT can’t be revoked before it expires.
  • The authorization code is the short-lived value the client exchanges for tokens. It should live seconds to minutes and be used only once.

Duende’s defaults are 5 minutes for identity tokens, 1 hour for access tokens and 5 minutes for authorization codes. Two applications having the same number here isn’t a vulnerability in itself. The problem is that a shared client forces one number on all of them. An admin application that should use 10-minute access tokens and a field app that needs longer ones to cope with poor connectivity will both get whatever the most demanding application insists on, and that’s usually the longer value.

RequireConsent, AllowRememberConsent and ConsentLifetime

These settings control whether users see a consent screen, whether their choice is remembered, and for how long. First-party applications usually skip consent, while third-party ones should always show it. On a shared client, the consent screen shows one client name and the combined scopes. That means a user is asked to approve permissions that the application in front of them doesn’t actually use, which is misleading and may matter for compliance under privacy rules such as the GDPR.

AlwaysIncludeUserClaimsInIdToken

By default, IdentityServer keeps the ID token small, and the client fetches the user’s profile claims from the userinfo endpoint. Turning this setting on puts all the user’s claims in the ID token. That makes the token bigger, and it exposes every claim wherever the token goes, including browser storage and logs. If one application needs it, every application on a shared client gets those larger ID tokens.

Client claims and AlwaysSendClientClaims

Client claims are claims you configure on the client in IdentityServer, for example tenant or app_tier. IdentityServer adds them to the tokens it issues to that client, prefixed with client_ by default. Clients don’t send them to IdentityServer. Normally they’re only included in the client credentials flow, and AlwaysSendClientClaims includes them in tokens for user sign-ins as well. APIs sometimes use client claims to make authorization decisions, such as “only the billing app may call this endpoint.” On a shared client, every application carries the same claims, so the API can’t make that distinction. That alone is a strong reason not to share a client between applications that call your APIs with different privileges.

What to do when five clients aren’t enough

Check the current editions first

Duende has changed its licensing more than once. It now offers a free Community Edition for companies and individuals with less than $1 million in annual revenue, and its editions and limits have shifted over time. Look at the current price list. Ask Duende directly, too, about redistribution pricing for your number of on-premises customers and about how they count clients. Find out what the license terms say about sharing a client between applications before you design around it.

Use a different identity server

This is the option I’d look at hardest. For software installed on customers’ servers, a SaaS identity provider such as Auth0, Okta or Microsoft Entra External ID usually isn’t an option, because the identity server has to ship with the product. For .NET, OpenIddict is a free, Apache-licensed OpenID Connect server library that you host in your own ASP.NET Core application. It has no client limit, although you write more of the surrounding code yourself. Keycloak is a complete, free identity server with an admin UI, and it runs as its own service. Zitadel and Authentik are worth comparing too. Moving from IdentityServer takes real work, but it may still cost less than the unlimited license over a few years.

Don’t stay on IdentityServer4

Forking the last free version is tempting. But IdentityServer4 reached end of support in December 2022, and it targets frameworks that are themselves out of support. An identity server is the last component you want to run without security patches.

Pay for unlimited clients

This is the simplest option, provided you have large amounts of money. Weigh the price against what a migration would cost, and against the cost of a security incident that the shared clients could have caused.

As a last resort, group applications carefully

If you really have to share, group applications by client type first, because client type already decides most of the settings:

  • Server-side web applications: confidential, code flow with PKCE, authenticated with a secret or private_key_jwt, and no tokens in the browser. Refresh tokens only if the application calls APIs while the user isn’t using it.
  • Single-page applications: preferably moved behind a BFF, which turns them into server-side web applications. Otherwise they’re public clients using the code flow with PKCE, short-lived access tokens, and refresh token rotation if they use refresh tokens at all.
  • Mobile and desktop applications: public clients using the system browser, the code flow with PKCE, and rotating refresh tokens.
  • Machine-to-machine services: the client credentials flow, authenticated with a secret or a certificate. No user is involved and no refresh tokens are issued.

Within a group, only share between applications that also:

  • belong to the same team
  • need the same scopes
  • handle data of the same sensitivity
  • would cause the same damage if compromised

Never share a client between machine-to-machine services that call your APIs with different privileges, and never put a public client in the same group as a confidential one. The configuration below shows how differently two types of client are set up:

new Client
{
    ClientId = "orders-web",                  // server-side web app: confidential
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    ClientSecrets = { new Secret(ordersSecret.Sha256()) },
    RedirectUris = { "https://orders.example.com/signin-oidc" },
    AllowedScopes = { "openid", "profile", "orders.read", "orders.write" },
    AllowOfflineAccess = true,
    AccessTokenLifetime = 900
},
new Client
{
    ClientId = "reports-desktop",             // desktop app: public
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    RequireClientSecret = false,
    RedirectUris = { "http://127.0.0.1/callback" },
    AllowedScopes = { "openid", "reports.read" },
    AllowOfflineAccess = true,
    RefreshTokenUsage = TokenUsage.OneTimeOnly
}

Almost every important line differs between the two. Grouping doesn’t remove the risks from the earlier section. It keeps them within applications that would be hurt about equally if one of them were compromised. Write down which applications share each client and why. Review that list whenever an application changes what it does, because a group that made sense a year ago may not anymore.

Further reading

RFC 9700: Best Current Practice for OAuth 2.0 Security

RFC 7636: Proof Key for Code Exchange (PKCE)

RFC 8252: OAuth 2.0 for Native Apps

Duende IdentityServer license keys

OpenIddict documentation

Keycloak

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

Leave a Reply

Your email address will not be published. Required fields are marked *