Scaling

Authagonal is designed to scale both vertically and horizontally with no special configuration.

Stateless by design

All persistent state is stored in the backing store — Azure Table Storage, DynamoDB on the AWS backend, or PostgreSQL on the self-hosted SQL backend. There is no in-process state that requires sticky sessions or coordination between instances:

ASP.NET Core’s Data Protection keys are automatically persisted to Azure Blob Storage when using a real Azure Storage connection string. This means cookies signed by one instance can be decrypted by any other instance, no sticky sessions required.

For local development with Azurite, data protection keys fall back to the default file-based store.

You can also point to an explicit blob URI via configuration (the managed-identity path, preferred in production):

{
  "DataProtection": {
    "BlobUri": "https://youraccount.blob.core.windows.net/dataprotection/keys.xml"
  }
}

On the AWS backend, pass an S3 client + bucket to AddAuthagonalAwsStorage to persist the key ring to S3, without it the key ring is in-memory and cookies break on restart and across nodes. See Installation → AWS backend.

Per-instance caches

A small number of read-heavy, slow-changing values are cached in memory per instance to reduce Table Storage round-trips:

Data Cache duration Impact of staleness
OIDC discovery documents 60 minutes (configurable) Delayed awareness of IdP key rotation
SAML IdP metadata 60 minutes (configurable) Same
CORS allowed origins 60 minutes (configurable) New origins take up to an hour to propagate

These caches are acceptable for production use. All durations are configurable via the Cache configuration section, see Configuration. If you need immediate propagation, restart the affected instances.

Rate limiting

Abuse-prone endpoints (registration per IP, password reset per target email, SCIM per client, dynamic client registration per IP, see Configuration → Rate Limiting) are protected by a built-in rate limiter.

By default, limits are enforced in-process per node behind the IRateLimiter seam, so with N instances the effective ceiling is N× the configured value. That’s deliberate: the limiter is a backstop against runaway abuse of a single node, and the authoritative global limit belongs at the edge (WAF / ingress / CDN), which sees all traffic before it’s load-balanced.

That trade-off is right for the volume limits and wrong for one case: a budget guarding a guessable secret. The device flow’s user_code is a short string from a small alphabet, and the attempt limit is the only thing standing between an attacker and a code that grants a live session — a ceiling that multiplies by replica count is the wrong shape there, and it makes the real bound a property of your ingress configuration rather than of the server.

Set Auth:DurableRateLimiting=true to move the counters into the store you already run, so every replica shares one budget. It costs a store round trip per rate-limit check, uses fixed windows (a budget of N allows up to 2N across a window boundary), and fails open if the store is unreachable — so it layers on the edge rule rather than replacing it. Counter rows are collected automatically on all three backends. See Configuration → Cluster-wide limits.

Clustering

Multiple instances coordinate through a leader election and a cross-node event bus, both behind pluggable backends:

Each instance generates a random 12-hex-char node ID at startup to identify itself; it is not persisted.

Backends

The default is in-process: a single node is always its own leader, and events are local-only, correct for one instance with zero configuration. Multi-node deployments swap in a real backend via the configureClustering callback on AddAuthagonal:

// Azure: leadership via a blob lease, event bus via a table log (Authagonal.AzureProvider)
builder.Services.AddAuthagonal(builder.Configuration,
    cluster => cluster.UseAzureStorage(blobServiceClient, tableServiceClient));

// AWS: leadership + event bus via DynamoDB (Authagonal.AwsProvider)
builder.Services.AddAuthagonal(builder.Configuration,
    cluster => cluster.UseAwsDynamo(dynamoDb));

// PostgreSQL: leadership via a conditional-upsert lease row, event bus via an
// append-only log in the same database (Authagonal.SqlProvider)
builder.Services.AddAuthagonal(builder.Configuration,
    cluster => cluster.UseSql(sqlDataSource));

UseAzureStorageBus / UseAwsDynamoBus / UseSqlBus register the event bus only, keeping the in-process (always-leader) lease, use them on nodes that must receive cluster events but must never contend for leadership.

Note: with the in-process default on multiple nodes, every node believes it is the leader. That’s harmless for most workloads, but enable a real lease backend before turning on Auth:KeyRotationEnabled across multiple instances.

Signing-key generation is separate from that leader-gated deactivation, and is not driven by it: every node calls EnsureActiveKeyAsync at startup and on each Auth:SigningKeyCacheRefreshMinutes refresh, so with KeyRotationEnabled off — the default — rollover at the 90-day expiry is driven entirely by that path. Generation takes its own short cluster lease, so it is single-writer wherever a real lease backend is configured. With the in-process default on multiple nodes there is no such coordination and two nodes that reach an expired key at the same moment can each generate one; both end up in the JWKS and tokens signed by either verify, but which key is reported active can flap. This is another reason to configure a real lease backend for multi-node deployments.

See the Configuration page for all cluster settings.

Multi-tenant deployments

In multi-tenant mode (AddAuthagonalCore()), no background services are registered, TokenCleanupService, GrantReconciliationService, SigningKeyRotationService, and the config seed services are all part of the single-tenant AddAuthagonal() composition. The host manages these per-tenant.

Name-index hot partition

Admin name-prefix search is backed by the UserFirstNames / UserLastNames index tables, which use a single hot partition. At scale this caps index-write throughput at roughly 2,000 ops/sec, which can become a bottleneck on user create/update under heavy load. If you don’t expose admin name search, set Storage:NameIndexesEnabled = false to skip these writes entirely. See Configuration.

Trusted-proxy and internal endpoints

When running multiple instances behind a load balancer:

Scaling recommendations

Vertical scaling: increase CPU and memory on a single instance. Useful for handling more concurrent requests per instance.

Horizontal scaling: run multiple instances behind a load balancer. No sticky sessions or shared caches required. Each instance is fully independent.

Scale to zero: Authagonal supports scale-to-zero deployments (e.g., Azure Container Apps with minReplicas: 0). The first request after idle will have a cold start of a few seconds while the .NET runtime initializes and signing keys are loaded from storage.