The Problem That Started This
We were building a healthcare connectivity platform — multi-tenant, Azure-hosted, integrating with enterprise IdPs via SAML 2.0. The stack was .NET 10, Angular 21, Azure SQL. Standard stuff.
When the SSO requirement came in, the instinct was to reach for one of the usual suspects: Microsoft.Identity.Web, a hosted OpenID Connect flow, or something sitting on top of IdentityServer. We tried each. They all work fine if your scenario fits their opinionated model. Ours didn't.
The first wrinkle was that SAML 2.0 is not natively supported by ASP.NET Core's auth middleware — you have to pull in third-party libraries like ComponentSpace.Saml2, Sustainsys.Saml2, or similar. These are not small. They carry entire middleware pipelines, NuGet trees, and assumptions about how you've wired your host. For a greenfield platform where we controlled everything end-to-end, that overhead felt like the wrong tradeoff.
The second problem was testing. Most SAML libraries are tightly coupled to HttpContext. Running an assertion round-trip in a unit test meant mocking the entire request pipeline, which is neither fast nor reliable.
The third — and this one bit us directly — was a security gap. During an audit of our SSO implementation, I identified a SAML token replay vulnerability: the IdP responses lacked assertion ID tracking. An attacker who intercepted a valid SAMLResponse could replay it within its validity window and authenticate as that user. Fixing it in a third-party library meant either patching or wrapping in ways that felt fragile.
That's when I decided to write AutoStack.Identity from scratch.
Background: What Existing Solutions Provided (and Didn't)
The .NET identity ecosystem has matured a lot in recent years, but it's optimized for certain shapes of problems:
-
System.IdentityModel.Tokens.Jwthandles JWT nicely, but it's tied toClaimsPrincipalandSecurityTokenabstractions that add weight when all you need is issue-and-verify. -
Microsoft.Identity.Webis excellent for Azure AD and OIDC but SAML support is absent by design. - Sustainsys.Saml2 and ComponentSpace both work, but they install themselves into the middleware pipeline and make testing hard. They also assume you want ASP.NET Core integration, not a portable library.
-
System.Security.Cryptography.Xmlis the underlying BCL primitive for XML signing — available since .NET Framework — but using it directly for SAML is painful because of namespace canonicalization issues, signature insertion placement, and the need to preserve whitespace semantics.
The gap I was targeting: a library that provides these primitives as pure, testable helpers — no middleware, no DI framework dependency, no ASP.NET coupling. Just a class you can instantiate anywhere and call.
What AutoStack.Identity Does
At its core the library is three things:
JWT issuance and validation with pluggable signing algorithms (RS256/384/512, ES256/384/512, HS256/384/512).
SAML 2.0 SP flows — building AuthnRequest XML for SP-initiated SSO, and parsing SAMLResponse payloads back into typed models.
SAML 2.0 IdP flows — building signed assertions and Response documents, useful for testing IdP behavior or running a custom IdP.
XML digital signing and verification — the underlying layer powering SAML signing, also exposed directly if you need to sign arbitrary XML documents.
The only external NuGet dependency is System.Security.Cryptography.Xml. Everything else comes from the BCL.
Architecture and Design Decisions
Crypto Behind Interfaces
The most deliberate structural decision was keeping cryptographic implementations completely separate from the protocol logic.
public interface IJwtSigner
{
string Algorithm { get; }
byte[] Sign(byte[] data);
}
public interface IJwtVerifier
{
bool Verify(byte[] data, byte[] signature);
}
JwtIssuerHelper and JwtValidationHelper accept these interfaces. They have no idea whether the backing key is RSA, ECDSA, or HMAC. The three concrete implementations (RsaJwtSigner, EcdsaJwtSigner, HmacJwtSigner) each handle their specific crypto, and all three implement both IJwtSigner and IJwtVerifier where applicable.
This matters for testing. You can implement IJwtSigner with a trivial fake that returns a fixed signature and write tests that focus entirely on claim construction, expiry logic, or audience validation — without touching real cryptographic keys.
The same pattern applies to XML signing via IXmlSigner, and to SAML response parsing via ISamlSpProvider.
TimeProvider Injection Everywhere
Every class that touches time takes TimeProvider? timeProvider = null in its constructor. When null, it defaults to TimeProvider.System.
public JwtIssuerHelper(JwtIssuerOptions options, IJwtSigner signer, TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
This is the .NET 8+ abstraction for abstractable time — it lets tests inject a fake time source and make deterministic assertions about expiry and NotBefore enforcement without Thread.Sleep or brittle timestamp comparisons. This pattern is throughout the library: JwtIssuerHelper, JwtValidationHelper, SamlSpHelper, SamlIdpHelper, CertificateExpiryRule.
XML Verification as a Composable Pipeline
Rather than writing a monolithic verification method, XML signature verification is structured as a pipeline of independent rules:
var pipeline = XmlVerificationPipelineBuilder.Create()
.AddRule(new SignatureVerificationRule())
.AddRule(new CertificateExpiryRule())
.AddRule(new CertificateTrustRule())
.WithFailFast(true)
.Build();
var result = await pipeline.VerifyAsync(context);
Each rule implements IXmlVerificationRule — a single async method that takes a context and returns a VerificationResult. Rules communicate through a shared Bag dictionary on the context. SignatureVerificationRule runs first and stores the extracted certificate in the bag under a typed key. CertificateExpiryRule and CertificateTrustRule read from that key rather than re-extracting the certificate.
// In SignatureVerificationRule:
context.Bag[BagKey] = cert;
// In CertificateExpiryRule:
var cert = context.GetBagValue<X509Certificate2>(SignatureVerificationRule.BagKey);
The pipeline can be configured with FailFast — stop on first failure — or collect all failures and return them together. Custom rules can be added without modifying the library.
The XmlBuilder
Working with System.Xml.XmlDocument directly while building SAML is frustrating. Namespace declarations bleed across elements, attribute order is unpredictable, and it's easy to produce XML that is syntactically valid but doesn't survive canonicalization the way SAML processors expect.
I wrote a thin fluent builder that separates the description of a document from its serialization:
var doc = XmlBuilder
.Create("AuthnRequest", SamlConstants.Ns.Protocol)
.WithPrefix(SamlConstants.Prefix.Protocol)
.WithAttribute("ID", requestId)
.WithAttribute("Version", "2.0")
.WithAttribute("IssueInstant", issuedAt.UtcDateTime)
.WithChild("Issuer", SamlConstants.Ns.Assertion, b => b
.WithPrefix(SamlConstants.Prefix.Assertion)
.WithText(_options.EntityId))
.BuildDocument();
Each WithChild call takes a lambda for configuration, so nesting stays readable without the call depth exploding. The builder first constructs an intermediate XmlElementNode tree (plain records with no DOM involvement), then materializes it into a XmlDocument in a single pass. This makes the structural intent easy to follow and keeps the messy doc.CreateElement(prefix, localName, ns) calls isolated to one method.
Implementation Details
JWT Issuance — Staying Close to the Spec
The JWT implementation in JwtIssuerHelper.Issue() deliberately avoids abstractions layered on top of the spec:
var headerEncoded = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(BuildHeader()));
var payloadEncoded = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(BuildPayload(descriptor, now, expires, jti)));
var signingInput = $"{headerEncoded}.{payloadEncoded}";
var signature = _signer.Sign(Encoding.ASCII.GetBytes(signingInput));
return $"{signingInput}.{Base64UrlEncode(signature)}";
That's the entire RFC 7519 issuance path: build header, build payload, base64url-encode both, sign the concatenation with a period separator, append the base64url-encoded signature. Nothing hidden.
BuildPayload constructs a Dictionary<string, object> using the registered claim names as constants, then merges in any extra claims from the JwtDescriptor. Because this is serialized by System.Text.Json rather than a JWT-specific library, the JSON output is predictable and doesn't carry extra envelope fields.
Timing-Safe HMAC Comparison
One detail worth calling out in HmacJwtSigner:
public bool Verify(byte[] data, byte[] signature)
{
var expected = _hmac.ComputeHash(data);
return CryptographicOperations.FixedTimeEquals(expected, signature);
}
CryptographicOperations.FixedTimeEquals was added in .NET Core 2.1 precisely to prevent timing attacks on MAC verification. A naive expected.SequenceEqual(signature) would short-circuit on the first mismatched byte, leaking timing information. This is the correct implementation.
ECDSA Signatures Use IEEE P1363 Format
For ECDSA JWT signing, the implementation uses DSASignatureFormat.IeeeP1363FixedFieldConcatenation:
return _ecdsa.SignData(data, _hashAlgorithm, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
This matters because .NET's default ECDSA output is DER-encoded, but the JWT spec (RFC 7518) requires the raw concatenation of r and s values (IEEE P1363). Using the wrong format produces tokens that will fail verification by any compliant JWT library. It's easy to get this wrong and not notice until another JWT consumer rejects your tokens.
Signature Insertion Mode
SAML is particular about where the <ds:Signature> element appears within the response XML. For SAML assertions specifically, the signature must follow the <saml:Issuer> element (i.e., the second child position). The SignatureInsertionMode enum encodes the three common positions:
public enum SignatureInsertionMode
{
AppendToRoot,
AfterFirstChild, // used for SAML assertions
PrependToRoot
}
The SAML helpers pass InsertionMode = SignatureInsertionMode.AfterFirstChild when calling the signer. Without this, some IdP and SP implementations will reject an otherwise valid signature simply because the element order doesn't match their schema expectations.
Challenges and Trade-offs
"No External Dependencies" Has a Cost
The decision to avoid NuGet dependencies beyond the BCL means implementing things that you'd normally get for free. Base64Url encoding and decoding, for instance:
internal static string Base64UrlEncode(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
internal static byte[] Base64UrlDecode(string input)
{
var padded = input.Replace('-', '+').Replace('_', '/');
padded = (padded.Length % 4) switch
{
2 => padded + "==",
3 => padded + "=",
_ => padded
};
return Convert.FromBase64String(padded);
}
Not complex, but code that needs to exist and be tested. The payoff is zero transitive dependency issues at the call site.
XML Canonicalization Is Not Forgiving
The XML signature standard (XMLDsig) requires exclusive canonicalization (Exc-C14N) before signing. This means the serialized form of the document matters — whitespace, namespace declarations, attribute order. Even the difference between PreserveWhitespace = true and false on XmlDocument can break a signature.
The X509XmlSigner.SignAsync method clones the document before signing to avoid mutating the caller's document:
var workingDoc = CloneDocument(document);
And the SAML parser sets PreserveWhitespace = true explicitly:
var doc = new XmlDocument { PreserveWhitespace = true };
Getting the whitespace handling wrong produces documents that fail signature verification even when the certificate and key are correct. It took more than a few "but the cert is fine" debugging sessions to pin this down.
The Bag Pattern Has a Weak Contract
The Dictionary<string, object> Bag on XmlVerificationContext is the inter-rule communication mechanism. It works, but it's stringly-typed on the key side. The mitigation is using const string bag keys defined on the rule that produces the value:
public const string BagKey = "SignatureVerificationRule.SignerCertificate";
Consuming rules reference that constant rather than duplicating the string. It's still more fragile than a typed property would be, but adding a typed property to the context would mean the context knows about specific rules — which breaks the pipeline's extensibility.
What I Learned
Own your security-critical abstractions. When you need to add assertion ID tracking to prevent token replay, it's a two-line change in your own library. In someone else's library, it might be a support ticket or a fork.
TimeProvider is worth using everywhere time touches logic. Once you've written a validation test that didn't require a Thread.Sleep, you won't go back.
SAML 2.0 is actually not that scary. The spec is verbose but the SP-initiated SSO flow is well-defined: generate an AuthnRequest, redirect the user to the IdP, receive a SAMLResponse at your ACS URL, verify the signature, extract the NameID and attributes. The implementation fits in a few hundred lines when you're not also shipping middleware.
Don't serialize to XmlDocument and then sign. Build first, sign once. The signer operates on the document after construction. The XmlBuilder generates a clean document without extraneous namespace declarations, and the signer transforms a clone — so the original is never touched.
Sealed records for descriptors and options. Using sealed record with required init-only properties for things like JwtDescriptor, SamlSpOptions, and JwtIssuerOptions catches misconfiguration at object construction rather than at runtime. Combined with nullable reference types enabled, it's hard to accidentally build a half-initialized configuration object.
Future Improvements
A few things I'd like to add that weren't blocking the production use case:
Assertion ID store for replay prevention. The library currently builds SAML assertions with unique IDs (_{Guid.NewGuid():N}) and enforces NotBefore/NotOnOrAfter boundaries, but the assertion ID tracking store needs to be implemented by the consuming application. Adding an IAssertionIdStore interface with an in-memory and Redis implementation would make replay prevention easier to wire up correctly.
SAML metadata parsing. Currently you configure IdP endpoints manually in SamlSpOptions. Parsing the standard EntityDescriptor XML from an IdP's metadata URL would reduce the manual configuration surface.
JWK (JSON Web Key) support. The library currently deals with raw key material and certificates. Adding support for JWKS endpoints would make it easier to use in systems that publish public keys via a /.well-known/jwks.json endpoint.
NuGet package. The library is open-source and consumed directly today. Publishing it as a package on NuGet would reduce the friction for anyone who wants to use it without adding a submodule.
Conclusion
AutoStack.Identity isn't trying to replace Microsoft.Identity.Web or a full-featured SAML middleware. It's a different thing: a set of precise, testable primitives for teams that need direct control over how JWT tokens and SAML assertions are constructed, signed, and validated.
The constraints were real — healthcare data, enterprise IdP integration, a hard security finding to address. Writing the library rather than adopting an existing one turned out to be the right call for this context. Not because existing libraries are bad, but because owning the implementation meant the token replay fix was a two-hour task rather than a multi-library investigation.
The entire thing is a single .NET 10 class library. One external NuGet dependency. Works in any context that can reference a class library — ASP.NET Core, Azure Functions, console apps, test runners.
Top comments (0)