X.509 certificates are not only used for authentication — we saw them in articles #9 and #10 in the role of identity for TLS connections. The same cryptographic mechanism underlies digital signatures on documents: you prove that a document was issued by you and that it has not been altered after signing.
In the Romanian context, the subject becomes concrete and urgent with e-Invoice — the national electronic invoicing system where XML documents must be digitally signed to be valid in B2B relationships. But XML signing has much broader applicability: digital contracts, messages between services, tax documents, API responses that must be non-repudiable.
In this article, we build XML signing in .NET from scratch, step by step.
1. What is an XML Digital Signature (XMLDSig)?
XML Digital Signature (XMLDSig) is a W3C standard that defines how to sign XML content — or any other content referenced from an XML document. The standard describes a <Signature> structure that contains:
- SignedInfo — what was signed and how (canonicalization algorithm, signing algorithm, content references)
- SignatureValue — the cryptographic value of the signature (hash signed with the private key)
- KeyInfo — information about the public key / certificate used (optional, but recommended)
- Object — additional data, including timestamps in XAdES
Three types of XMLDSig signatures
Enveloped — the signature is included inside the signed document. Most common for self-contained XML documents (invoices, contracts).
<Invoice>
<Header>...</Header>
<Lines>...</Lines>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<!-- the signature is embedded in the document -->
</Signature>
</Invoice>
Enveloping — the signed document is included inside the signature, as an <Object> element.
Detached — the signature is a separate XML document that references external content via URI (URL, hash, etc.).
For e-Invoice and tax documents you will almost always use enveloped.
XMLDSig vs. XAdES
XMLDSig is the basic standard — simple cryptographic signature. XAdES (XML Advanced Electronic Signatures) extends XMLDSig with:
- Cryptographic timestamp (the signature cannot be backdated)
- Revocation information included in the signature (OCSP response, CRL)
- Long-term archiving (the signature remains valid even after certificate expiration)
XAdES is required in scenarios with high legal compliance. For this article, we build on XMLDSig — understanding the basics, XAdES becomes a natural extension.
2. XML Canonicalization — why it matters
Before signing, the XML must be canonicalized — transformed into a standard, deterministic form. The same XML document can be represented in dozens of semantically equivalent ways:
<!-- These two fragments are semantically identical -->
<Invoice id="1" date="2025-01-01"/>
<Invoice date="2025-01-01" id="1"></Invoice>
If you sign the first and validate the second, the signature would fail — the hashes would be different. Canonicalization solves this: before computing the hash, the XML is brought to a unique form (sorted attributes, normalized whitespace, explicit namespaces, etc.).
Main canonicalization algorithms:
http://www.w3.org/TR/2001/REC-xml-c14n-20010315— Canonical XML 1.0 (most used)http://www.w3.org/2001/10/xml-exc-c14n#— Exclusive Canonical XML (better suited when the document is included in another XML)
In .NET, SignedXml handles canonicalization automatically — but you need to know it exists to understand why the signature fails if the document is reformatted.
3. Enveloped signing with X.509 certificate
The central class in .NET is System.Security.Cryptography.Xml.SignedXml, available in System.Security.Cryptography.Xml (NuGet for .NET Core).
3.1 Package installation
dotnet add package System.Security.Cryptography.Xml
3.2 Signing an XML document
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography.Xml;
using System.Xml;
public static class XmlSigner
{
/// <summary>
/// Adds an enveloped signature to the XML document.
/// Returns the signed XML document.
/// </summary>
public static XmlDocument Sign(
XmlDocument document,
X509Certificate2 certificate)
{
if (!certificate.HasPrivateKey)
throw new InvalidOperationException(
"The certificate does not contain the private key.");
// Extract the RSA private key from the certificate
var rsaKey = certificate.GetRSAPrivateKey()
?? throw new InvalidOperationException(
"Could not extract the RSA key from the certificate.");
var signedXml = new SignedXml(document)
{
SigningKey = rsaKey
};
// Signing algorithm: RSA with SHA-256
signedXml.SignedInfo.SignatureMethod =
SignedXml.XmlDsigRSASHA256Url;
// Canonicalization algorithm
signedXml.SignedInfo.CanonicalizationMethod =
SignedXml.XmlDsigExcC14NTransformUrl;
// Reference to the entire document ("") with enveloped transform
var reference = new Reference
{
Uri = "", // sign the whole document
DigestMethod = SignedXml.XmlDsigSHA256Url
};
// Enveloped transform — excludes the <Signature> element from the hash
// (otherwise we would sign the signature itself, a vicious circle)
reference.AddTransform(new XmlDsigEnvelopedSignatureTransform());
reference.AddTransform(new XmlDsigExcC14NTransform());
signedXml.AddReference(reference);
// Include the certificate in KeyInfo
// — the validator can extract the public key from the document
var keyInfo = new KeyInfo();
keyInfo.AddClause(new KeyInfoX509Data(certificate));
signedXml.KeyInfo = keyInfo;
// Compute and add the signature to the document
signedXml.ComputeSignature();
var signatureElement = signedXml.GetXml();
document.DocumentElement!.AppendChild(
document.ImportNode(signatureElement, deep: true));
return document;
}
}
3.3 Usage
// Load the XML document
var doc = new XmlDocument { PreserveWhitespace = true };
doc.Load("invoice.xml");
// Load the certificate with private key
var cert = new X509Certificate2(
"my-cert.pfx",
"password",
X509KeyStorageFlags.EphemeralKeySet);
// Sign
var signedDoc = XmlSigner.Sign(doc, cert);
// Save
signedDoc.Save("invoice-signed.xml");
PreserveWhitespace = true is critical. If it is
false, the XML parser may modify whitespace on load, changing the document hash — the signature will fail validation even if the document was not altered.
4. Signature validation
Validation checks two things: document integrity (the hash is correct) and signature authenticity (it was produced by the private key holder).
public static class XmlSignatureValidator
{
public static ValidationResult Validate(XmlDocument document)
{
var signedXml = new SignedXml(document);
// Find the <Signature> element in the document
var signatureNode = document.GetElementsByTagName(
"Signature", SignedXml.XmlDsigNamespaceUrl);
if (signatureNode.Count == 0)
return ValidationResult.Fail("The document does not contain a signature.");
signedXml.LoadXml((XmlElement)signatureNode[0]!);
// Extract the certificate from KeyInfo
X509Certificate2? signingCert = null;
foreach (KeyInfoClause clause in signedXml.KeyInfo)
{
if (clause is KeyInfoX509Data x509Data
&& x509Data.Certificates.Count > 0)
{
signingCert = (X509Certificate2)x509Data.Certificates[0]!;
break;
}
}
if (signingCert is null)
return ValidationResult.Fail(
"No certificate found in KeyInfo.");
// Check that the certificate has not expired
if (DateTime.UtcNow < signingCert.NotBefore ||
DateTime.UtcNow > signingCert.NotAfter)
{
return ValidationResult.Fail(
$"The certificate has expired or is not yet valid. " +
$"Valid: {signingCert.NotBefore:u} — {signingCert.NotAfter:u}");
}
// Verify the signature with the public key from the certificate
var rsaKey = signingCert.GetRSAPublicKey()!;
var isValid = signedXml.CheckSignature(rsaKey);
if (!isValid)
return ValidationResult.Fail("The digital signature is not valid.");
return ValidationResult.Ok(
signingCert.Subject,
signingCert.Thumbprint);
}
}
public record ValidationResult(
bool IsValid,
string? SignedBy,
string? Thumbprint,
string? Error)
{
public static ValidationResult Ok(string signedBy, string thumbprint) =>
new(true, signedBy, thumbprint, null);
public static ValidationResult Fail(string error) =>
new(false, null, null, error);
}
Validation with trusted CA
The above validation checks the mathematical signature but does not verify that the certificate comes from a trusted CA. In a production scenario, you add chain validation:
// Add after mathematical signature validation
var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
if (!chain.Build(signingCert))
{
var errors = string.Join(", ",
chain.ChainStatus.Select(s => s.StatusInformation));
return ValidationResult.Fail(
$"Chain validation failed: {errors}");
}
5. Detached signing
Detached signature is useful when you want to sign an external file (PDF, CSV, anything), or when the signed document cannot be modified to include the signature.
public static XmlDocument SignDetached(
byte[] contentToSign,
X509Certificate2 certificate,
string contentUri = "document.xml")
{
var rsaKey = certificate.GetRSAPrivateKey()!;
// XML document that will contain ONLY the signature
var signatureDoc = new XmlDocument();
var signedXml = new SignedXml(signatureDoc)
{
SigningKey = rsaKey
};
signedXml.SignedInfo.SignatureMethod =
SignedXml.XmlDsigRSASHA256Url;
signedXml.SignedInfo.CanonicalizationMethod =
SignedXml.XmlDsigExcC14NTransformUrl;
// External reference — URI to the signed file
var reference = new Reference(contentUri)
{
DigestMethod = SignedXml.XmlDsigSHA256Url
};
// Provide the content directly (for hash calculation)
// instead of reading it from the URI at runtime
reference.AddTransform(new XmlDsigExcC14NTransform());
// Precomputed hash of the content
using var sha256 = SHA256.Create();
var contentHash = sha256.ComputeHash(contentToSign);
// Note: for external references with precomputed hash
// we use DataObject with manual hash
signedXml.AddReference(reference);
var keyInfo = new KeyInfo();
keyInfo.AddClause(new KeyInfoX509Data(certificate));
signedXml.KeyInfo = keyInfo;
signedXml.ComputeSignature();
// The signature document contains only <Signature>
signatureDoc.AppendChild(
signatureDoc.ImportNode(signedXml.GetXml(), true));
return signatureDoc;
}
6. Signing with RSA key without certificate
Sometimes you want to sign with a programmatically generated RSA key pair, without a full certificate — useful for tests or internal scenarios where you don't need a certification chain.
// Generate RSA key pair
using var rsa = RSA.Create(2048);
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml("<Root><Data>test</Data></Root>");
var signedXml = new SignedXml(doc) { SigningKey = rsa };
signedXml.SignedInfo.SignatureMethod =
SignedXml.XmlDsigRSASHA256Url;
var reference = new Reference("");
reference.AddTransform(new XmlDsigEnvelopedSignatureTransform());
reference.AddTransform(new XmlDsigExcC14NTransform());
reference.DigestMethod = SignedXml.XmlDsigSHA256Url;
signedXml.AddReference(reference);
// KeyInfo with the RSA public key (no certificate)
var keyInfo = new KeyInfo();
keyInfo.AddClause(new RSAKeyValue(rsa));
signedXml.KeyInfo = keyInfo;
signedXml.ComputeSignature();
doc.DocumentElement!.AppendChild(
doc.ImportNode(signedXml.GetXml(), true));
// Validate with the public key
var validator = new SignedXml(doc);
var sigNode = doc.GetElementsByTagName(
"Signature", SignedXml.XmlDsigNamespaceUrl)[0]!;
validator.LoadXml((XmlElement)sigNode);
var isValid = validator.CheckSignature(rsa); // true
7. XML signing in the context of e-Invoice
e-Invoice is Romania's national B2B electronic invoicing system, mandatory from 2024 for transactions with the state and gradually extended for B2B. Invoices are XML documents in UBL 2.1 format, compliant with the CIUS-RO profile.
From the signing perspective, relevant points are:
- XML documents sent through the e-Invoice system (ANAF) are structurally and semantically validated by ANAF — the digital signature is not technically mandatory at upload via API, but it is legally mandatory for the document's value as a valid electronic invoice
- The legally required signature is qualified (with a qualified certificate on a hardware token, issued by an accredited provider) — which is beyond the technical scope of this article
- From the .NET code perspective, the mechanism is identical to what we built above — the difference is the certificate source (hardware token via PKCS#11 instead of PFX file)
- For automated systems (ERP, invoicing SaaS), signing is done with a qualified certificate for automatic signing — issued by authorities like DigiSign, certSIGN, Trans Sped — which can be stored in HSM or Key Vault
Structure of a signed UBL invoice
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1</cbc:CustomizationID>
<cbc:ID>FACT-2025-0001</cbc:ID>
<cbc:IssueDate>2025-05-08</cbc:IssueDate>
<!-- ... invoice lines ... -->
<!-- XMLDSig signature added at the end -->
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>...</SignedInfo>
<SignatureValue>...</SignatureValue>
<KeyInfo>...</KeyInfo>
</Signature>
</Invoice>
The signature is added exactly as implemented in section 3 — as an enveloped element at the end of the document.
A word about XAdES and e-Invoice
The European standard EN 16931 (on which CIUS-RO is based) recommends XAdES-BES or XAdES-T for signatures with legal value. XAdES adds to simple XMLDSig the cryptographic timestamp — essential for the signature to remain valid even after the signer's certificate expires. Full XAdES implementation in .NET requires specialized libraries (PAdES, DSS) and is beyond the scope of this article.
8. Signing in ASP.NET Core — pipeline integration
In a web application that generates and signs XML documents on demand, the recommended structure:
// Dedicated service, injected via DI
public interface IXmlSigningService
{
XmlDocument Sign(XmlDocument document);
ValidationResult Validate(XmlDocument document);
}
public class XmlSigningService : IXmlSigningService
{
private readonly X509Certificate2 _signingCert;
public XmlSigningService(IConfiguration config)
{
// The certificate is loaded once at startup
// From Azure Key Vault in production
var certPath = config["Signing:CertificatePath"]!;
var certPassword = config["Signing:CertificatePassword"]!;
_signingCert = new X509Certificate2(
certPath, certPassword,
X509KeyStorageFlags.EphemeralKeySet |
X509KeyStorageFlags.PersistKeySet);
}
public XmlDocument Sign(XmlDocument document)
=> XmlSigner.Sign(document, _signingCert);
public ValidationResult Validate(XmlDocument document)
=> XmlSignatureValidator.Validate(document);
}
// Registration in Program.cs
builder.Services.AddSingleton
// Controller
[ApiController]
[Route("api/[controller]")]
public class InvoiceController(IXmlSigningService signer) : ControllerBase
{
[HttpPost("sign")]
public IActionResult SignInvoice([FromBody] string xmlContent)
{
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml(xmlContent);
var signed = signer.Sign(doc);
return Content(signed.OuterXml, "application/xml");
}
[HttpPost("validate")]
public IActionResult ValidateInvoice([FromBody] string xmlContent)
{
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml(xmlContent);
var result = signer.Validate(doc);
return result.IsValid
? Ok(new { result.SignedBy, result.Thumbprint })
: BadRequest(new { result.Error });
}
}
9. Common problems and their solutions
| Problem | Probable cause | Solution |
|---|---|---|
| Signature fails validation even though the document is intact | PreserveWhitespace = false on load |
Always set doc.PreserveWhitespace = true before Load() |
CryptographicException on signing on Linux |
Incompatible storage flags | Add X509KeyStorageFlags.EphemeralKeySet when loading the certificate |
| Signature mathematically valid but rejected by external system | Deprecated algorithm (SHA-1) or incorrect canonicalization | Use XmlDsigRSASHA256Url and XmlDsigExcC14NTransformUrl |
| Duplicate namespaces after signing | ImportNode adds namespaces already present | Use XmlDsigExcC14NTransform (Exclusive C14N) instead of standard C14N |
| Signature does not include the certificate | KeyInfo not populated |
Add KeyInfoX509Data with the full certificate, not just the public key |
| Error signing with certificate from Azure Key Vault | The private key cannot be exported from Key Vault | Use CryptographyClient.SignAsync() from Azure SDK for operations with non-exportable keys |
10. Production checklist
- ✅
PreserveWhitespace = trueon any XML load operation involving signing or validation - ✅ SHA-256 algorithm (not SHA-1) for DigestMethod and SignatureMethod
- ✅
XmlDsigExcC14NTransformUrlas canonicalization algorithm - ✅ Signing certificate stored in Azure Key Vault, not on disk
- ✅
EphemeralKeySetwhen loading certificates in Linux containers - ✅ Chain validation enabled in production (not just mathematical validation)
- ✅ Logging for any signing or validation failure with certificate details
- ✅ Tested with document modified after signing → validation must fail
- ✅ Tested with expired certificate → validation must fail with clear message
- ✅ Alert on signing certificate expiration (at least 30 days in advance)
Conclusion
XML signing in .NET is surprisingly accessible once you understand the two essential concepts: canonicalization (why the same XML can produce different hashes) and signature type (enveloped, enveloping, detached). The SignedXml class in the BCL covers common scenarios without any external dependency.
For scenarios with legal value — e-Invoice, electronic contracts, tax documents — the difference from what we implemented is the certificate source (qualified, issued by an accredited provider) and possibly adding a cryptographic timestamp (XAdES). The signing mechanism itself remains the same.
The security series continues with protection against CSRF, XSS, and Injection — a shift from cryptography articles to the most common threats in web applications.