Adding SSO to a Small SaaS
08 May 2026An enterprise buyer asks “do you support SSO” before they ask about price. Saying no loses the deal regardless of how good your product is — their IT policy requires it, and that’s not negotiable from their side. Here’s the smallest implementation that actually satisfies procurement.
What “SSO” means to the buyer
They want their employees logging into your app through their identity provider (Okta, Azure AD, Google Workspace) using SAML or OIDC, so access is centrally managed — when someone’s offboarded in the IdP, they lose access to your app automatically, without your involvement. That automatic deprovisioning is usually the actual requirement behind “we need SSO,” more than the login UX itself.
SAML vs OIDC: which to build
| SAML | OIDC | |
|---|---|---|
| Age / ubiquity | Older, universally supported by enterprise IdPs | Newer, growing support |
| Format | XML | JSON / JWT |
| Implementation effort | Higher — XML signing is fiddly | Lower — it’s OAuth with an identity layer |
| What enterprise buyers expect | Still the default ask | Increasingly accepted |
If you can only build one, build SAML — it’s what most enterprise procurement checklists still name explicitly, even though OIDC is technically simpler to implement. Building OIDC-only will lose you deals with buyers whose IdP config only offers SAML for third-party apps.
Don’t hand-roll SAML
SAML’s XML signature validation is a well-documented source of real vulnerabilities (signature wrapping attacks are the classic one) when implemented from scratch. Use a maintained library:
- Node:
@node-saml/node-samlorsamlify - Python:
python3-saml - Ruby:
ruby-saml(used by GitLab, well-maintained)
The integration shape is the same regardless of library:
const saml = new SAML({
entryPoint: 'https://customer.okta.com/app/xxx/sso/saml',
issuer: 'https://yourapp.com',
cert: customerIdpCertificate, // from their metadata
callbackUrl: 'https://yourapp.com/sso/saml/callback',
})
app.get('/sso/saml/login', (req, res) => {
saml.getAuthorizeUrl(req).then(url => res.redirect(url))
})
app.post('/sso/saml/callback', async (req, res) => {
const profile = await saml.validatePostResponse(req.body)
const user = await findOrCreateUser(profile.email, profile.nameID)
req.session.userId = user.id
res.redirect('/dashboard')
})
Multi-tenant: the part people get wrong first
Every customer has their own IdP, own certificate, own entity ID. You need per-tenant SAML config, not one global setting:
CREATE TABLE sso_configs (
organization_id INT REFERENCES organizations(id),
idp_entry_point TEXT,
idp_cert TEXT,
idp_issuer TEXT
);
Look up the tenant’s config by a slug or email domain before building the SAML request — yourapp.com/sso/login?org=acme-corp, or infer it from the email domain the user typed on your login page.
Just-in-time provisioning
Most enterprise buyers expect a user’s first SSO login to create their account automatically, matched by email, rather than requiring you to pre-provision every employee manually:
async function findOrCreateUser(email, nameId) {
let user = await db.users.findByEmail(email)
if (!user) {
user = await db.users.create({ email, ssoNameId: nameId, role: 'member' })
}
return user
}
What enterprise buyers actually check during procurement
- Can an admin enforce SSO-only login for their org (block password login entirely)?
- Does deprovisioning in the IdP actually revoke access on your side, not just block new logins?
- Is there an audit log of SSO logins?
You don’t need all three to close deals, but “SSO-only enforcement” is the one that comes up most — a customer’s security team often requires that password login be disabled org-wide once SSO is configured.
The concrete next action
If you’re building this for your first enterprise deal, scope it to SAML with ruby-saml or node-saml, per-tenant config, and JIT provisioning. Skip building an admin UI for it until a second customer asks — for one customer, a manual config row in your database is enough.