Single sign-on: OAuth 2.0, OpenID Connect, SAML and SCIM

What OAuth 2.0, OpenID Connect, SAML and SCIM each do, how discovery documents and signing keys work, the authorization-code flow with PKCE, state and nonce, every check an ID token must pass, why people are matched by subject id, how a DNS TXT record proves a domain, and how to require single sign-on without locking anyone out.

In short

Single sign-on (SSO) lets people sign in to many apps with one company account, kept at the company's identity provider - Microsoft Entra ID, Okta or Google Workspace. Four standards share the work: OAuth 2.0 hands out access, OpenID Connect says who signed in, SAML is the older XML way of saying the same, and SCIM creates and removes accounts. {{brand}} signs people in with OpenID Connect: the browser visits the provider, comes back with a one-time code, and the server swaps that code for a signed ID token it checks line by line. Before any of that, a company proves it owns its email domain with one DNS TXT record.

After this lesson you can Say what OAuth 2.0, OpenID Connect, SAML and SCIM each do, and when to use which. Walk through the authorization-code flow with PKCE, state and nonce, and name the attack each stops. List every check an ID token must pass. Design account linking, domain proof and required sign-on that cannot be abused and cannot lock a company out.

Words you need

Four standards, four jobs

People mix these four up in interviews all the time. The quickest way to keep them apart is to ask which question each one answers: may this app act for me, who is this person, or which accounts should exist.

OAuth on its own is not sign-in An access token is meant for an API, not for your app: it may not be a JWT, it may be meant for another audience, and it carries no nonce. Signing people in with OAuth alone - whoever holds a token is the user - lets a token issued to some other app open yours. Use OpenID Connect and check the ID token.

Discovery documents and JWKS

An OpenID Connect provider describes itself in one public JSON file, the discovery document. The app needs to know only the issuer address: it appends /.well-known/openid-configuration and reads everything else from there.

Why the keys are public The provider signs with a private key only it holds; anyone can verify with the public key in the JWKS. That is why ID tokens must use an asymmetric algorithm such as RS256 or ES256: with a shared secret (HS256), whoever can verify a token could also forge one.

The authorization-code flow, step by step

This is the flow every modern web app uses for sign-in. The front channel is the browser being redirected between sites; the back channel is the server talking to the provider directly. Tokens travel only on the back channel.

Why a code, and not the token itself The old implicit flow put tokens straight into the browser's address, where history, logs and referrers could leak them. A code is worthless on its own: only a server holding the client secret and the PKCE verifier can turn it into tokens. The OAuth security best practice (RFC 9700) and the OAuth 2.1 draft drop the implicit flow.

PKCE, state and nonce: three values, three attacks

All three are random strings made fresh for each sign-in, and all three are checked on the way back. They look alike, but each one closes a different hole.

The options in the market

What {{brand}} chose, and why

{{brand}} signs people in with OpenID Connect, through an explicit authorization-code flow that its own code runs, while the Microsoft.IdentityModel libraries do the token and key work. On the hosted service each organization connects its own provider, as an Enterprise plan feature, and the provider's groups can optionally map to roles; a private installation has one provider, named in its configuration.

The trade-off accepted Every rule a framework handler would apply quietly - nonce, issuer, audience, authorized party - is written out and has a test that sends a token failing only that rule.

When another option is right: a single app with one fixed provider should use the framework handler - it is less code. A product that must offer SAML, SCIM and dozens of providers quickly, without identity specialists, may be better served by a hosted broker. A company with many internal apps should run one identity server and let every app trust it.

Every check on the ID token

The token endpoint's answer holds an ID token, usually an access token and sometimes a refresh token. For sign-in only the ID token is believed, and only after every check below has passed. Skipping one is the classic way sign-in gets broken.

Read flags as JSON, not as text Claims arrive as JSON, and a loose check is easy to fool: a truthy test passes for the string "false". Accept only the JSON boolean true for email_verified, and treat a missing claim as not verified. The same care applies to the second-factor claim amr: it says what the provider did, for that provider's own users.

Provider quirks that bite Microsoft Entra ID leaves email_verified out of work-account tokens, so add the optional claims email and xms_edov (the domain owner verified the address) or no address will be trusted; with more than 200 groups it sends a reference instead of the list. Google Workspace names the company's domain in the hd claim. Ask for the scopes openid, email and profile.

Matching people by subject id, never by email

Once the token is verified, the app must decide which account it signs in to. The only safe key is the pair issuer + subject: the provider promises that sub never changes for a person and is never given to someone else under that issuer. An email address promises neither.

The nOAuth lesson In 2023 researchers showed apps that matched Microsoft Entra ID sign-ins by the email claim. In a multi-tenant setup anyone could create a tenant, set the victim's address as their own email and arrive as the victim. The fix is the rule above: key accounts by subject, and trust an address only when the provider vouches for it and the domain belongs to the company.

Why never link silently: someone could register [email protected] before the real person ever arrives, set a password and wait. This is called pre-account hijacking. Linking only from inside the account, or through a mailbox link that first removes the old password, passkeys and sessions, closes it.

Shared issuers are nobody's provider Microsoft's common endpoint and public sign-in services answer for anyone who makes an account, so a sound design never treats them as one company's provider. Google Workspace shares one issuer for all its customers, so its hd claim must name a domain the company has proved.

Proving an email domain with DNS TXT

Trusting a company's provider for addresses at acme.example is safe only if that company really controls acme.example. The standard proof is a DNS TXT record holding a random value: only someone who can edit the domain's DNS can publish it. Let's Encrypt, Google Search Console and Microsoft 365 use the same idea.

Requiring single sign-on without locking anyone out

Once a company trusts its provider, it usually wants everyone at its domain to sign in that way, so a leaver removed at the provider is out everywhere. The danger is the day the provider is down or misconfigured: nobody, not even the administrators, could get in. A careful design therefore keeps a break-glass account.

What enforcement alone does not do In any app, removing someone at the provider stops their next sign-in; a session already running lasts until it ends. Short sessions, removing the person from the organization in the app, and SCIM deprovisioning are what close that gap. Signing out of the app does not sign you out of the provider, and the reverse is also true: OpenID Connect defines RP-initiated logout (the app asks the provider to end its session) and back-channel logout (the provider tells the app), SAML has single logout, and many products simply keep sessions short.

Why a hand-written flow instead of a library handler

Frameworks ship an OpenID Connect handler: ASP.NET Core's AddOpenIdConnect, Spring Security's oauth2Login, passport-openidconnect in Node. For one fixed provider it is the right tool. {{brand}} has a different provider per organization and must treat every provider address as untrusted input, so it runs the flow itself and leaves only the cryptography to a library.

Do not hand-roll the cryptography A hand-written flow does not mean hand-written signature checks. Parsing a JWKS, verifying RS256 and ES256 signatures and reading discovery documents come from a maintained library; your code decides only what to ask and what to believe.

SAML and SCIM in more depth

SAML and SCIM come up in every enterprise conversation and in many interviews, so here is how each works inside.

XML signature wrapping An XML signature covers one element, found by its ID. An attacker can keep the signed element and add a second, unsigned assertion that a careless app reads instead. Validate with a maintained library, read only the element the signature covers, refuse a response with more than one assertion, and refuse unsolicited IdP-initiated responses unless you truly need them.

Try it yourself

You can watch every message of the flow on your own machine in half an hour. Run Keycloak - a free, open-source identity provider - in Docker, and use a terminal with curl, openssl and jq (on Windows, Git Bash works). The exercise ends with you breaking a check on purpose.

Decode tokens locally Tokens from a real company are credentials. Decode them on your own machine as above, never by pasting them into a website.

Common mistakes

Interview questions

Related