Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Unify Your SAML and OIDC Signing Keys with Automatic Rotation and Duende IdentityServer

Two blue circles
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/Saml2 returns SAML IdP metadata with the X.509 certificate
  • http://localhost:5000/.well-known/openid-configuration/jwks returns OIDC JWKS with x5c

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:

  1. Delete the keys/ directory (stale keys from before enabling UseX509Certificate)
  2. Restart the application
  3. 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.

Related Articles