Self-service SSO setup: letting enterprise customers configure their own SAML IdP

Requiring your customer success team to configure SAML for every enterprise customer does not scale. The setup process is mechanical enough that customers can do it themselves if your UI is good enough. Self-service SSO configuration is a standard expectation for enterprise SaaS products. The challenge is building a UI that guides non-expert IT administrators through a protocol with complex XML-based metadata, helpful error messages when things go wrong, and a test connection flow that validates the setup before going live.

What the customer needs from you (SP metadata)

Before the customer can configure their IdP, they need your Service Provider (SP) metadata. You provide these values; they enter them into Okta, Azure AD, or Google Workspace:

  • Entity ID (also called the Audience URI): a URL that uniquely identifies your SP, e.g. https://app.example.com/saml/metadata
  • ACS URL (Assertion Consumer Service URL): where the IdP posts the SAML assertion after authentication, e.g. https://app.example.com/saml/acs/{orgId}
  • NameID format: typically urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
  • Signing certificate (optional): your SP's public key for encrypted assertions
// Display SP metadata to the customer
app.get('/org/:orgId/settings/sso', requireAuth, requirePermission('settings:manage'), async (req, res) => {
  const { orgId } = req.params;
  const org = await db.orgs.findById(orgId);

  res.json({
    spMetadata: {
      entityId: `https://app.example.com/saml/metadata`,
      acsUrl: `https://app.example.com/saml/acs/${orgId}`,
      nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
      // Link to download the SP metadata XML (some IdPs accept file upload)
      metadataXmlUrl: `https://app.example.com/saml/metadata.xml`,
    },
    currentConfig: org.samlConfig ? {
      idpEntityId: org.samlConfig.idpEntityId,
      idpSsoUrl: org.samlConfig.idpSsoUrl,
      status: org.samlConfig.status,  // 'configured', 'testing', 'active'
      testedAt: org.samlConfig.testedAt,
    } : null,
  });
});

Metadata upload and parsing

Customers can configure SSO by uploading their IdP's metadata XML file (which Okta, Azure, and Google can export) or by manually entering the IdP SSO URL and X.509 certificate. Supporting metadata upload removes the need for customers to know what fields to copy from where.

import { parseSamlMetadata } from 'samlify';

app.post('/org/:orgId/settings/sso/configure', requireAuth, async (req, res) => {
  const { orgId } = req.params;
  let idpConfig;

  if (req.file) {
    // Parse uploaded metadata XML
    try {
      const metadataXml = req.file.buffer.toString('utf-8');
      const parsed = parseSamlMetadata(metadataXml);
      idpConfig = {
        idpEntityId: parsed.entityID,
        idpSsoUrl: parsed.singleSignOnService?.find(s =>
          s.binding === 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
        )?.location,
        idpCertificate: parsed.signingCert,
      };
    } catch (err) {
      return res.status(400).json({
        error: 'metadata_parse_failed',
        detail: 'The uploaded file is not valid SAML metadata XML.',
      });
    }
  } else {
    // Manual entry
    const { idpEntityId, idpSsoUrl, idpCertificate } = req.body;
    if (!idpEntityId || !idpSsoUrl || !idpCertificate) {
      return res.status(400).json({ error: 'missing_required_fields' });
    }
    idpConfig = { idpEntityId, idpSsoUrl, idpCertificate };
  }

  // Validate the certificate is a valid PEM
  try {
    validateX509Certificate(idpConfig.idpCertificate);
  } catch {
    return res.status(400).json({
      error: 'invalid_certificate',
      detail: 'The signing certificate is not a valid X.509 PEM certificate.',
    });
  }

  // Save as 'draft' — not active until tested
  await db.orgs.update({ id: orgId }, {
    samlConfig: { ...idpConfig, status: 'configured', configuredAt: new Date() },
  });

  res.json({ success: true, status: 'configured' });
});

Test connection flow

The test connection flow initiates a real SAML authentication attempt without activating SSO enforcement. The org admin clicks "Test Connection," is redirected to the IdP, authenticates, and is returned with a success or failure report. This validates the full round-trip before any other users are affected.

// Initiate test SSO flow
app.post('/org/:orgId/settings/sso/test', requireAuth, async (req, res) => {
  const { orgId } = req.params;
  const org = await db.orgs.findById(orgId);

  if (!org.samlConfig) {
    return res.status(400).json({ error: 'sso_not_configured' });
  }

  // Store test intent in session — the ACS handler checks for this
  const testNonce = crypto.randomUUID();
  await redis.setex(`sso_test:${testNonce}`, 300, JSON.stringify({
    orgId,
    initiatedBy: req.user.id,
    isTest: true,
  }));

  // Build the SAML AuthnRequest
  const authnRequest = buildSAMLAuthRequest(org.samlConfig, {
    relayState: `test:${testNonce}`,
    acsUrl: `https://app.example.com/saml/acs/${orgId}`,
  });

  res.json({ redirectUrl: authnRequest.redirectUrl });
});

// ACS handler — processes assertions from both test and production flows
app.post('/saml/acs/:orgId', async (req, res) => {
  const { orgId } = req.params;
  const { SAMLResponse, RelayState } = req.body;
  const isTest = RelayState?.startsWith('test:');

  try {
    const org = await db.orgs.findById(orgId);
    const assertion = await parseSAMLResponse(SAMLResponse, org.samlConfig);

    if (isTest) {
      const testNonce = RelayState.slice(5);
      await redis.del(`sso_test:${testNonce}`);
      // Mark SSO as successfully tested
      await db.orgs.update({ id: orgId }, {
        'samlConfig.status': 'tested',
        'samlConfig.testedAt': new Date(),
        'samlConfig.testedEmail': assertion.email,
      });
      return res.redirect(`/org/${orgId}/settings/sso?test=success`);
    }

    // Production login: JIT provision or authenticate
    await handleSSOLogin(orgId, assertion.attributes, assertion.nameId);
    res.redirect(`/dashboard`);

  } catch (err) {
    // Detailed error for test flow; generic error for production
    if (isTest) {
      return res.redirect(
        `/org/${orgId}/settings/sso?test=failed&reason=${encodeURIComponent(classifySamlError(err))}`
      );
    }
    logger.error({ event: 'saml_assertion_failed', orgId, error: err.message });
    res.redirect('/login?error=sso_failed');
  }
});
Parse and classify SAML errors into user-friendly categories. Raw SAML errors like "Audience restriction validation failed" or "NotBefore condition violated" are opaque to IT administrators. Map them to actionable messages: "The Entity ID in your IdP configuration doesn't match ours — check the Audience URI setting" and "Your server clock may be out of sync — check the system time on your IdP."

Error handling for malformed assertions

Common SAML assertion problems and how to detect and surface them:

  • Wrong ACS URL configured in IdP: assertion arrives at the wrong endpoint or with a recipient mismatch. Check SubjectConfirmationData Recipient attribute.
  • Certificate mismatch: assertion signature verification fails. Customer uploaded the wrong certificate or the IdP rolled its signing certificate. Log the received certificate fingerprint so it can be compared to what is configured.
  • Missing email attribute: NameID is not an email, or the email attribute is at an unexpected path. Show the full attribute map received to help the customer identify the correct attribute name to configure.
  • Clock skew: assertion NotBefore or NotOnOrAfter conditions fail if the clocks differ by more than 5 minutes. Allow 5 minutes of skew in assertion validation.
← Back to blog Try Bastionary free →