When single sign-on breaks, the evidence is a wall of base64 squiggles. This guide unwraps a SAML message layer by layer — URL, encoding, compression, XML — and then reads the handful of fields that decide whether the login was ever going to work.
A SAML 2.0 response is an XML assertion — "this person authenticated, here's who they are, here's how long I vouch for them" — sent from an identity provider (IdP) to your service provider (SP). The XML never travels in the clear. It gets wrapped in layers chosen by the binding, and each layer has to come off in order:
SAMLRequest or SAMLResponse, URL-encoded on top of everything else.- and _ for + and /, usually without padding).<saml2p:Response>.The bindings differ in one crucial way, and it's the difference between "base64-decode worked" and "I got binary garbage":
| Binding | Wraps as | Compressed? | Where you find it |
|---|---|---|---|
| HTTP-POST | base64(XML) | No | Form field SAMLResponse in the POST to your ACS URL |
| HTTP-Redirect | base64url(DEFLATE(XML)) | Yes — raw DEFLATE | Query parameter on the IdP redirect URL |
Redirect compresses because URLs have hard length limits in browsers — around 8 KB — and assertions don't fit uncompressed.
Step 1: strip the URL. If what you captured is a full URL, pull the parameter out and URL-decode it. In JavaScript, new URL(theUrl).searchParams.get('SAMLResponse') does both.
Step 2: normalize the alphabet. Convert base64url back to base64 (-→+, _→/), strip whitespace, pad to a multiple of four with =. Now standard base64 decoders accept it.
Step 3: decode and sniff. Base64-decode to bytes. If the first byte is < (0x3C), you had a POST-binding message — the bytes are XML, and you're done. Otherwise you're holding a DEFLATE stream.
Step 4: inflate. Raw DEFLATE, no header — in Node that's zlib.inflateRawSync(buf); in the browser, pako's inflateRaw. Some stacks in the wild emit zlib-wrapped deflate instead (first byte 0x78), so a decoder worth using tries raw first, then wrapped. Out pops XML.
Or skip the steps: the SAML decoder on this site does all four and pretty-prints the result, entirely in your browser.
Take a realistic assertion: 2,527 bytes of XML naming [email protected] for the audience https://app.example.com/metadata. Two encodings of the same message:
That 72% size cut is why the redirect binding exists at all.
Once you can see the XML, six fields settle most debugging sessions:
| Field | Example value | Fails when… |
|---|---|---|
Issuer | https://idp.example.com/saml/metadata | Not byte-identical to the IdP entity ID in your SP config |
NameID | [email protected] | Format (emailAddress vs persistent) isn't what your user lookup expects |
Audience | https://app.example.com/metadata | It equals some other SP's entity ID — wrong audience restriction |
InResponseTo | id-9c8d7e6f | Doesn't match an AuthnRequest ID you sent (or you expected IdP-initiated) |
NotBefore | 2026-08-31T11:59:00Z | Your clock says earlier — "not yet valid", the classic skew failure |
NotOnOrAfter | 2026-08-31T12:05:00Z | Your clock says later — expired assertion, often a replayed capture |
Those last two deserve attention. In the example, the assertion was issued at 12:00:00Z, valid from 11:59:00Z until 12:05:00Z — a 6-minute window, 5 minutes of usable life after issue. SAML assertions are deliberately short-lived. If the IdP's clock is 3 minutes ahead of the SP's, the assertion arrives "not yet valid" and every login fails until someone adds skew tolerance. Five minutes is the conventional setting; needing more than ten usually means someone should fix NTP instead.
Paste the base64, the DEFLATE-compressed value, or the entire redirect URL — get pretty XML and the extracted fields, computed locally.
Open the SAML Decoder →A decoded response is readable, not verified. The ds:Signature block you'll see inside is a claim until it's cryptographically checked against the IdP's X.509 certificate, with the reference digests and wrap-attack checks your SAML library performs. Never make an access decision by eyeballing decoded XML — decode to debug, verify in code. And since assertions carry names, emails, and group memberships, treat captures of production traffic the way you'd treat a password dump from your own HR system.
SAML is one of several "trust artifact" formats you'll end up squinting at. JWTs hide their claims in base64url too — the JWT decoder unpacks headers and payloads (also without verifying signatures). And since SAML verification bottoms out in X.509 certificates, the certificate decoder is the next stop when the question becomes "is this even the right cert?"
Because the message was probably compressed first. Redirect-binding messages are DEFLATE-compressed before base64url encoding, so decoding gives you a raw DEFLATE stream — binary bytes that don't start with '<'. You have to inflate those bytes (pako.inflateRaw in JavaScript, zlib inflateRawSync in Node) before the XML appears. POST-binding messages skip compression, so plain base64 decoding does show XML.
Open devtools before clicking login. For POST binding, watch the Network tab for the POST to your ACS URL (often /acs or /saml/acs), then look in the form data for the SAMLResponse field. For redirect binding, copy the full IdP URL with the SAMLRequest or SAMLResponse query parameter. Browser extensions like SAML-tracer automate exactly this capture.
Assertions carry NotBefore and NotOnOrAfter timestamps, and SPs accept them only if they bracket 'now' on the SP's clock. If the IdP server's clock runs a couple of minutes ahead of yours, fresh assertions look not-yet-valid. Skew tolerance — typically 5 minutes, configured on the SP — widens the accepted window on both ends to absorb normal drift.
Yes — signatures live inside the XML as a ds:Signature block, and they don't prevent decoding. But decoding is not verifying. Confirming the signature requires the IdP's X.509 certificate and cryptographic checks that belong in your SAML library, not in a human reading pretty-printed XML.