Unify Your SAML and OIDC Signing Keys with Automatic Rotation and Duende IdentityServer
Summary: Duende IdentityServer's automatic key management can handle both OIDC and SAML signing with a single configuration. By enabling UseX509Certificate on your signing algorithm options, managed keys are wrapped in self-signed X.509 certificates that satisfy SAML's certificate requirements. This gives you unified key rotation across both protocols, eliminating manual certificate management and coordinated rollover windows with Service Providers. Managing X.509 certificates for SAML is tedious. You track expiration dates, coordinate certificate rollover with service providers, and hope nothing breaks during the transition. What if your SAML signing certificates rotated themselves, just like your OIDC keys?
Duende IdentityServer's automatic key management can handle SAML signing, but it requires understanding one optional but crucial change: wrapping managed keys in X.509 certificates. This post shows you how to set it up, explains the key rotation timeline, and covers the tradeoffs.
The Problem: SAML Needs X.509 Certificates
SAML assertions require X.509 certificates in the <KeyInfo> element. When a Service Provider validates a signed assertion, it expects to find the signing certificate in your IdP metadata.
By default, Duende IdentityServer's automatic key management produces raw RSA or EC keys. These work fine for JWT/OIDC (tokens include the key ID, and clients fetch the public key from JWKS). But SAML validation expects certificate data that isn't there.
The result? Your /Saml2 metadata endpoint returns empty <ds:X509Data> elements, and SPs can't validate your assertions.
The Solution: One Line of Configuration
Enable UseX509Certificate on your signing algorithm options, and automatic key management wraps each generated key in a self-signed X.509 certificate:
Csharp
builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.SigningAlgorithms =
[
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256)
{
// Wrap the auto-managed key in a self-signed X.509 certificate
UseX509Certificate = true
}
];
// Tune rotation based on your SP metadata cache durations
options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);
options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);
})
.AddSaml()
// ... rest of configuration That's the key change. The SAML component reads from the same ISigningCredentialStore as OIDC. No additional wiring required. In fact, setting the value of UseX509Certificate to true is also optional, as wrapping the signing key with an x509 wrapper will happen automatically when you add SAML to your IdentityServer instance.
The Key Rotation Lifecycle
Automatic key management follows a predictable lifecycle that gives Service Providers time to update their cached metadata:
flowchart LR
A[Announced] -->|14 days| B[Signing]
B -->|76 days| C[Retired]
C -->|14 days| D[Deleted]Announced: The new certificate appears in your SAML metadata and OIDC JWKS, but doesn't sign anything yet. SPs that refresh metadata will cache the new certificate.
Signing: The new key becomes the active signing credential. SPs already have it cached.
Retired: The old certificate stays in metadata so SPs can still validate recently-issued assertions.
Deleted: The key is removed from storage.
The Critical Setting: PropagationTime
PropagationTime controls how long a new certificate is announced before it starts signing. This window must exceed the longest metadata cache duration among your Service Providers.
If an SP caches metadata for 24 hours, the default 14-day propagation is generous. But if an SP has aggressive caching (say, 7 days), you need to know that before configuring rotation.
Ask your SP administrators: "How often does your system refresh IdP metadata?" Set PropagationTime accordingly.
And lastly: if you have SPs that configure your IdP statically (and not refresh metadata automatically), make sure to take into account the SP administrator should update the configuration at regular intervals.
Try It Yourself
Here's a complete file-based .NET application you can run with dotnet run Program.cs (requires .NET 10+):
Csharp
#:sdk Microsoft.NET.Sdk.Web
#:property JsonSerializerIsReflectionEnabledByDefault=true
#:package Duende.IdentityServer@8.*
using System.Security.Cryptography.X509Certificates;
using System.Xml.Linq;
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Saml;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Tokens;
using static Microsoft.AspNetCore.Http.Results;
var builder = WebApplication.CreateBuilder(args);
// Data Protection is required for automatic key management to persist and retrieve keys.
// Without this, keys may not survive restarts or may not be accessible.
var keysDirectory = Path.Combine(builder.Environment.ContentRootPath, "keys");
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDirectory))
.SetApplicationName("SamlWithAutoKeyManagement");
builder.Services.AddIdentityServer(options =>
{
// --- Automatic Key Management with X.509 certificates ---
// This is the key configuration: wrapping managed keys in X.509 certificates
// makes them compatible with SAML signing (which requires X509 key info).
options.KeyManagement.SigningAlgorithms =
[
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256)
{
// This wraps the auto-managed RSA key in a self-signed X.509 certificate.
// SAML requires X.509 certificates in the <KeyInfo> element of signed assertions.
UseX509Certificate = true
}
];
// Key rotation settings — tune these based on your SP metadata cache durations.
// PropagationTime MUST exceed the longest SP metadata cache duration to ensure
// all SPs have fetched the new certificate before it starts signing.
options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);
options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);
})
// AddSaml() registers SAML protocol handlers that consume credentials from the shared
// ISigningCredentialStore. No explicit wiring needed — the SAML component automatically
// uses the X.509-wrapped keys configured above for signing assertions and metadata.
.AddSaml()
.AddInMemoryIdentityResources(
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email()
])
.AddInMemoryApiScopes([])
.AddInMemoryClients([])
.AddInMemorySamlServiceProviders(
[
new SamlServiceProvider
{
EntityId = "https://sp.example.com",
DisplayName = "Example SAML SP",
AssertionConsumerServiceUrls =
[
new IndexedEndpoint
{
Location = "https://sp.example.com/saml/acs",
Binding = SamlBinding.HttpPost,
Index = 0,
IsDefault = true
}
],
SingleLogoutServiceUrls =
[
new SamlEndpointType
{
Location = "https://sp.example.com/saml/slo",
Binding = SamlBinding.HttpRedirect
}
],
// The signing behavior determines what gets signed.
// SignAssertion is the default and most interoperable.
SigningBehavior = SamlSigningBehavior.SignAssertion,
AllowedScopes = ["openid", "profile", "email"],
ClaimMappings = new Dictionary<string, string>
{
["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
},
// DefaultNameIdFormat is a string — use SamlConstants.NameIdentifierFormats
DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress
}
])
.AddTestUsers(
[
new Duende.IdentityServer.Test.TestUser
{
SubjectId = "1",
Username = "alice",
Password = "password",
Claims =
[
new("name", "Alice Smith"),
new("email", "alice@example.com")
]
}
]);
var app = builder.Build();
app.UseIdentityServer();
// Root endpoint fetches SAML metadata and OIDC JWKS, extracts certificates, and verifies they match
app.MapGet("/", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
{
var request = context.Request;
var baseUrl = $"{request.Scheme}://{request.Host}";
var samlMetadataUrl = $"{baseUrl}/Saml2";
var jwksUrl = $"{baseUrl}/.well-known/openid-configuration/jwks";
using var httpClient = httpClientFactory.CreateClient();
// Fetch SAML metadata and extract certificate
var metadataXml = await httpClient.GetStringAsync(samlMetadataUrl);
var doc = XDocument.Parse(metadataXml);
XNamespace ds = "http://www.w3.org/2000/09/xmldsig#";
var samlCertElement = doc.Descendants(ds + "X509Certificate").FirstOrDefault();
var samlBase64 = samlCertElement?.Value.Trim();
if (string.IsNullOrEmpty(samlBase64))
{
return Json(new
{
Error = "No certificate found in SAML metadata",
SamlMetadataUrl = samlMetadataUrl
});
}
var samlCertBytes = Convert.FromBase64String(samlBase64);
var samlCert = X509CertificateLoader.LoadCertificate(samlCertBytes);
// Fetch JWKS and extract certificate from x5c
var jwksJson = await httpClient.GetStringAsync(jwksUrl);
var jwksDoc = System.Text.Json.JsonDocument.Parse(jwksJson);
var keys = jwksDoc.RootElement.GetProperty("keys");
X509Certificate2? oidcCert = null;
foreach (var key in keys.EnumerateArray())
{
if (key.TryGetProperty("x5c", out var x5c) && x5c.GetArrayLength() > 0)
{
var oidcBase64 = x5c[0].GetString();
if (!string.IsNullOrEmpty(oidcBase64))
{
var oidcCertBytes = Convert.FromBase64String(oidcBase64);
oidcCert = X509CertificateLoader.LoadCertificate(oidcCertBytes);
break;
}
}
}
if (oidcCert is null)
{
return Json(new
{
Error = "No x5c certificate found in JWKS",
JwksUrl = jwksUrl,
SamlCertificate = new
{
samlCert.Subject,
samlCert.Thumbprint
}
});
}
var certificatesMatch = samlCert.Thumbprint == oidcCert.Thumbprint;
return Json(new
{
CertificatesMatch = certificatesMatch,
Message = certificatesMatch
? "SAML and OIDC use the same X.509-wrapped signing key"
: "WARNING: Certificates differ!",
SamlCertificate = new
{
Source = samlMetadataUrl,
samlCert.Subject,
samlCert.Issuer,
samlCert.Thumbprint,
samlCert.NotBefore,
samlCert.NotAfter,
samlCert.SerialNumber,
SignatureAlgorithm = samlCert.SignatureAlgorithm.FriendlyName
},
OidcCertificate = new
{
Source = jwksUrl,
oidcCert.Subject,
oidcCert.Issuer,
oidcCert.Thumbprint,
oidcCert.NotBefore,
oidcCert.NotAfter,
oidcCert.SerialNumber,
SignatureAlgorithm = oidcCert.SignatureAlgorithm.FriendlyName
}
});
});
app.Run(); Run this with dotnet run Program.cs, then check these endpoints:
-
http://localhost:5000/verifies both certificates match (JSON response) -
http://localhost:5000/Saml2returns SAML IdP metadata with the X.509 certificate -
http://localhost:5000/.well-known/openid-configuration/jwksreturns OIDC JWKS withx5c
The root endpoint fetches both metadata documents, extracts the certificates, and confirms they share the same thumbprint. Same key, same rotation schedule, one configuration.
Tradeoffs to Consider
This approach unifies SAML and OIDC key management, but it does change behavior for both protocols:
| Consideration | What It Means |
|---|---|
| OIDC JWTs include certificate headers | JWTs will have x5c and x5t claims. Most clients ignore these and validate via n/e parameters, but minimal JWT libraries might not expect them. |
| Larger JWKS payload | The JWKS endpoint includes the full certificate, not just the public key parameters. Negligible for typical deployments. |
| Self-signed certificates only | Automatic key management generates self-signed certificates. Most SAML SPs validate via metadata exchange (not CA trust chains), so this works. If an SP strictly requires CA-issued certificates, you'll need a custom ISamlSigningService. |
| Unified rotation lifecycle | OIDC and SAML share the same key. You can't rotate them independently if you need different lifetimes. |
When this works well: You're running both OIDC and SAML from the same IdentityServer, your SPs accept self-signed certificates, and you want one fewer thing to manage.
When to avoid it: OIDC-only deployments (no benefit), strict CA requirements from SPs, or you need independent key rotation schedules.
Going to Production
For multi-instance deployments, the managed keys need shared storage:
| Concern | Recommendation |
|---|---|
| Key storage | Use EF Core operational store or a shared file system |
| Data Protection | Configure shared key persistence and encryption |
| Health checks | Monitor key rotation events in logs |
If your /Saml2 metadata shows empty <ds:X509Data> elements:
- Delete the
keys/directory (stale keys from before enablingUseX509Certificate) - Restart the application
- Check console output for key management errors
With this setup, certificate management drops off your maintenance list. No more calendar reminders, no more coordinating rollover windows with SP administrators, no more 3 AM pages when someone forgot to renew. Your SAML signing certificates rotate on the same schedule as your OIDC keys, with the same propagation window that gives downstream systems time to update.
Read more about automatic key management and SAML 2.0 configuration in the documentation.