# 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](https://www.abblix.com/en/docs/glossary-overview#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 reflects the 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](https://www.abblix.com/en/docs/glossary-overview#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 **reusable**: `AllowReuse` defaults to `true` in 2.3 (the 2.4 branch flips the default to rotation). That means 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 revoked in the token registry, 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.

```js
{
  "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](https://www.abblix.com/en/docs/glossary-overview#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.

:::warning[IMPORTANT]
Reuse detection is stateful. In a multi-instance deployment the used/revoked status lives in the token registry. A replay that lands on a *different* node only fails if that registry is a shared, persistent store. Back it with [Redis](https://www.abblix.com/en/docs/glossary-overview#redis) or a database, not a per-pod in-memory cache, or a stolen token can slip through on another instance (see [Choosing a Backend for Operational State](https://www.abblix.com/en/docs/choosing-a-cache-backend)).
:::

**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, which also re-labels rotated-out tokens `Used` rather than `Revoked` in the registry.

**[Dynamic Client Registration](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#id-token-identity-token), and a 1-minute authorization code; shorten `AccessTokenExpiresIn` to your risk tolerance, and consider sender-constraining public/SPA clients with DPoP or [mTLS](https://www.abblix.com/en/docs/glossary-overview#mutual-tls-client-authentication) so a stolen access token is not replayable.

## Transport: HTTPS, forwarded headers, secure cookies

The library is secure by default on transport: every protocol endpoint is gated on HTTPS (`[RequireHttps]` on the MVC controllers, the group HTTPS filter in the Minimal API adapter; on 2.3 and prior, discovery/JWKS are the exception, see the note below), 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, listing every hop as trusted.** `[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, then ingress, then 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.

```csharp
// 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](https://www.abblix.com/en/docs/glossary-overview#xss) cannot read it.

:::warning[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[NOTE]
The discovery and [JWKS](https://www.abblix.com/en/docs/glossary-overview#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 `[RequireHttps]`-gates them exactly like every other endpoint: a cleartext GET is redirected to the https URL rather than served. (On 2.3 and prior they were the exception, ungated by the library; enforce HTTPS for them at the TLS edge or host level there.) This is not about secrecy (the metadata is public) but about integrity: 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. If you additionally want an ungated route (a load-balancer liveness probe, say), map it yourself outside the OIDC endpoints; the library gates all of its own without exception. The `ForwardedHeaders` requirement above covers these routes too: behind a TLS-terminating proxy, `Request.IsHttps` must reflect `X-Forwarded-Proto` or even the metadata endpoints redirect-loop. (`RequireHttpsMetadata` is a setting on Microsoft's [OpenID Connect](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/signing-key-persistence)**; 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 of 2048 bits or more, or an elliptic-curve key. A public OP issues asymmetric-signed tokens third parties verify against your JWKS, and the JWKS never publishes usable symmetric key material. An HMAC (`HS*`)-only signing configuration therefore 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.

```csharp
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 to 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 grants, [PAR](https://www.abblix.com/en/docs/glossary-overview#par), [CIBA](https://www.abblix.com/en/docs/glossary-overview#ciba) and device records, token status marks, replay-prevention markers) lives as expiring entries in whatever `IDistributedCache` the host registers, serialized with protobuf (choosing that backend is its own guide: [Choosing a Backend for Operational State](https://www.abblix.com/en/docs/choosing-a-cache-backend)). It is written **as plaintext**: the library ships no cache-encryption surface of its own. As a result, 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.

```csharp
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>();
```

:::warning[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. The Data Protection key ring is separate from the OIDC cache it protects; a plain `AddDataProtection()` for cookies and antiforgery does not encrypt cache entries. That is what this decorator adds, and it makes 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 a pushed authorization request, a CIBA request, or a device code across instances, reintroducing replay races. (Authorization-code single-use is enforced separately, by a reuse-detecting decorator at the [token endpoint](https://www.abblix.com/en/docs/glossary-overview#token-endpoint).)
- **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: enforce it for third-party clients

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](https://www.abblix.com/en/docs/glossary-overview#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.

```csharp
// 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](https://www.abblix.com/en/docs/glossary-overview#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.

```csharp
// 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.** When the client limit is exceeded the server logs a warning and, once past a margin, drops only *new* clients, 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 `EventId`s (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](https://www.abblix.com/en/docs/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](https://www.abblix.com/en/docs/glossary-overview#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

| Item | Shipped 2.3 default | Harden by | Lives in |
|---|---|---|---|
| Refresh-token rotation | Reusable (`AllowReuse=true`) | Set `AllowReuse=false` per client (always for public/SPA); tighten lifetimes; shared token registry; pin it in your DCR policy | Per-client options |
| Client policy | PKCE required for static clients; secrets carry a plaintext `Value` | Require PKCE (DCR defaults to off); store `Sha512Hash` not plaintext; cap `AccessTokenExpiresIn`; DPoP/mTLS for public clients | Per-client options |
| Transport HTTPS | Enforced on all endpoints (2.3 and prior: except discovery/JWKS) | `ForwardedHeaders` with `ForwardLimit` + known proxies behind an ingress; `Secure`, `HttpOnly` auth cookie | Host pipeline |
| Signing keys | Fail-closed (you must provide) | Pin `alg`; PS256/ES256; asymmetric only; separate encryption key; overlap rotation | `OidcOptions` |
| Cache entries | Plaintext in the backing store | Encrypt at `IBinarySerializer`, `IEntityStorage`, or `IDistributedCache`, with a shared, persisted DataProtection ring | DI |
| Consent | Not enforced (auto-grant) | Replace `IUserConsentsProvider` **before** `AddOidcServices`; set `ConsentUri`; verify with `prompt=consent` | DI + options |
| CORS | Policy driven by host config | Pin `AllowedOrigins`; never wildcard with `AllowCredentials` | `CorsSettings` |
| License | Free-tier fallback (2 clients, 1 issuer) | Supply `LicenseJwt` from a secret; alert on license events | Options + secret |
| Host-side | - | Rate-limit token/introspection endpoints; keep secrets out of logs | Host / 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.
