Skip to main content

Single sign-on with SAML

Set up SAML so your people sign in with your existing identity provider — the metadata exchange, and every check we run on an assertion before we believe it.

Written for whoever runs IT9 min readUpdated

SAML is the older of the two standards for letting one system vouch for a person to another. Your identity provider — Microsoft Entra ID, Okta, ADFS, Google Workspace — already knows who your staff are and has already made them prove it. SAML lets it write that fact down, sign it, and hand it to Qorionix, so nobody needs a second password here.

The setup is about fifteen minutes of copying values between two consoles. The second half of this article is longer and matters more: what we verify on every assertion before we accept it, and what each of those checks prevents. Individually they are boring. The absence of any one of them is a complete authentication bypass, which is why they are written down here rather than left as an implementation detail.

The two halves of the trust

A SAML trust is two configurations that have to agree. Your identity provider needs to know who Qorionix is and where to send assertions. Qorionix needs to know which provider is allowed to speak for your organisation and which certificate its signatures will carry. Neither half is useful alone, and a mismatch in either direction produces the same symptom — a sign-in that fails with no detail.

Identity provider (IdP)
The system that authenticates the person. Yours. It signs the assertion.
Service provider (SP)
The system being signed in to. Qorionix, in this case. It verifies the assertion.
Assertion
The signed statement itself: this person, authenticated at this time, valid until this time, intended for this recipient. Everything in the verification section below is about making sure all four of those are true of this login.
Assertion Consumer Service (ACS)
The single Qorionix URL that receives assertions. Your browser posts a form to it after your identity provider has finished with you. It has to be reachable without a session, because at that moment you do not have one.

The metadata exchange

Both sides publish a metadata document so the values do not have to be typed. Take ours to your provider, and bring theirs back.

  1. Fetch the Qorionix service provider metadata

    GET /api/v1/saml/metadata returns an XML EntityDescriptor with our entity ID and our ACS location. It is public, so you can open it in a browser. It declares WantAssertionsSigned="true" and AuthnRequestsSigned="false", and a single AssertionConsumerService on the HTTP-POST binding.

  2. Create the application in your identity provider

    Import our metadata document, or paste the entity ID and ACS URL by hand. Set the name ID format to email address unless you have a reason not to — that is what we default to and what the subject lookup expects.

  3. Bring your provider's metadata back

    POST /organizations/{orgID}/saml/metadata/parse accepts either a metadata_url or a metadata_xml document and returns the fields it read — entity ID, sign-on URL, signing certificate, name ID format — without saving anything. Check them before you commit. These are the values that decide whose assertions become logins for your tenant.

  4. Register the provider

    POST /organizations/{orgID}/saml/providers with the same body saves it. Anything you type explicitly wins over anything read from the metadata, so you can correct a published document without abandoning the import. The response hands back sp_entity_id and sp_acs_url again, along with any warnings raised while parsing.

  5. Map the attributes you need

    attribute_map translates the attribute names your provider emits into the four Qorionix understands: email, first_name, last_name and groups. Anything unmapped is still recorded on the result but is not acted on. If the name ID is an email address, the email mapping is optional — we fall back to it.

The fields each side needs

The six values that make up a SAML trust, which side owns each, and what goes wrong when one is wrong.
ValueOwned byNotes
SP entity IDQorionixYour Qorionix base URL with no trailing slash. Must appear in the assertion's AudienceRestriction.
SP ACS URLQorionixBase URL plus /api/v1/saml/acs. Must appear in the Response Destination and in the bearer Recipient. Take it from our metadata rather than typing it, so it is byte-identical.
IdP entity IDYouThe issuer your provider stamps on its assertions. We use it to find which registered provider a message belongs to, and then require the signed assertion's issuer to equal it.
IdP sign-on URLYouWhere your users are sent to authenticate. Stored, and returned to the console.
Signing certificateYouPEM, or the bare base64 DER that most provider consoles show you. A PEM bundle with two certificates is accepted so you can register the old and new one during a rotation. A bare public key is refused — we pin by comparing certificate bytes.
Name ID formatYouDefaults to urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress if you leave it blank.

Registering, editing and deleting a provider all require the sso permission family — sso:read to list, sso:write to create or parse metadata, sso:delete to remove. Metadata parsing is deliberately a write rather than a read, because supplying a metadata_url makes our server fetch a document of your choosing. See Roles and permissions for who holds these by default; in practice it is the admin role.

Registering a provider from a published metadata URL
POST /api/v1/organizations/{orgID}/saml/providers
{
  "name": "Contoso Entra ID",
  "metadata_url": "https://login.microsoftonline.com/<tenant>/federationmetadata/...",
  "attribute_map": {
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "email",
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "first_name",
    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "last_name",
    "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups": "groups"
  }
}

The validation messages on that endpoint are plain text in a JSON error field, and they are worth recognising: a display name is required, a sign-on URL is required, an entity ID is required. Supply metadata_url or metadata_xml and it will be read from the document, and the signing certificate is not usable: followed by the parse failure. A certificate is checked at registration time rather than at first login, deliberately — a broken certificate discovered during a login attempt surfaces as a deliberately uninformative failure and costs you an afternoon.

What we verify on every assertion

An assertion is a piece of XML that arrived in a browser POST from a machine we do not control, carrying a signature. Trusting it correctly is harder than it looks, and this codebase got it wrong once in a way worth describing, because it explains why the checks below are shaped as they are.

The previous verifier located the signed region by scanning the raw bytes of the document for <SignedInfo> and <Assertion>. It did not canonicalise, did not resolve the signature's reference, and did not check for duplicate element IDs. Go's XML reader, meanwhile, keeps the last assertion when a document contains two. So the reader took the last assertion while the byte-scanning verifier validated the first. Appending a forged administrator assertion carrying a byte-for-byte copy of a genuine signature passed both. That is signature wrapping, and it authenticated as any user in the tenant.

Verification now runs through a hardened verifier that resolves the signature structurally rather than by pattern-matching text. The checks below run in order, and any one of them failing rejects the sign-in.

The enforced checks and, in the reader's terms, the attack each one closes. All of them run on every assertion; none is optional or configurable per provider.
CheckWhat it prevents
Signature verification, properly done — one Reference, a same-document fragment, resolved through a duplicate-free ID index, canonicalised, and required to enclose the signature it carriesA forged assertion smuggled into a document alongside a genuinely signed one. This is the wrapping attack above. Detached signatures and multiple signatures on one element are refused outright.
Duplicate ID checkTwo elements sharing an ID so that the verifier and the reader can be pointed at different halves of the same document.
Certificate pinning — the presented certificate is compared against the one registered for the provider, by its raw bytesAn assertion signed by anybody other than your identity provider, including one signed by a certificate that merely chains to a public authority.
Exactly one assertionThe direct fix for wrapping. A response carrying two assertions is refused rather than resolved.
SHA-1 digests refusedAn attacker who can find a hash collision swapping the content the signature covers.
Destination — the Response must name our ACS URLAn assertion your provider minted for a different endpoint being posted at ours.
Audience — the assertion's AudienceRestriction must include our entity IDThe one worth reading twice. Your identity provider signs assertions for Slack, for your payroll system, for everything. An assertion it legitimately signed for Slack must not be a valid Qorionix login, and the audience check is the only thing standing between those two facts.
Recipient — the bearer SubjectConfirmationData must name our ACS URLThe same problem one layer down, inside the subject confirmation rather than the response envelope.
InResponseToAn assertion answering a request we never sent. Because Qorionix does not currently send AuthnRequest messages, the rule is the strict one: an assertion that carries an InResponseTo at all is refused. An assertion answering a request we never sent is exactly what a relayed or replayed one looks like.
NotBefore and NotOnOrAfter, with two minutes of clock skewAn assertion captured today being useful tomorrow. The window is short by design and the skew allowance is small.
Replay guardThe same assertion, inside its validity window, being presented twice. Even a five-minute window is enough to reuse a captured assertion if nothing remembers it.
Subject resolved inside the asserting tenant onlyAn identity provider registered by one customer authenticating a user who belongs to another. Email addresses are unique across the whole platform, so a lookup by email alone would make every registered IdP authoritative for everybody.

Two more properties are not conditional and cannot be switched off. An unsigned assertion is refused whatever the provider record says — a signature on the response envelope alone is not accepted in its place, and the stored require signed assertions flag is always set true and never consulted to weaken anything. And if two organisations have registered the same identity provider entity ID, the login is refused rather than resolved, because picking one would let an attacker decide which tenant they land in.

Two algorithm rules are worth knowing before you configure your provider, because both produce a rejection rather than a downgrade. SHA-1 digests and RSA-SHA1 signatures are refused outright, so a provider still configured for SHA-1 will not work at all. And an RSA key shorter than 2,048 bits is refused, as are a mismatched key and signature type — an ECDSA signature presented with an RSA certificate, or the reverse.

The exact rejection strings

These are the internal reasons recorded when an assertion is refused. They are what appears in the platform's own logs and in a support ticket, and they are reproduced here so that a failed sign-in is searchable.

Verifier rejection reasons, verbatim
saml: malformed SAML Response
saml: SAML Response status is not Success
saml: assertion audience does not include this service provider
saml: assertion is outside its validity window
saml: assertion recipient is not this endpoint
saml: assertion InResponseTo does not match the request
saml: assertion has already been consumed
saml: response carries more than one assertion
saml: signature verification failed
saml: signature does not cover the element it claims to
The more specific forms, with the offending value included
saml: signature verification failed: no pinned certificate
saml: signature verification failed: no registered signing certificate to verify against
saml: signature verification failed: detached signature
saml: signature verification failed: multiple signatures on one element
saml: signature verification failed: no CanonicalizationMethod
saml: signature verification failed: expected exactly one Reference, got <n>
saml: signature verification failed: Reference URI "<uri>" must be a same-document fragment
saml: signature verification failed: Reference URI "<uri>" resolves to nothing
saml: signature does not cover the element it claims to: signature on unexpected element <local>
saml: signature does not cover the element it claims to: signature is not enveloped by its referenced element
saml: assertion recipient is not this endpoint: Response Destination "<dest>"
saml: assertion recipient is not this endpoint: SubjectConfirmationData Recipient "<r>"
saml: assertion InResponseTo does not match the request: unsolicited assertion carries InResponseTo "<got>"
saml: assertion audience does not include this service provider: "<audience>"
saml: assertion audience does not include this service provider: no AudienceRestriction
saml: assertion is outside its validity window: not valid before <v>
saml: assertion is outside its validity window: bearer confirmation expired at <v>
saml: assertion issuer "<got>", want "<want>"
saml: malformed SAML Response: no assertion
saml: malformed SAML Response: assertion has no Conditions
saml: malformed SAML Response: no usable bearer SubjectConfirmation
Reasons raised by the tenant-facing layer rather than the verifier
saml: no active provider is registered for that issuer
saml: issuer is registered by more than one tenant
saml: asserted subject is not a member of the asserting tenant
saml: unsigned assertion refused: the assertion itself is not signed
saml: service provider identity is not configured
saml: registered signing certificate is unusable
saml: invalid assertion: SAMLResponse is not base64
saml: invalid assertion: assertion carries no Issuer

Single use, across every replica

An assertion is valid for a few minutes. Within that window, anybody who captured it could present it again — from a proxy log, a browser history, a shared machine. The replay guard records every consumed assertion, scoped to the provider that issued it, and refuses a second presentation.

It is stored in the database rather than in the API's memory, and the reason is worth stating because it is a common way to get this wrong. The API runs as several replicas behind a load balancer. An assertion consumed on one replica is unknown to the others, so an in-process cache degrades the single-use guarantee to once per replica per window — which, with four replicas, means four uses. The record is written with a single insert-on-conflict statement, so two simultaneous presentations of the same assertion cannot both be told they were first.

From subject to user

Once an assertion verifies, the email address it carries — from the mapped attribute, or from the name ID if that is an email — is resolved to an active user inside the organisation that registered the asserting provider, and only inside it. If there is no such user, the login is refused with saml: asserted subject is not a member of the asserting tenant.

The practical consequence: SAML on this path does not create accounts. The person has to exist in your Qorionix organisation and be active before their first federated sign-in. Invite them first — see Inviting your team — or provision them from your HR system with SCIM, which is the right answer if you have more than a handful of people. Group values carried on the assertion are recorded on the result; they do not currently grant anything on their own.

What to do next

  1. Fetch /api/v1/saml/metadata, confirm the entity ID is your real Qorionix URL, and import it into your identity provider.
  2. Parse your provider's metadata with the parse endpoint and read the result before you save it.
  3. Make sure the people who will use it already exist in your organisation, then test from the application tile in your identity provider.
  4. If you are choosing between standards rather than migrating an existing SAML setup, read Single sign-on with OIDC first — it is the newer standard and the inbound path there is better finished.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in identity and access