Saltar al contenido
Esta página aún no está traducida.

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. 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.

Licensing sits in neither list and is here for a different reason. With no license supplied the server runs on the free tier, which is a supported production posture rather than an evaluation mode, so the item below is about knowing which posture you are in and alerting on it, not about tightening a loose default.

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: keep rotation on, and make it stateful

Refresh tokens rotate out of the box: AllowReuse defaults to false, so every redemption mints a fresh token and marks the presented one used, and a replay of a superseded token is rejected. What to check here is whether anything in your configuration switches it off. Never set AllowReuse = true for a public or SPA client: a leaked reusable token is replayable until natural expiry.

The policy is per client (ClientInfo.RefreshToken), not a server-wide switch: each confidential client that issues refresh tokens carries its own. It holds three values worth stating rather than inheriting. AllowReuse decides rotation: left at false, the presented token becomes single-use. AbsoluteExpiresIn is the hard ceiling on the token and every rotation descended from it, 8 hours by default. SlidingExpiresIn is the rolling window, 1 hour by default; set it to null to make the absolute ceiling a hard, non-extending cap. Set all three explicitly per client to shrink the replay window.

JavaScript
{
  "Clients": {
    "my-web-app": {
      "ClientId": "my-web-app",
      "RefreshToken": {
        "AllowReuse": false,
        "AbsoluteExpiresIn": "08:00:00",
        "SlidingExpiresIn": "01:00:00"
      }
    }
  }
}
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 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).

What rotation buys. A rotated-out token is marked used rather than revoked, and presenting it again revokes the whole grant family: every token descended from the same authorization, including the live one the thief did not take. That is the rotation-with-lineage model of RFC 9700, section 4.14.2, and it is what turns a replay from a rejected request into an ended session. Keep the distinction in mind when you query the registry: rotation writes Used, and only the replay writes Revoked.

Dynamic Client Registration inherits the same defaults. A registration that says nothing about rotation or PKCE gets the library's answer to both, which is the strict one, so a third-party client cannot register itself into a weaker posture by omission. What a registration policy still owes you is the values a client can state: a request that explicitly asks for reusable refresh tokens is a request to refuse.

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 client requires PKCE by default (PkceRequired = true) and rejects the plain challenge method (PlainPkceAllowed = false), and a dynamically registered client that stays silent on the subject inherits exactly that. The audit is therefore for clients that opted out: verify none sets PkceRequired = false or 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 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: every protocol endpoint is gated on HTTPS ([RequireHttps] on the MVC controllers, the group HTTPS filter in the Minimal API adapter), discovery and JWKS included, 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.

The call that does this is UseForwardedHeaders, host-side ASP.NET Core rather than an Oidc.Server option, placed before authentication. Its options object names the headers to honour through the ForwardedHeaders flags: XForwardedProto, XForwardedHost and XForwardedFor are the three this checklist is about, and there is a fourth, XForwardedPrefix, which matters when the proxy mounts the app under a path rather than at a root. All is the union of the four. It also sets ForwardLimit to the number of trusted proxies in front of the app, which defaults to one, and lists every trusted hop in KnownProxies or in KnownIPNetworks, whose entries are System.Net.IPNetwork. Those defaults are the insecure part: KnownProxies starts out holding the loopback address alone, so a deployment behind a real proxy honours nothing until it says which hops it trusts.

C#
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedProto
                     | ForwardedHeaders.XForwardedHost
                     | ForwardedHeaders.XForwardedFor,
    ForwardLimit = 2, // = number of trusted proxies in front of the app
    KnownIPNetworks = { new System.Net.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 [RequireHttps]-gates them exactly like every other endpoint: a cleartext GET is redirected to the https URL rather than served. 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 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 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.

JsonWebKeyFactory.CreateRsa is where all four of those meet: it takes the usage the key is for, Signature or Encryption, the algorithm, and the key size, so a signing key created with PS256 named carries that algorithm and cannot be repurposed for another. Signing keys go into OidcOptions.SigningKeys and encryption keys into EncryptionKeys, two collections that never share a member.

C#
options.SigningKeys = new[]
{
    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, 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). 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.

The decorator is a small class taking the inner IBinarySerializer and an IDataProtectionProvider, holding one protector obtained from CreateProtector under a purpose string of its own. Its Serialize passes the inner result through Protect, and its Deserialize calls Unprotect before handing the bytes to the inner serializer. Nothing else is overridden, and the single-use lock tokens of the atomic get-and-remove protocol bypass the serializer, so encrypting here leaves that path intact.

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.)
  • 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.

In Program.cs that is one AddScoped for your IUserConsentsProvider standing above the AddOidcServices call, whose options set ConsentUri to the page a pending outcome is sent to.

  • 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 runs on the free tier, which allows one issuer and puts no ceiling on client applications, users or nodes. A deployment that needs a second independent issuer fails once that second issuer is seen, and it fails on both: the issuer count is process-wide and never decreases, so every later request throws whichever issuer it names. The failure is easy to miss in development, which is exactly why the license 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. In code that is a single assignment to options.LicenseJwt from a configuration key of your own naming, whose value arrives from the secret and is never committed.

  • Alert on the license log events, because the loudest failure mode is silent in development. An issuer-limit or issuer-whitelist violation throws at runtime, and that exception takes down discovery, token, userinfo and registration flows alike. An expired license degrades back to the free tier once its grace period ends, and the free tier allows one issuer, so a deployment serving two then fails on both until the process restarts under a valid license. The grace period exists only where the issued key carries one. Alert on the license EventIds (issuer-limit, whitelist, expiring-soon, grace-period), and alert on the license expiry date held outside the server: nothing is logged at the instant a license passes the end of its grace period, so the daily grace-period record simply stops.

The inline options, the ILicenseJwtProvider seam for vault-backed retrieval and zero-downtime rotation, and the free-tier limit 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

ItemDefaultHarden byLives in
Refresh-token rotationRotating (AllowReuse=false), replay revokes the grant familyRefuse AllowReuse=true in review and in your DCR policy; tighten lifetimes; back the token registry with a shared storePer-client options
Client policyPKCE required, plain refused; secrets can carry a plaintext ValueAudit for clients that opted out of PKCE; store Sha512Hash not plaintext; cap AccessTokenExpiresIn; DPoP/mTLS for public clientsPer-client options
Transport HTTPSEnforced on all endpoints, JWKS and discovery includedForwardedHeaders 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: 1 issuer, no client or user limitSupply LicenseJwt from a secret; alert on license eventsOptions + secret
Host-side-Rate-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.