Skip to main content

Production Hardening Checklist

A fresh Abblix OIDC Server compiles, boots, and passes a happy-path login. That is not the same as being ready to face third-party clients in production. This page is the list of what to check and what to turn on before you get there.

The items split into two kinds, and it helps to keep them apart:

  • On (or fail-closed) by default — verify a deployment has not undone it. Transport enforcement is on, and signing keys are fail-closed: the server refuses to issue tokens until you supply them. What breaks these is the deployment around them — a reverse proxy that hides the real scheme, or a certificate loaded without a pinned algorithm.
  • Off by default — you have to turn it on. Refresh-token rotation, cache-entry encryption, and consent enforcement ship in their most permissive form, and licensing runs a free-tier fallback. Each is a deliberate default that suits a trusted first-party evaluation and that a third-party-facing deployment is expected to tighten. Every one has a one-line fix in its section below.

Everything below is per shipped 2.3; where the 2.4 development branch changes a default or adds a surface, it is called out. A note on wiring: the examples use the MVC host entry point AddOidcServices, which internally calls the protocol-core AddOidcCore — where a DI seam has to beat or wrap the library's own TryAdd registration, "before/after AddOidcServices" is what you write.

Refresh tokens: turn on rotation

Out of the box a client's refresh tokens are reusableAllowReuse defaults to true in 2.3, so a refresh token can be redeemed repeatedly until it expires. Rotation is a per-client opt-in: set AllowReuse to false and every redemption mints a fresh token and marks the presented one used, so a replay of a superseded token is rejected. Public and SPA clients should always run with AllowReuse = false — a leaked reusable token is replayable until natural expiry.

{
"Clients": {
"my-web-app": {
"ClientId": "my-web-app",
"RefreshToken": {
// false = rotate on every use; the presented token becomes single-use
"AllowReuse": false,
"AbsoluteExpiresIn": "08:00:00", // hard ceiling on the token and all its rotations
"SlidingExpiresIn": "01:00:00" // rolling window; set null to make AbsoluteExpiresIn a hard, non-extending cap
}
}
}
}

The setting is per client (ClientInfo.RefreshToken), not a server-wide switch — each confidential client that issues refresh tokens carries its own policy. The 2.3 defaults are an 8-hour absolute ceiling and a 1-hour sliding window; set them explicitly per client to shrink the replay window.

IMPORTANT

Reuse detection is stateful. In a multi-instance deployment the used/revoked status lives in the token registry, so a replay that lands on a different node only fails if that registry is a shared, persistent store — back it with Redis or a database, not a per-pod in-memory cache, or a stolen token can slip through on another instance.

What rotation buys on 2.3, and what it does not. AllowReuse = false gives you rotation plus rejection of a replayed, rotated-out token. It does not yet sweep the whole token family: the still-active token minted alongside the stolen copy survives — though it remains under the same absolute expiry ceiling, and the rotated-out token it was minted from is already rejected. Full-family revocation on a single replay (the rotation-with-lineage model of RFC 9700 §4.14.2) lands with the 2.4 branch's per-grant family claim.

Dynamic Client Registration inherits the reusable default. DCR is itself an opt-in endpoint — with it disabled, no client reaches production without your explicit config. If you enable it, DCR-registered clients are born with AllowReuse = true (and, as below, pkce_required = false), so stamp AllowReuse = false and pkce_required = true in your registration policy rather than trusting the defaults for clients you did not hand-configure.

Client policy: PKCE, secrets, and token lifetimes

Three per-client settings decide how exposed each client is, and their safe forms are worth pinning explicitly.

  • Require PKCE, and audit for it. A static ClientInfo requires PKCE by default (PkceRequired = true) and rejects the plain challenge method (PlainPkceAllowed = false). But a DCR-registered client defaults to pkce_required = false — exactly the third-party clients you care about — so require it in your registration policy, and verify no client sets PlainPkceAllowed = true.
  • Store client secrets hashed, not in plaintext. ClientSecret carries Sha256Hash, Sha512Hash, and a raw Value; client_secret_basic/client_secret_post authenticate against the hash. Store the Sha512Hash and keep the raw Value only for the clients that use client_secret_jwt (HMAC verification needs it). Never commit a raw secret to appsettings.
  • Cap token lifetimes. The access token is the primary bearer exposure window — stolen, it is usable and unrevoked until it expires. The 2.3 per-client defaults are a 10-minute access token, a 5-minute ID token, and a 1-minute authorization code; shorten AccessTokenExpiresIn to your risk tolerance, and consider sender-constraining public/SPA clients with DPoP or mTLS so a stolen access token is not replayable.

Transport: HTTPS, forwarded headers, secure cookies

The library is secure by default on transport — its protocol controllers carry [RequireHttps], so a cleartext request is redirected or refused with no opt-in from you. The way to break that is to run behind a TLS-terminating reverse proxy or ingress and forget that, to the app, every forwarded request now looks like plain HTTP.

  • Run ForwardedHeaders behind a proxy, trusting every hop. [RequireHttps] keys off Request.IsHttps, which is false behind a terminating proxy until the host honors X-Forwarded-Proto. ForwardLimit defaults to one hop, so with a chain (edge CDN → ingress → pod) you must raise it to the number of trusted hops and list every hop's range in KnownNetworks / KnownProxies — otherwise the scheme is dropped and the library redirect-loops or 403s every request, the exact failure this prevents.
  • Include XForwardedFor if anything downstream needs the client IP. Forwarding only the scheme leaves RemoteIpAddress as the proxy's, silently breaking host-side rate limiting, audit logging, and IP allowlists.
// Host-side (ASP.NET Core), not an Oidc.Server option. Register before authentication.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost
| ForwardedHeaders.XForwardedFor,
ForwardLimit = 2, // = number of trusted proxies in front of the app
// list every trusted hop; loopback-only is the insecure default. Note the type:
// Microsoft.AspNetCore.HttpOverrides.IPNetwork on .NET 8+.
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8) },
});
  • Mark the host's own authentication cookie Secure, and keep it HttpOnly. The login cookie is the host's, not the library's — set CookieSecurePolicy.Always, and leave HttpOnly on (ASP.NET Core's default) so XSS cannot read it.
IMPORTANT

Do not force HttpOnly globally with a blanket CookiePolicy. The library deliberately sets its OIDC Session Management session_state cookie non-HttpOnly because the check_session_iframe must read it from JavaScript — and that cookie is a non-secret session-management value, not your authentication cookie. A global HttpOnly = Always would override the library and break Session Management; the fix is to leave the auth cookie HttpOnly (its default) and not impose a global policy, not to make anything else script-readable.

NOTE

The discovery and JWKS endpoints are public, unauthenticated GET metadata — the provider configuration a client reads to find your endpoints, and the public keys it uses to verify your tokens. The library does not [RequireHttps]-gate them the way it gates the credential-bearing endpoints. Serve them over HTTPS in production anyway, and not for secrecy: a client that fetches your JWKS over cleartext can have its view of your signing keys tampered with in flight, and an attacker who substitutes keys there can forge tokens any client trusting that JWKS would accept as genuinely yours. Enforce it where these endpoints terminate — TLS at the edge, or globally in the host with UseHttpsRedirection() and UseHsts() so every route, metadata included, is covered. (RequireHttpsMetadata is a setting on Microsoft's OpenID Connect client handler, not an Abblix server option — leave it at its secure default true if the same host also consumes OIDC.)

Signing keys: fail-closed, then harden the choices

Signing keys are fail-closed: OidcOptions.SigningKeys is empty until you supply it, and the first token issuance throws if it is still empty — the server never auto-generates or auto-rotates a key. Persisting and rotating those keys across restarts and replicas is its own topic, covered in Persisting JWT Signing Keys in Production; this checklist adds the hardening on top of a working key store.

  • Pin each key's algorithm. A certificate loaded via cert.ToJsonWebKey() carries no alg, so the token header alone drives selection and the key is usable with any compatible algorithm. Set JsonWebKey.Algorithm so the validator rejects the key for anything else, closing within-family algorithm confusion.
  • Prefer PS256 or ES256 over RS256. Both have a tighter security reduction than the RSASSA-PKCS1 family.
  • Use asymmetric keys for a public OP. RSA ≥ 2048-bit or an EC curve. A public OP issues asymmetric-signed tokens third parties verify against your JWKS, and the JWKS never publishes usable symmetric key material — so an HMAC (HS*)-only signing configuration cannot serve one, and token issuance fails.
  • Provision a separate encryption key. Never reuse a signing key to encrypt. The library reads signing and encryption keys from two distinct collections; leaving EncryptionKeys empty simply issues signed-only tokens.
options.SigningKeys = new[]
{
// alg is bound to the key, so it cannot be repurposed for another algorithm
JsonWebKeyFactory.CreateRsa(PublicKeyUsages.Signature, SigningAlgorithms.PS256, keySize: 3072),
};
options.EncryptionKeys = new[]
{
JsonWebKeyFactory.CreateRsa(PublicKeyUsages.Encryption, keySize: 3072),
};

Rotate by publishing overlapping keys: the JWKS carries every key in SigningKeys, so prepend the fresh key — it signs, because selection takes the first key matching the token's algorithm — keep the previous one for at least one maximum token lifetime plus a buffer so tokens it signed still validate, then drop it. That works for same-algorithm rotation; changing the signing algorithm (RS256 → PS256) is a separate step, not achieved by prepending, since the old key keeps signing until the issued algorithm itself changes. There is no built-in scheduler — cadence is a runbook item you drive, by mutating the collection or through a replaceable IAuthServiceKeysProvider.

Cache entries: encrypt at rest

Everything the server keeps between requests — authorization codes and grants, PAR, CIBA and device records, replay-prevention markers — lives as expiring entries in whatever IDistributedCache the host registers, serialized with protobuf. It is written as plaintext: the library ships no cache-encryption surface of its own, so at-rest confidentiality is delegated to the backing store. A private Redis with TLS on encrypted disk satisfies most controls; where the cache tier is not trusted, encrypt at one of three host-owned seams.

  • Decorate the serializer (cleanest). Wrap IBinarySerializer so bytes are encrypted before they reach the cache and decrypted on read. Register the decorator after AddOidcServices so the built-in composite serializer is the inner instance.
public sealed class EncryptingBinarySerializer(
IBinarySerializer inner, IDataProtectionProvider dp) : IBinarySerializer
{
// the single-use lock tokens of the atomic get-and-remove protocol bypass the
// serializer, so encrypting here leaves that path intact
private readonly IDataProtector _p = dp.CreateProtector("Abblix.Oidc.CacheEntries");
public byte[] Serialize<T>(T obj) => _p.Protect(inner.Serialize(obj));
public T? Deserialize<T>(byte[] bytes) => inner.Deserialize<T>(_p.Unprotect(bytes));
}

// after AddOidcServices(...):
services.Decorate<IBinarySerializer, EncryptingBinarySerializer>();
IMPORTANT

This decorator depends on the app's ASP.NET Data Protection key ring, and the default ring is per-process and ephemeral. In the multi-instance deployment this page assumes, an entry written by one instance cannot be decrypted by another — the authorization-code, PAR, and device flows break under load balancing, and every entry is unreadable after a restart. So if you take this route you must persist and share the key ring across instances (PersistKeysToStackExchangeRedis, blob storage, or a mounted volume) and protect it at rest — the same overlap discipline as signing keys. (This is a different key ring from the OIDC cache; a plain AddDataProtection() for cookies and antiforgery does not encrypt cache entries — that is what this decorator adds — but the decorator does make the ring's persistence a hard prerequisite for your token flows.)

  • Replace IEntityStorage with your own encrypting implementation, pre-registered before AddOidcServices so it wins the library's TryAdd. If you do, wrap or delegate to the built-in DistributedCacheStorage — do not reimplement the get-and-remove yourself, or you lose the atomic lock-token protocol that guarantees exactly one caller consumes an authorization or device code across instances, reintroducing code-replay races.
  • Encrypt at the transport layer, wrapping the host's IDistributedCache or pointing it at a store with at-rest encryption — fully host-side, decoupled from Abblix.

Consent is not enforced by default. The library binds IUserConsentsProvider to a NullConsentService that auto-grants every requested scope, so the authorize flow never prompts — the shape intended for a trusted first-party deployment. Never expose a third-party or public client while NullConsentService is active: for anything touching consent-to-processing, an auto-grant with no user prompt and no consent record is a compliance problem. Replace the provider before that happens; there is no global "require consent" flag, so enforcement is the provider you supply.

  • Replace the provider, and register it before AddOidcServices. Your provider returns the not-yet-approved scopes in Pending; any non-empty Pending makes the authorize flow return a consent-required outcome. Order matters: register before AddOidcServices and the library's TryAdd yields to you and the built-in PromptConsentDecorator wraps you; register after with a plain AddScoped and your provider wins but the decorator is bypassed, so prompt=consent stops forcing re-consent — with nothing to signal it.
// Program.cs — register FIRST so the decorator wraps your provider
builder.Services.AddScoped<IUserConsentsProvider, MyConsentProvider>();
builder.Services.AddOidcServices(options =>
{
options.ConsentUri = new Uri("https://id.example.com/consent"); // where a pending outcome goes
});
  • Set OidcOptions.ConsentUri. Once a provider produces pending consents, the default MVC formatter redirects the consent-required outcome there and throws if it is unset.
  • Put per-client policy in host code. With no per-client flag, branch inside your provider on request.ClientInfo — auto-grant a trusted first-party client, force Pending for third-party ones.
  • Verify it enforces. Because a misordered registration fails silently, confirm after wiring by driving one authorize call with prompt=consent and checking it re-prompts.

CORS: pin the origins

The UserInfo, EndSession, CheckSession, and Discovery endpoints can be called cross-origin, and the library exposes their CORS policy through CorsSettings (AllowedOrigins, AllowCredentials). A third-party OP with SPA or public clients must pin AllowedOrigins to the exact origins that need it — and never combine wildcard origins with AllowCredentials = true, the classic misconfiguration that lets any site make credentialed calls.

License: supply the JWT from a secret

Abblix OIDC Server is licensed, and the license is a signed JWT blob issued by Abblix — not a key string you can invent. With none supplied, the server falls back silently to a free tier of 2 clients and 1 issuer; the fallback is easy to miss in development, which is exactly why it belongs on a production checklist.

  • Supply the license through OidcOptions.LicenseJwt (or the AddLicense(jwt) extension), and never hardcode it. The library binds no license appsettings section of its own — the host owns that mapping — so read it from a secret and mount it as an environment variable.
// value mounted from a Kubernetes secret as an env var, never committed
options.LicenseJwt = configuration["Abblix:OpenIdServer:License"]; // your own config key
  • Alert on the license log events, because two failure modes are loud in production and silent in dev. Exceeding the client limit warns and then drops only new clients past a margin — but an issuer-limit or issuer-whitelist violation throws at runtime, and that exception takes down discovery, token, userinfo, and registration flows, not just new clients. An expired license degrades back to the free tier after its grace period. Alert on the license EventIds (client-limit, issuer-limit, whitelist, expiry) so an unmounted or expiring license is loud, not a silent free-tier cap or a 3 AM outage.

The inline options, the ILicenseJwtProvider seam for vault-backed retrieval and zero-downtime rotation, and the free-tier limits are covered in Configuration and Setup; treat this as the reminder to move the value into a secret and to alert on it.

Host-side essentials

Two more belong on the go-live list even though they are the host's job, not the library's:

  • Rate-limit the token, introspection, and client-authentication endpoints at the edge or with ASP.NET Core rate limiting, to blunt credential-stuffing and brute-force.
  • Keep secrets out of logs. The server logs request detail; ensure your logging pipeline does not persist code, client_secret, or Authorization headers.

The checklist

ItemShipped 2.3 defaultHarden byLives in
Refresh-token rotationReusable (AllowReuse=true)Set AllowReuse=false per client (always for public/SPA); tighten lifetimes; shared token registry; pin it in your DCR policyPer-client options
Client policyPKCE required for static clients; secrets carry a plaintext ValueRequire PKCE (DCR defaults to off); store Sha512Hash not plaintext; cap AccessTokenExpiresIn; DPoP/mTLS for public clientsPer-client options
Transport HTTPSEnforced on protocol endpointsForwardedHeaders with ForwardLimit + known proxies behind an ingress; Secure, HttpOnly auth cookieHost pipeline
Signing keysFail-closed (you must provide)Pin alg; PS256/ES256; asymmetric only; separate encryption key; overlap rotationOidcOptions
Cache entriesPlaintext in the backing storeEncrypt at IBinarySerializer, IEntityStorage, or IDistributedCache — with a shared, persisted DataProtection ringDI
ConsentNot enforced (auto-grant)Replace IUserConsentsProvider before AddOidcServices; set ConsentUri; verify with prompt=consentDI + options
CORSPolicy driven by host configPin AllowedOrigins; never wildcard with AllowCredentialsCorsSettings
LicenseFree-tier fallback (2 clients, 1 issuer)Supply LicenseJwt from a secret; alert on license eventsOptions + secret
Host-sideRate-limit token/introspection endpoints; keep secrets out of logsHost / edge

None of these is exotic, and none of them is on by accident. The permissive defaults are the ones a first-party evaluation wants; the checklist is the deliberate list of what a third-party-facing production deployment tightens on top.