Enterprise SSO with SAML and OIDC: Validation, Account Linking, and Offboarding
The SSO bugs that cause incidents happen after the library says 'valid'. How to link logins to users by issuer and subject, map roles, and end access when people leave.
Selling to larger companies usually brings a security questionnaire, and one of its first questions is whether employees can sign in through the company's identity provider (IdP), such as Microsoft Entra ID, Okta, or Google Workspace. Supporting that means implementing SAML 2.0, OpenID Connect (OIDC), or both, once per customer connection.
Protocol libraries handle the cryptography. The bugs that cause incidents are usually in what your code does after the library says "valid": which local account the login belongs to, which tenant, which role, and what happens when the employee leaves. This guide covers both protocols briefly, the validation you must not skip, and account linking in detail, with a small tested Go package that resolves a verified login to a user, including the case where another tenant's IdP asserts your user's email address.
SAML and OIDC side by side
| SAML 2.0 | OpenID Connect | |
|---|---|---|
| Message format | XML assertions with XML signatures | JSON Web Tokens (ID tokens) |
| Typical flow | Browser posts a signed response to your Assertion Consumer Service URL | Authorization code flow; your server exchanges a code for tokens |
| Stable user identifier | Issuer plus a persistent NameID | iss plus sub |
| Configuration | Metadata XML exchanged with the customer | Discovery document at /.well-known/openid-configuration |
| Common in | Established enterprise IdP deployments | Newer deployments and consumer identity |
Most enterprise IdPs support both. Offer the one your customers ask for first, but design your account model so a tenant can switch protocols without creating duplicate users.
Validation you must not skip
Use a maintained library for parsing and signature checks; XML signature handling in particular has a long history of subtle bypasses. The OWASP SAML Security Cheat Sheet lists the checks. The ones most often missed:
- The signature covers what you read. Read identity only from the element whose signature you verified. Reject responses with more than one assertion.
- The response answers your request. For service-provider-initiated logins, check that
InResponseTomatches a request ID you issued and have not used yet. Unsolicited (IdP-initiated) responses have no such link, which is why many teams disable them or accept them only for tenants that need them. - Audience, recipient, and time. The audience must be your service's entity ID for this connection, the recipient your ACS URL, and the current time inside the validity window with only a small clock-skew allowance.
- Replay. Remember each assertion ID until it expires, keyed by issuer and ID, and reject repeats.
- The issuer matches the connection. A response must come from the IdP configured for the tenant whose login page started the flow.
For OIDC, the equivalent checks are on the ID token: signature with the IdP's published keys, iss, aud, exp, and the nonce you sent. The OpenID Connect Core specification defines them.
Account linking: the part your library does not do
After validation you hold a verified statement: "this issuer says the user with subject X, email Y, groups Z is signing in." The decisions that follow belong to you.
Identify users by issuer and subject, not email. OIDC Core states that sub and iss, used together, are the only claims a relying party can rely on as a stable identifier for the user. Emails change, are reused when people leave, and, crucially, any IdP can assert any email address. If your code finds an existing account by email, a tenant that controls its own IdP can sign in as a user of another tenant by asserting their email.
Keep logins inside the connection's tenant. A login through tenant A's connection can only ever resolve to a user in tenant A.
Create users just in time only for verified domains. If a connection allows automatic account creation, require the email domain to be one the tenant has proven it controls.
Derive roles from groups on every login. Map IdP groups to your roles with a mapping the tenant administrator controls, apply it each time the user signs in, and remove roles the IdP no longer grants. Rank roles explicitly so the result does not depend on the order in which the IdP lists groups.
The package below applies those rules after protocol validation. It compiles with Go 1.26 and its test passes; the test includes another tenant's IdP asserting an Acme user's email (rejected), a response from the wrong issuer (rejected), an email change (same user), group order reversed (same role), and removal from an admin group (role lowered).
// Package ssolink decides which local user a verified SSO login belongs to.
// It runs after the SAML response or OIDC ID token has been fully validated.
package ssolink
import (
"errors"
"slices"
"strings"
)
// Login is the verified identity from one enterprise connection.
type Login struct {
ConnectionID string // the tenant's configured IdP connection
Issuer string // SAML Issuer or OIDC iss
Subject string // persistent NameID or OIDC sub
Email string
Groups []string
}
// Connection is one tenant's SSO configuration.
type Connection struct {
ID string
TenantID string
Issuer string
VerifiedDomain string // a domain the tenant proved it controls
JIT bool // create users on first login
GroupRoles map[string]string // IdP group -> local role
RoleRank []string // local roles from least to most privileged
DefaultRole string
}
type User struct {
ID, TenantID, Email, Role string
}
type Store interface {
ByExternalID(connectionID, issuer, subject string) (*User, error) // nil, nil if none
Create(u User, connectionID, issuer, subject string) (*User, error)
UpdateRole(userID, role string) error
}
var (
ErrWrongIssuer = errors.New("issuer does not match this connection")
ErrForeignDomain = errors.New("email domain is not verified for this tenant")
ErrNoAccount = errors.New("no account and just-in-time provisioning is off")
)
// Resolve maps a login to a user in the connection's tenant only. Identity is
// (connection, issuer, subject); email is never used to find an existing user.
func Resolve(s Store, c Connection, l Login) (*User, error) {
if l.ConnectionID != c.ID || l.Issuer != c.Issuer || l.Subject == "" {
return nil, ErrWrongIssuer
}
// The most privileged mapped role wins, whatever order the IdP lists groups in.
role := c.DefaultRole
for _, g := range l.Groups {
if r, ok := c.GroupRoles[g]; ok && slices.Index(c.RoleRank, r) > slices.Index(c.RoleRank, role) {
role = r
}
}
u, err := s.ByExternalID(c.ID, l.Issuer, l.Subject)
if err != nil {
return nil, err
}
if u != nil {
if u.Role != role { // roles follow the IdP on every login, including removals
if err := s.UpdateRole(u.ID, role); err != nil {
return nil, err
}
u.Role = role
}
return u, nil
}
if !c.JIT {
return nil, ErrNoAccount
}
at := strings.LastIndex(l.Email, "@")
if at < 0 || !strings.EqualFold(l.Email[at+1:], c.VerifiedDomain) {
return nil, ErrForeignDomain
}
return s.Create(User{TenantID: c.TenantID, Email: strings.ToLower(l.Email), Role: role}, c.ID, l.Issuer, l.Subject)
}
package ssolink
import (
"errors"
"fmt"
"testing"
)
type memStore struct {
users map[string]*User // key: connection|issuer|subject
seq int
}
func (m *memStore) ByExternalID(c, i, s string) (*User, error) { return m.users[c+"|"+i+"|"+s], nil }
func (m *memStore) Create(u User, c, i, s string) (*User, error) {
m.seq++
u.ID = fmt.Sprintf("usr_%d", m.seq)
m.users[c+"|"+i+"|"+s] = &u
return &u, nil
}
func (m *memStore) UpdateRole(id, role string) error {
for _, u := range m.users {
if u.ID == id {
u.Role = role
}
}
return nil
}
func TestResolve(t *testing.T) {
acme := Connection{ID: "conn_acme", TenantID: "t_acme", Issuer: "https://idp.acme.example", VerifiedDomain: "acme.example",
JIT: true, GroupRoles: map[string]string{"eng": "member", "it-admins": "admin"},
RoleRank: []string{"viewer", "member", "admin"}, DefaultRole: "viewer"}
evil := Connection{ID: "conn_evil", TenantID: "t_evil", Issuer: "https://idp.evil.example", VerifiedDomain: "evil.example", JIT: true, DefaultRole: "viewer"}
s := &memStore{users: map[string]*User{}}
u, err := Resolve(s, acme, Login{"conn_acme", acme.Issuer, "00u1", "Ana@ACME.example", []string{"eng", "it-admins"}})
if err != nil || u.TenantID != "t_acme" || u.Role != "admin" {
t.Fatalf("first login: %+v %v", u, err)
}
// Group order from the IdP does not change the result.
if r, _ := Resolve(s, acme, Login{"conn_acme", acme.Issuer, "00u1", "ana@acme.example", []string{"it-admins", "eng"}}); r.Role != "admin" {
t.Fatalf("group order changed role: %+v", r)
}
// Same subject later, removed from it-admins in the IdP: role is lowered.
u2, _ := Resolve(s, acme, Login{"conn_acme", acme.Issuer, "00u1", "ana@acme.example", []string{"eng"}})
if u2.ID != u.ID || u2.Role != "member" {
t.Fatalf("returning login: %+v", u2)
}
// Another tenant's IdP asserts Ana's email: must not reach Ana's account or tenant.
if _, err := Resolve(s, evil, Login{"conn_evil", evil.Issuer, "x9", "ana@acme.example", nil}); !errors.Is(err, ErrForeignDomain) {
t.Fatalf("cross-tenant email assertion: got %v", err)
}
// A response from the wrong issuer on Acme's connection is rejected.
if _, err := Resolve(s, acme, Login{"conn_acme", evil.Issuer, "00u1", "ana@acme.example", nil}); !errors.Is(err, ErrWrongIssuer) {
t.Fatalf("wrong issuer: got %v", err)
}
// Email changed in the IdP: still the same user, because identity is the subject.
u3, _ := Resolve(s, acme, Login{"conn_acme", acme.Issuer, "00u1", "ana.lima@acme.example", []string{"eng"}})
if u3.ID != u.ID {
t.Fatalf("email change created a new user: %+v", u3)
}
}
An earlier version of the role mapping took the last matching group in the IdP's order, which made the same user an admin or a member depending on how groups happened to be listed. The reversed-order test was added to catch exactly that.
Negative tests to run before the first customer connects
Most SSO test plans check that a valid login works. The incidents come from invalid logins that also work. Before enabling a connection, send your assertion consumer service each of the following, built from a real response from a test IdP, and confirm every one is rejected and logged:
| Test | How to produce it | Expected result |
|---|---|---|
| Modified attribute | Change the email or a group in a signed response | Signature check fails |
| Unsigned assertion | Remove the signature element | Rejected; never fall back to "unsigned is fine" |
| Second assertion | Insert a copied, edited assertion next to the signed one | Rejected for containing more than one assertion |
| Replay | Send the same valid response twice | Second attempt rejected by the replay check |
| Expired | Send a valid response after its NotOnOrAfter time | Rejected, beyond your clock-skew allowance |
| Wrong audience | Use a response issued for another service provider | Rejected on audience |
| Wrong connection | Send tenant A's valid response to tenant B's ACS URL | Rejected on issuer for that connection |
| Unsolicited | Send a response with no matching request, if IdP-initiated login is disabled | Rejected on InResponseTo |
| Foreign email | Assert an email in a domain the tenant has not verified | No account created or linked |
Keep these as automated tests with recorded fixtures, so a library upgrade or a refactor of the login handler cannot quietly reopen one of them. The OWASP SAML Security Cheat Sheet explains the attacks behind several rows.
Signing certificates change
IdPs rotate the certificate or keys they sign with. For SAML, let a connection hold more than one trusted signing certificate during a rotation window, and remove the old one afterwards. For OIDC, fetch the IdP's published keys and refresh them when a token arrives with an unknown key ID, rather than caching them forever. Notify the tenant administrator before a configured certificate expires.
When an employee leaves
Disabling a user at the IdP stops new sign-ins. It does not end sessions your application already issued. Until your session expires or you revoke it, the user keeps access. Two ways to close that gap:
- Short sessions for SSO users, so access ends soon after the IdP stops issuing logins. The session guide covers what that costs and how to revoke sessions early.
- SCIM provisioning, defined in RFC 7644, which lets the IdP tell your application when a user is deactivated, so you can revoke their sessions and API keys immediately. Many enterprise customers expect SCIM alongside SSO.
NIST SP 800-63C describes federation assurance levels if a customer's security team asks which requirements your implementation meets.
Where to start
Find the code that runs after your SSO library returns a valid login, and check how it finds the user. If any path looks up an existing account by email, fix that first, because it is a cross-tenant account takeover waiting for a curious customer. Then confirm that roles are recomputed on every login, and decide how a deactivated employee's existing sessions end. The RBAC guide covers what those mapped roles should be allowed to do.
Frequently Asked Questions
Should we support IdP-initiated SAML?
Only where customers need it. Unsolicited responses cannot be matched to a request you issued, so they lose a defense against replayed and injected responses. If you support them, keep strict replay checks and short validity windows.
Can a user have both a password and SSO?
For tenants that enforce SSO, disable password sign-in for their users, except for one or two break-glass administrator accounts with strong second factors. Otherwise a departed employee can keep signing in with a password.
What if a customer changes IdPs?
The issuer changes, and often the subject too. Provide an administrator-run migration that links new subjects to existing users, for example by matching verified emails within that tenant, rather than linking automatically on first login.
Is a SAML transient NameID usable?
Not as an account identifier, because it changes on every login. Ask the customer to configure a persistent NameID or a stable attribute such as an immutable employee ID.
Do we need SCIM if we have JIT provisioning?
JIT creates users when they sign in; it never learns that someone left. SCIM adds deactivation and updates pushed from the IdP, which is what lets you revoke access promptly.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
SAML Security Cheat Sheet
(OWASP Foundation)
• Link checked 2026-11-03
Validation checks including InResponseTo, signatures, and unsolicited responses.
-
OpenID Connect Core 1.0 incorporating errata set 2
(OpenID Foundation)
• Link checked 2026-11-03
ID token validation and the stability of the iss and sub claims.
-
Assertions and Protocols for the OASIS SAML V2.0 (SAML Core)
(OASIS)
• Link checked 2026-11-03
The SAML 2.0 assertion and protocol specification.
-
RFC 7644: System for Cross-domain Identity Management: Protocol
(Internet Engineering Task Force)
• Link checked 2026-11-03
SCIM provisioning and deprovisioning.
-
NIST SP 800-63C-4: Federation and Assertions
(National Institute of Standards and Technology)
• Link checked 2026-11-03
Federation assurance levels.
Revision history
- — Added tested account-linking code and its tests. Removed the claim that disabling a user at the IdP immediately cuts off access, the invented xmlsec.Verify API, and a JIT example that could link by email or create users after any lookup error. Added InResponseTo, issuer checks, role ranking, and SCIM offboarding. Updated the OIDC Core and NIST references. Added a table of negative tests to automate before enabling a customer connection.
- — Corrected IdP-initiated and identifier-binding language.