XML in Production · Security and Integration

The Signed Message That Failed Verification

A payment message signed by the sender failed verification after a gateway pretty-printed it. Byte-level hashing of XML broke on harmless formatting changes.

Create a free account to save this page, take notes and track your progress. Sign in

Scenario

Example team: a payments integration signs each XML instruction by hashing the raw bytes and sending the hash in a header. A new API gateway "normalises" bodies: it reorders attributes alphabetically and reformats empty elements.

What you see in production

All signature checks fail after the gateway rollout; payments queue up. Turning verification off "temporarily" is proposed in the incident channel.

The naive code

signature = hmac(key, raw_body_bytes)       # any change in formatting changes the hash

Reproduce it

Raw bytes vs canonical XMLTry it yourself
import hashlib
from lxml import etree

sent = b'<payment currency="EUR" amount="250.00"><payee iban="DE89370400440532013000"></payee></payment>'
received = b'<payment amount="250.00"   currency="EUR">\n<payee iban="DE89370400440532013000"/></payment>'
tampered = b'<payment amount="2500.00" currency="EUR"><payee iban="DE89370400440532013000"/></payment>'

def raw(data):
    return hashlib.sha256(data).hexdigest()[:16]

def canonical(data):
    doc = etree.fromstring(data, etree.XMLParser(remove_blank_text=True))
    return hashlib.sha256(etree.tostring(doc, method="c14n")).hexdigest()[:16]

for name, data in (("sent", sent), ("reformatted", received), ("tampered", tampered)):
    print(f"{name:12} raw={raw(data)} c14n={canonical(data)}")
print(etree.tostring(etree.fromstring(received, etree.XMLParser(remove_blank_text=True)), method="c14n").decode())

Output

sent         raw=7b690a9fa9831752 c14n=6a8f0ac430108c3d
reformatted  raw=5e1e3e1ee90473a4 c14n=6a8f0ac430108c3d
tampered     raw=5e99d60490dfce4c c14n=c4f226587a5153c6
<payment amount="250.00" currency="EUR"><payee iban="DE89370400440532013000"></payee></payment>

Root cause

XML has many byte representations of the same information: attribute order, quote style, <a></a> vs <a/>, whitespace inside tags, namespace declarations. Hashing raw bytes makes any intermediary that re-serialises XML break the signature. Hashing after parsing without a defined form is worse: different libraries serialise differently.

The fix

  • Use Canonical XML (C14N, or Exclusive C14N for signed fragments) before hashing: a single defined serialisation.
  • For real interoperability use XML Signature (XMLDSig) through a maintained library (xmlsec/python-xmlsec, Apache Santuario, .NET SignedXml), which specifies canonicalisation and references.
  • Verify the signed element is the one you process. "Signature wrapping" attacks move the signed element and insert an unsigned one where the application reads; look up signed content by the reference ID, never by a separate XPath.
  • Don't let gateways rewrite signed bodies; mark them as pass-through.

Trade-offs

  • Canonicalisation costs CPU and must match on both sides (C14N 1.0 vs 1.1 vs exclusive, with or without comments).
  • Insignificant whitespace in mixed content can be significant; agree whether to strip it.

Detect it in production

  • Signature failure rates per sender and per intermediary.
  • Alerts when a body's bytes change between the edge and the application (compare hashes in logs).

Key points

  • XML signatures need canonical XML, not raw bytes.
  • Use a vetted XMLDSig library, and process exactly the element that was signed.
  • Never "fix" verification failures by turning verification off.

Practice questions

Why do XML signatures use canonicalization?

Answer

The same XML can be serialised in many ways (attribute order, empty-element syntax, whitespace in tags, namespace declarations). C14N defines one byte representation, so harmless re-serialisation doesn't change the digest while real changes do.

What is an XML signature wrapping attack?

Answer

The attacker keeps the validly signed element but moves it, and inserts an unsigned element where the application reads its data. If verification and processing locate elements differently, the forged data is used. Process only the element referenced by the signature.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?