Skip to content

Getting Started With Spaces

Spaces require Duende Storage for their management data and isolated storage pools.

This example uses SQLite:

Terminal window
# Terminal
dotnet add package Duende.IdentityServer --prerelease
dotnet add package Duende.Storage.Sqlite --prerelease
dotnet add package Duende.Spaces --prerelease

See the Duende.Spaces NuGet Gallery page for package versions.

Add a connection string for the SQLite database:

appsettings.json
{
"ConnectionStrings": {
"IdentityServer": "Data Source=identityserver.db"
}
}

Keep production credentials out of source control and load them from your deployment platform’s secret store.

Register Spaces and the IdentityServer storage adapters. AddStorage selects the database provider; AddConfigurationStorage() and AddOperationalStorage() route configuration and operational data to it. Add Spaces with the AddSpaces extension on IIdentityServerBuilder:

Program.cs
using System.Net;
using Duende.IdentityServer;
using Duende.Spaces;
using Duende.Storage.Schema;
using Duende.Storage.Sqlite;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddIdentityServer()
.AddServerSideSessions()
.AddStorage(storage =>
storage.AddSqlite(options =>
options.ConnectionString =
builder.Configuration.GetConnectionString("IdentityServer")
?? throw new InvalidOperationException(
"IdentityServer connection string is missing.")))
.AddConfigurationStorage()
.AddOperationalStorage()
.AddSpaces(options =>
{
options.SpacePathPrefix = "/t";
options.FallbackToDefault = false;
});
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("203.0.113.42"));
options.ForwardLimit = 1;
});
var app = builder.Build();
await app.Services
.GetRequiredService<IStorageInstanceSchema>()
.MigrateAsync(CancellationToken.None);

FallbackToDefault is already false; setting it explicitly makes the intended isolation behavior visible during review.

Use ISpaceAdmin from a trusted provisioning or administration path. Query by name before creating the space:

Program.cs
using Duende.Storage.Querying;
var spaces = app.Services.GetRequiredService<ISpaceAdmin>();
var existing = await spaces.QueryAsync(
QueryRequest.Create<SpaceFilter, SpaceSortField>(
new SpaceFilter { Name = "Acme" }),
CancellationToken.None);
if (!existing.Items.Any(space =>
string.Equals(space.Name, "Acme", StringComparison.Ordinal)))
{
var result = await spaces.CreateAsync(
new CreateSpaceConfiguration
{
Name = "Acme",
MatchPatterns =
[
new SpaceMatchPattern
{
Origin = "https://login.example.com",
Path = "/acme"
}
]
},
CancellationToken.None);
if (!result.IsSuccess)
{
throw new InvalidOperationException(
$"Could not create the Acme space: {result.Errors}");
}
}

The Acme space created in this example requires both the origin and path to match. A request to https://login.example.com/t/acme/.well-known/openid-configuration resolves to the Acme pool.

Forwarded headers must run before space resolution so the resolver sees the public scheme and host. Space resolution must then run before ASP.NET Core routing because path-based matches rewrite PathBase and Path:

Program.cs
app.UseForwardedHeaders();
app.UseSpaceResolution();
app.UseRouting();
app.UseIdentityServer();
app.Run();

If another middleware reads tenant-specific data, place it after UseSpaceResolution. You can inject ISpaceContextAccessor into scoped services and call GetSpaceId() after resolution.

Replace 203.0.113.42 with your proxy’s address or configure an appropriate trusted network. Only accept forwarded headers from expected proxies or networks. An untrusted forwarded host or protocol can otherwise influence which security boundary the request selects. See Proxy Servers and Load Balancers for more configuration options.

  • Use origin matching when each space has a dedicated host name.
  • Use path matching when spaces share a host. The default /t prefix keeps space paths separate from ordinary routes.
  • Use both when a space must be constrained to a specific host and path.

Origins must include the scheme and host, plus the port when it is not the scheme default.

Review what Spaces does not isolate before deploying. Confirm whether signing credentials, Data Protection, caches, telemetry, rate limits and custom services require space-aware configuration.

Spaces partitions ASP.NET Core Data Protection by space by default (SpacesOptions.PartitionDataProtection, default true): a protector created from the app’s IDataProtectionProvider binds to the space resolved when you call Protect or Unprotect, so a value protected in one space can’t be unprotected in another. IdentityServer’s own signing key protection stays global across spaces because it calls GetUnpartitioned() on the provider. Use GetUnpartitioned() yourself for data that must be readable from every space; see Data Protection for background on providers and protectors.

For the Default space fallback, failures outside a resolved space, and which providers are partitioned, see Data Protection In Spaces.

Set SpacesOptions.PartitionDataProtection to false when all protected data from the app’s IDataProtectionProvider, such as cookies or tokens, must be readable across spaces. This removes per-space partitioning for that data. If only specific values need to be shared, use GetUnpartitioned() for those protectors instead.

Spaces registers its own keyed HybridCache under SpacesServiceProviderKeys.SpacesCache instead of registering or sharing the app’s default HybridCache. If your app already relies on an unkeyed HybridCache for its own caching, call AddHybridCache() yourself. Entries created through IHybridCacheFactory are automatically partitioned by the resolved space, so callers don’t need to build space-aware cache keys themselves.

Space resolution results are cached in the Spaces HybridCache. SpacesOptions.LocalCacheExpiration sets the in-process cache lifetime (default 30 seconds), and SpacesOptions.Expiration sets the distributed cache lifetime (default 30 minutes). A space change is visible immediately on the instance that made it. Other instances see it once their in-process cache entry expires, after at most LocalCacheExpiration.

Spaces management data and every space’s pool share the default Duende Storage instance unless you map them elsewhere. See Multiple Storage Instances to route IdentityServer’s configuration or operational data, or User Management’s data, to a separate database from Spaces management data.

For a complete, runnable multi-space deployment, see the Spaces sample.