Table of Contents

SAML signatures and encryption

What this server signs SAML messages with, what it accepts a partner's signatures in, who must sign, and how assertions are encrypted — in both roles, identity provider and service provider. The code on this page is included from samples/Server.Saml. Which key signs is on the SAML identity provider page.

Signature algorithms

RSA and elliptic curve, as of 0.62.0, in both roles and on both bindings — this server signs with whichever key its certificate holds and verifies whichever key a partner's does. Six signature methods are accepted (rsa-sha256/384/512, ecdsa-sha256/384/512) and three digest methods (SHA-256/384/512); SHA-1 is in neither list. When signing, RSA keeps SHA-256 whatever its modulus, because that is what existing partners are configured to verify; an EC key takes its hash from its curve — P-256 with SHA-256, P-384 with SHA-384, P-521 with SHA-512.

The list stays pinned in code rather than configurable. Narrow and pinned is a deliberate posture for this server, and what 0.62.0 changed is the shape of the narrowing, not the principle: before it, a partner whose certificate was EC could not be talked to at all. Every verification path reached for GetRSAPublicKey() and treated null as a failed check, so an EC certificate was not a weaker signature — it was no signature, refused as invalid_signature by a verifier that never looked at it, and indistinguishable from a forgery.

The key must be the family the message named. With one accepted algorithm, "the method is the one we pinned" and "the key is the kind we expect" were the same sentence; with six they are not. A verifier checking only the first would hand an RSA key a signature made by an EC one and let the crypto layer decide what that means, so the pair is checked together.

.NET ships no ECDSA support for XML signatures. SignedXml resolves a SignatureDescription through CryptoConfig, which knows RSA and DSA only, so this package registers three of its own at first use — process-global state, stated here because a host that registers its own for the same three URIs will find whichever ran last. Registering was preferred over verifying outside SignedXml, because the alternative means recomputing reference digests and canonicalising SignedInfo by hand, and the signature-wrapping defences are built on exactly that machinery.

What a redirect signature covers

On the HTTP-Redirect binding the signature is detached: it covers the query string's message parameter, then RelayState when sent, then SigAlg (SAML Bindings §3.4.4.1). Since 0.178.0 it is verified over the parameter the endpoint acts on — SAMLRequest at single sign-on, at single logout for a request and at the service provider's logout endpoint, SAMLResponse at single logout for a response — and a query that carries both message parameters, or any signed parameter twice, does not verify (SamlRedirectSignature.Read(query, messageParameter); the one-argument Read refuses the same ambiguity). Until then the signature was checked over whichever message came first while the endpoint read SAMLRequest: a LogoutResponse a partner had signed vouched for an AuthnRequest it never sent. Duende 8.0.1's HttpRedirectBinding refuses the same queries, measured.

Who must sign, and with what

// Narrows what this partner's signatures may use: a signature method and a digest method must both be named.
new SamlServiceProvider
{
    EntityId = "https://strict.example.com/saml",
    AssertionConsumerServiceUrl = "https://strict.example.com/saml/acs",
    RequestSigningCertificate = requestSigning,
    AllowedScopes = { "openid" },
    AllowedSignatureAlgorithms =
    {
        SamlConstants.SignatureAlgorithms.RsaSha512,
        SamlConstants.SignatureAlgorithms.Sha512,
    },
},

New in 0.154.0, Duende 8.0.1's inbound signature policy, measured from its SingleLogoutServiceEndpoint.ValidateResponseTrustAsync, ServiceProviderEntityResolver, SignedXmlHelper and redirect binding:

  • SamlOptions.RequireSignedLogoutResponses (true) and SamlServiceProvider.RequireSignedLogoutResponses (null takes the option): read at the single logout endpoint for a LogoutResponse only — a partner's answer in a sequence, and an answer from the signed-out page's iframe — as Duende's endpoint reads them. False lets an answer with no signature at all through, from a registered provider with or without a certificate; an answer carrying a signature is still held to it, as Duende's reader holds it. With nothing set, every LogoutResponse must be signed, as before. The sample's legacy partner turns it off for itself alone (its registration).
  • SamlServiceProvider.AllowedSignatureAlgorithms (empty): read wherever the provider's signature is checked — its AuthnRequests, LogoutRequests and LogoutResponses, on both bindings — as Duende's resolver hands it to its reader and its redirect binding. Duende's single list: an enveloped signature's signature method and digest method must each be named, so a list naming no digest method refuses every enveloped signature, as Duende's does; a redirect-bound signature's SigAlg must be named. For a LogoutRequest it is part of HasTrustedSignature, so no step of a validator's can trust a method the provider does not allow.
  • Not breaking: nothing changes until you set one.

Ours: the list only narrows — a method must also be one this server pins (RSA and ECDSA with SHA-256, -384 or -512), where Duende's list replaces its defaults and so can admit SHA-1; here naming SHA-1 admits nothing. An unsigned answer from an issuer that is not registered is refused whatever the option says, where Duende falls back to the option. A refused answer is a 400 with a failure event, as before; Duende's is a 200 and a log line.

A LogoutRequest matched to the session it would end

// Every LogoutResponse must be signed (the default, as Duende's), and — opt-in here, where Duende always
// checks — a LogoutRequest must name a NameID this browser holds a session with that provider for.
options.RequireSignedLogoutResponses = true;
options.MatchLogoutRequestToSession = true;

New in 0.159.0, opt-in, Duende 8.0.1's LogoutRequestValidator.ValidateSessionAsync, measured from its IL. Off by default; a partner's own MatchLogoutRequestToSession (null takes the option) decides for it.

  • What it does. For a request whose signature verified, from a user signed in here: no NameID is refused (name_id_required; Duende's "LogoutRequest must contain a NameID"); a NameID that matches none of the requesting provider's SAML sessions in this browser, compared ordinally, is refused (name_id_mismatch; Duende's "NameID does not match any active session") — the error page and a SamlLogoutFailureEvent, and the session stays; a provider this browser holds no SAML session with, or a SessionIndex naming none of that NameID's sessions, is answered Success without ending the session (SessionFound = false, which the endpoint has answered so since 0.153.0). Nobody signed in passes. Logged with Duende's messages, names and levels (Warning for the mismatch, Information for the other two). The NameID match is what keeps a request captured from one user's sign-out from ending another user's session when it is replayed through their browser.
  • Where the switch is read: inside the step. ValidateSessionAsync stays protected virtual, so an override of it replaces Duende's checks whatever the switch says, as an override of Duende's does. It needs no guarding from one: it is reached only by a request that verified, and it can only refuse a request or leave a session running. An untrusted request is still read no further and never ends a session by itself.
  • ValidatedLogoutRequest.Subject (Duende's): the user signed in to the session, which the endpoint reads from the session's cookie scheme as the sign-on endpoint does; null when nobody is, or the host has no session scheme.
  • A validator of your own chains to the constructor that takes the user's session — see what an inbound message must be.
  • Not breaking: off by default, so no request is answered differently until you set it; the members are new, and the old constructors stay.

Ours, decided 2026-10-14: the checks are opt-in, where Duende always makes them, because each changes what this server does today with a verified request — it ends the session. This server's error codes, with its own descriptions, rather than Duende's Requester status and its text.

Encrypted assertions

Both directions, as of 0.55.0. Outbound, a service provider with an encryption certificate — Certificates with KeyUse.Encryption, or the EncryptionCertificate shorthand — is sent an <EncryptedAssertion>; one without it is sent a plain assertion, which is what partners integrated before 0.55.0 expect, so adding the package does not change what an existing partner receives. Inbound, SamlSpOptions takes a DecryptionCertificate, and RequireEncryptedAssertion refuses a partner that stops encrypting rather than quietly accepting either shape (this server as a service provider).

A fresh AES-256 content key per assertion, RSA-OAEP wrapped to the recipient. Neither is configurable: RSA-1.5 is the padding Bleichenbacher's attack targets and an endpoint that accepts assertions from the network is exactly the decryption oracle it needs, so this server refuses to read that padding — and therefore must never write it.

Encryption is RSA-only and stays that way. Not an omission: the algorithm is RSA-OAEP key transport and elliptic curve has no key transport — the EC equivalent is ECDH key agreement, a different EncryptedKey shape and a different thing to get wrong. Signing and encryption are separate capabilities that happen to both take a certificate.

The assertion is signed and then encrypted, in that order, so the signature travels inside the ciphertext. That is what lets the receiver verify the plaintext that actually came out rather than an envelope somebody wrapped around it, and it is why SamlResponseValidator decrypts before it verifies. The two sides are only correct together.

If your partner publishes metadata, SamlMetadataReader reads their encryption key straight out of it:

/// <summary>A partner registered from the metadata it publishes, its encryption key included.</summary>
public static async Task<SamlServiceProvider> FromMetadataAsync(HttpClient http, Uri partnerMetadata, CancellationToken ct)
{
    var partner = SamlMetadataReader.Read(await http.GetStringAsync(partnerMetadata, ct));

    return new SamlServiceProvider
    {
        EntityId = partner.EntityId,
        AssertionConsumerServiceUrl = partner.Sp!.AssertionConsumerServices[0].Location,
        EncryptionCertificate = partner.Sp.EncryptionCertificates.Count > 0 ? partner.Sp.EncryptionCertificates[0] : null,
        AllowedScopes = { "openid", "profile", "email" },
    };
}

A KeyDescriptor marked use="encryption" is read as an encryption key and never offered as one to verify a signature against; a descriptor with no use answers both, which is what SAML metadata §2.4.1.1 means by making the attribute optional.