Table of Contents

CORS

A browser application calls the token, userinfo and revocation endpoints from script, so those answer cross-origin. The code on this page is included from samples/Server.Hosting, which a test sends a preflight from an allowed origin and from another.

Which endpoints answer

AddIdentityServer() puts an ICorsPolicyProvider in front of the host's own, and it answers only for the paths in CorsOptions.CorsPaths — discovery, its key set, userinfo, token and revocation. Every other request, the host's own endpoints included, goes to whatever CORS configuration the host had. Adding this library does not take that over; UseIdentityServer applies the policy by its own name (CorsOptions).

Authorize and end session are absent from the list because a browser reaches them by navigation rather than by script, and introspection because it is a resource server's call. The policy never sets Access-Control-Allow-Credentials: these endpoints authenticate with a client secret or a bearer token the caller supplies, never with an ambient cookie, and credentials alongside a reflected origin is the combination that turns a permissive CORS policy into cross-site request forgery.

Which origins are allowed

Which origins are allowed is a separate question, and it has no default. Nothing registers an ICorsPolicyService on its own, so resolving one throws rather than quietly allowing or denying everything. Pick where the origins live.

On the client registrations. AddInMemoryClients supplies InMemoryCorsPolicyService, which allows each client's AllowedCorsOrigins:

// A browser application with no back end: a public client, so PKCE (required by default) is its protection.
new Client
{
    ClientId = "spa",
    RequireClientSecret = false,
    AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
    RedirectUris = { "https://app.example.com/callback" },
    AllowedCorsOrigins = { "https://app.example.com" },
    AllowedScopes = { "openid", "profile", "api" },
    Properties = { ["owner"] = "web-team" },
},

// A service: its secret is stored as a hash, and compared as one.
new Client
{
    ClientId = "reporting",
    ClientSecrets = { new Secret("reporting-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
    AllowedScopes = { "api" },
    Properties = { ["owner"] = "data-team" },
},

InMemoryCorsPolicyService ignores the origins of a client whose Enabled is false, so decommissioning a client stops its script reaching the protocol endpoints. An origin is an origin — https://app.example.com, no path, no trailing slash — and a client registering anything else is refused.

In the host's own configuration, for a host whose client store does not carry origins:

// The origins a browser application may call the token, userinfo and revocation endpoints from, kept in the host's
// configuration rather than on the client registrations. This replaces the policy service AddInMemoryClients
// supplied, which reads each client's AllowedCorsOrigins.
identityServer.AddDefaultCorsPolicyService(cors => cors.AllowedOrigins.Add("https://app.example.com"));

Or a service of your own, over your own store, with AddCorsPolicyService — shown with a cache below.

Caching the decision

/// <summary>
/// A policy service of the host's own, asked on every preflight and every request after it — so a cache in front of
/// it. The cache must come after the registration it caches.
/// </summary>
public static IIdentityServerBuilder AddTenantOrigins(this IIdentityServerBuilder identityServer)
    => identityServer
        .AddCorsPolicyService<TenantOriginPolicyService>()
        .AddCorsPolicyCache(TimeSpan.FromMinutes(5));   // AddCorsPolicyCache() alone holds an answer for a minute

A cross-origin call asks twice — once on the preflight, once on the request that follows it — so a policy service that reads a database is asked far more often than the data it reads changes. AddCorsPolicyCache decorates whichever policy service is already registered. It has to come after the registration it caches, and it throws saying so if there is nothing there yet. There is no duration that turns it off: a host that does not want a cache does not register one. A host keeping its clients in memory has nothing to gain here.

What is cached is a permission, and the cost is that a client disabled now keeps reaching the protocol endpoints from the browser until its entry expires — which is why the default duration is a minute rather than an hour, and why a host that has just changed a registration and does not want to wait can end the window itself:

/// <summary>Ends the cache's window at once, after a registration changed — a client disabled, an origin removed.</summary>
public static void ForgetAllowedOrigins(IServiceProvider services)
    => services.GetRequiredService<CorsPolicyCache>().Clear();

Two things the cache does that a Dictionary<string, bool> would not, both because the key is the Origin header and the header is chosen by whoever is calling:

  • Denials are never stored. Otherwise anyone could grow the cache with origins nobody registered, and the entry that mattered would be the one evicted to make room. Storing only what was allowed keeps the keys inside the set the host's own configuration permits — and it means the lag is on withdrawal, never on grant: a newly registered origin works on its first request.
  • It stops at CorsPolicyCache.MaxEntries (1000), pruning expired entries first. That covers what the first rule does not: a policy service may allow origins by pattern — every subdomain of a tenant, as the sample's does — and then the allowed set is not bounded by any registration. Full, the cache asks every time, which is what this server does without one.

Keys compare ordinally even though both services above match origins ignoring case. A cache that answers differently from the service it caches is not a cache, and a host's own service may compare exactly; the cap, not the comparer, is what keeps case variants of one origin from filling it.