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
- Identity provider (IdP) The system that owns a company's accounts and checks their passwords and second steps: Microsoft Entra ID, Okta, Google Workspace, Keycloak.
- Relying party (RP) The app that trusts the provider's word - here {{brand}}. SAML calls it the service provider (SP); OAuth calls it the client.
- OAuth 2.0 A standard for delegated access: an app gets an access token to call an API for someone. On its own it does not say who signed in.
- OpenID Connect (OIDC) A thin layer on OAuth 2.0 that adds identity: the scope openid and a signed ID token naming the person.
- SAML 2.0 An older standard (2005) that does single sign-on with signed XML assertions posted through the browser. Common in banks and large enterprises.
- SCIM 2.0 A REST API the provider calls to create, change and deactivate accounts in an app. It is about accounts, not sign-in.
- ID token A JWT - a signed JSON object - in which the provider states who signed in, for which app, when and how.
- Discovery document A public JSON file at /.well-known/openid-configuration that lists a provider's addresses, keys and abilities.
- JWKS JSON Web Key Set: the provider's public signing keys, each with a key id (kid), so anyone can check its signatures.
- PKCE Proof Key for Code Exchange (say pixy): the app keeps a random verifier and sends only its hash, so a stolen code is useless.
- State and nonce Two random values made for each sign-in. State ties the answer to the browser that asked; nonce ties the ID token to this sign-in.
- Subject (sub) The provider's own stable id for a person, unique within that issuer. The right key to remember someone by - unlike an email address.
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.
- The directory The company keeps people and groups in its identity provider, for example Entra ID.
- SCIM creates the account When Aynur joins, the provider calls the app's SCIM API: create [email protected] and add her to the group QA.
- OIDC or SAML signs her in Each morning the app sends her to the provider and receives an ID token (OIDC) or an assertion (SAML).
- OAuth calls APIs If the app must read her calendar, it asks for an access token with a calendar scope - OAuth's real job.
- SCIM removes her When she leaves, the provider sends active: false and the app deactivates her account at once.
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.
- 1. Fetch the document GET the issuer plus /.well-known/openid-configuration, over HTTPS only.
- 2. Check the issuer The document's issuer must be exactly the address that was asked. A document naming another issuer is refused - otherwise one provider could pose as another.
- 3. Check its abilities If the document lists its response types or PKCE methods, they must include code and S256. Every endpoint it lists must be HTTPS.
- 4. Fetch the keys GET the jwks_uri: a JSON Web Key Set with one or more public keys.
- 5. Cache both Keep them for a while - an hour is common - instead of fetching them on every sign-in.
- 6. Follow key rotation A token signed with an unknown kid usually means the provider rotated its keys: fetch them again, but at most once in a short interval, so made-up kids cannot make you flood the provider.
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.
- Client id The public name of your app at the provider, sent with every request to the authorization endpoint.
- Client secret A password your server uses on the back channel. Browser and mobile apps cannot keep one secret, which is why PKCE exists.
- Redirect URI The only address the provider may send the code to. It must match exactly, or a code could be sent to an attacker's page.
- Scope What the app asks for: openid (give me an ID token), email and profile.
- Claim One fact inside a token, such as the subject, the email address or the expiry time.
- Authorization code A one-time, short-lived ticket that the server exchanges for tokens. On its own it opens nothing.
- 1. Home realm discovery The person types [email protected]. The app looks up which identity provider speaks for acme.example - the choice is made from the domain.
- 2. The app makes three random values A PKCE verifier, a state and a nonce, each from a secure random generator, kept where only the browser that asked can return them - for example a short-lived, encrypted cookie.
- 3. Redirect to the provider The browser goes to the authorization endpoint with response_type=code, the client id, the redirect address, the scopes, the state, the nonce and the PKCE challenge.
- 4. The person signs in at the provider With the company's own rules: its password, its second step, its device checks. The app never sees the password.
- 5. The provider redirects back To the app's callback address with a one-time code and the same state.
- 6. Check the state, once only The app compares the state with its saved copy and accepts the answer only once, so the same answer can never count twice.
- 7. Redeem the code The server POSTs the code and the verifier to the token endpoint, with its client credentials. The provider checks that the verifier hashes to the challenge.
- 8. Verify the ID token Signature, issuer, audience, lifetime and nonce - every check in the section on the ID token below.
- 9. Find the account, start a session By the provider's subject id. The app then starts its own session; it does not need to keep the provider's tokens.
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.
- Use a cryptographically secure random generator - never Math.random, a counter or a timestamp.
- Compare state and nonce in constant time (a comparison that takes the same time wherever the strings differ, so timing leaks nothing), and refuse a missing value: a check that passes when the value is absent is no check.
- A good design keeps them where only the starting browser has them: for example an encrypted, short-lived cookie that is HttpOnly (scripts cannot read it), Secure (sent over HTTPS only) and SameSite=Lax (sent on a top-level navigation from another site). It must be Lax, because the provider's redirect back is such a navigation and Strict would drop the cookie.
- A good design also spends the flow on first use on the server: deleting a cookie is the browser's choice, so remember which flows were answered for as long as one could live.
- Always use S256. The plain method sends the verifier itself, which protects nothing against someone who can read the redirect.
The options in the market
- OpenID Connect JSON and JWT on top of OAuth 2.0; Supported by every major provider: Entra ID, Okta, Google, Auth0, Keycloak; Discovery and key rotation are part of the standard; Mature libraries in every language; Some older enterprise setups offer only SAML
- SAML 2.0 Signed XML assertions; Required by many banks, governments and ADFS setups; Very mature in enterprise identity; Existing federation agreements and tooling at the customer; XML signature checks are notoriously easy to get wrong; Metadata and certificates are swapped by hand; A poor fit for mobile apps and APIs
- OAuth 2.0 alone Treat any access token as a sign-in; Simple to build for API access that really is delegated access; Not built for identity: a token issued to another app could sign someone in; No standard claim for who signed in
- Social sign-in only Personal Google or Microsoft accounts; Quick for individuals; The company cannot require it, remove a leaver or enforce its own second step
- The framework's OpenID Connect handler For example AddOpenIdConnect in ASP.NET Core; Little code; Well tested for one fixed provider; Built around providers registered at startup; one per customer, read from a database, is awkward; Its back channel runs inside the handler, where your own per-request network rules are hard to enforce
- A hand-written flow over a token library Own the redirects; let a library do the cryptography; The provider is chosen per sign-in, from the database; Every outbound request passes your own network guard; Each rule is visible and has its own test; More code to own; Every check must be remembered - and tested
- A hosted identity broker Auth0, Entra External ID, WorkOS and similar; SAML, OIDC and SCIM for many providers with little code; A cost per user or per connection; Another company sits in the middle of every sign-in
- A self-hosted identity server Keycloak or Duende IdentityServer as a broker you run; Full control and many protocols; A second critical system to run, patch and back up
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.
- A provider per organization, chosen at each sign-in from the address's domain and read from the database - not a fixed list known at startup.
- Addresses a customer typed are untrusted: the discovery document, the keys and the token endpoint are fetched through a guarded outbound client, built on the back-channel rules in the section below.
- No cryptography by hand: signature checks, key parsing and discovery parsing come from a maintained library. The flow code only decides what to ask and what to believe.
- One session model: a sign-in through a provider ends in {{brand}}'s own session, exactly like a password sign-in, so the second step, the session list and sign-out work one way.
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.
- Header JSON naming the signing algorithm (alg) and the key id (kid), encoded as base64url.
- Payload The claims: iss, sub, aud, exp, nonce, email and more, also JSON in base64url.
- Signature The provider signs the text header.payload with its private key and appends the result.
- Verify Recompute with the public key from the JWKS. Any changed byte, in the header or the payload, breaks the signature.
- Not secret base64url is encoding, not encryption: anyone who holds a token can read it. Treat tokens as credentials.
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.
- Believe only a vouched address Every sign-in needs an address the provider marks as verified, at a domain this company has proved - and at Google an hd claim for a proved domain too. A provider is never trusted for someone else's domain.
- A known subject wins If a link for this issuer and sub exists, that account is used - provided its own address still belongs to the company.
- Only a new subject creates or links Nothing new starts while the domain's proof is in doubt, for example when its record has just vanished.
- No account yet One is created on the spot, confirmed and with no password: just-in-time provisioning.
- An account already exists It is never linked silently. Its owner links it from inside a signed-in session, or through a link mailed to the account's own address, which first removes every old way in.
- Its own locks still hold If the account has its own second step, it is still asked: a provider stands in for the password, not for the account's other locks.
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.
- Make a random value Per organization and per domain, from a secure random generator, so it cannot be guessed or copied from another company.
- The company publishes it At a dedicated name under the domain (an underscore label), not at the domain itself, so it never clashes with mail records.
- Look it up Ask the resolver for TXT records at that name, with caching off, so a record is seen the moment it is published.
- Keep only records the name owns If the name is a CNAME alias, resolvers also return the target's records - somebody else's name. Drop them.
- Join the pieces A TXT value longer than 255 bytes arrives as several strings; join them before comparing.
- Hold it, and look again from time to time One organization holds a domain at a time. A record that disappears should start a grace period, and a resolver error must never count as missing.
- Refuse public mail domains such as gmail.com and outlook.com, and public suffixes such as co.uk: no single company can own them.
- Decide in advance what happens when two companies claim one domain and when a record vanishes: never move a held domain on a single DNS answer, give the holder a grace period during which nothing new starts, and let the database break ties with a unique index.
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.
- 1. Prove the connection works Require one successful sign-in through the provider before enforcement can be switched on.
- 2. Keep a local way back in Name a break-glass administrator with a passkey or password of their own that skips the provider.
- 3. Refuse password sign-in with one answer At the governed domain every password attempt gets the same neutral answer, whatever was typed. Keep the exceptions few and written down.
- 4. End the other sessions Enforcement alone only blocks the next sign-in, so end sessions that did not come through the provider.
- 5. Alert on every emergency sign-in A break-glass session is a security event and a notice to every administrator.
- 6. Test the emergency account on a schedule A break-glass account nobody has tried fails on the day it is needed.
- Large tenants keep two or more emergency accounts - Microsoft recommends this for Entra ID - with credentials stored apart from daily use.
- Protect them with a phishing-resistant second step such as a passkey or a hardware security key.
- Alert on every use: a quiet break-glass sign-in is exactly what an attacker hopes for.
- Test them on a schedule, for example every quarter, so they work on the day they are needed.
- Answer the same for every address at an enforced domain, account or not, so the sign-in form never reveals who has an 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.
- HTTPS only, and no user name or password inside the address.
- Resolve the host yourself, refuse private, loopback, link-local and cloud-metadata addresses such as 169.254.169.254, and connect to the very address you checked, so the DNS answer cannot change in between (DNS rebinding: a hostile name that shows a harmless address to your check and an internal one to the connection).
- Follow no redirects: a redirect is a second address nobody checked.
- Read at most a fixed number of bytes, with a timeout: a hostile server can stream forever.
- Rate-limit what you send to each provider, so your service cannot be used to flood somebody else's.
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.
- AuthnRequest The app redirects the browser to the IdP with a SAMLRequest: deflated, base64-encoded XML naming the app and a request id.
- Sign in The person signs in at the IdP with its usual rules.
- Assertion The IdP returns an HTML form that POSTs a signed SAMLResponse to the app's Assertion Consumer Service (ACS) address.
- Validate The app checks the XML signature against the IdP's certificate from its metadata, then the Issuer, the Audience, NotBefore and NotOnOrAfter, InResponseTo, and that the assertion was not used before.
- Session A persistent NameID - SAML's subject - identifies the person, and the app starts its own session.
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.
- Create POST /scim/v2/Users with userName, name, emails and active: true when someone joins.
- Change PATCH /scim/v2/Users/{id} when a name, a department or a group changes.
- Deactivate PATCH with active: false when someone leaves - the moment that matters most.
- Groups POST and PATCH /scim/v2/Groups keep team membership in step.
- Secure it The provider calls with a long-lived bearer token that can change every account: keep it hashed, scope it to one organization, and log every call.
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.
- Start Keycloak Run it in Docker detached, wait about a minute, then open its console at localhost:8080 as the admin user.
- Make a realm, a client and a user Realm demo; client demo-app with client authentication off and the standard flow on; valid redirect address http://localhost:3000/callback; one user with a password.
- Read the discovery document curl it (the realm must exist first) and find the authorization, token and jwks addresses.
- Make PKCE, state and nonce With openssl, as in the script below.
- Open the authorization address Sign in as your user. The browser lands on localhost:3000/callback, which does not load - but the code and the state are in the address bar.
- Redeem the code quickly The code lives for about a minute. POST it with the verifier. Then start again with a fresh code and a wrong verifier, and watch the provider refuse with invalid_grant.
- Read the ID token Decode its middle part and find iss, aud, sub, nonce and exp. Check that the nonce is yours.
- Verify it Run the jose snippet from the ID token section against the realm's jwks_uri and see it pass.
- Break it on purpose Change one character of the token, change the audience in your check, or reuse the code a second time. Note which check fails each time: invalid signature, wrong audience, or invalid_grant.
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
- Signing in with an access token instead of an ID token: the access token may have been issued to another app. Use OpenID Connect and verify the ID token.
- Using the email claim as the account key: addresses change and can be claimed. Key accounts by issuer plus subject; trust an address only when it is verified and at a proved domain.
- Skipping one check - usually the nonce, the audience or the algorithm list. Write each check down and test each one with a token that fails only that check.
- Accepting a multi-tenant issuer such as Microsoft's common endpoint as one company's provider: it answers for anyone who has an account.
- Linking an existing account silently because the email matches: this invites pre-account hijacking. Link from inside the account, or through a mailbox link that removes the old credentials first.
- Requiring SSO with no way back in: keep a tested break-glass account, and require one working sign-in before enforcement can be switched on.
Interview questions
- OAuth 2.0, OpenID Connect, SAML and SCIM - what does each do? OAuth 2.0 is delegated access: it gives an app an access token to call an API for someone. OpenID Connect adds identity on top of it - the openid scope, a signed ID token and discovery - and SAML does the same single sign-on job with signed XML assertions. SCIM is not sign-in at all: it is a REST API through which the directory creates, changes and deactivates accounts.
- Walk me through the authorization-code flow with PKCE. The app makes a verifier, a state and a nonce, keeps them in the browser and redirects to the provider with the hashed verifier as code_challenge. The provider signs the person in and sends back a one-time code and the state. The server checks the state, redeems the code on the back channel with the verifier, and verifies the ID token - which is why the old implicit flow, with tokens in the address bar, is gone.
- What do state, nonce and PKCE each protect against? State stops login CSRF by tying the answer to the browser that started the sign-in. The nonce stops ID-token replay and injection by tying the token to this sign-in. PKCE stops code interception: a stolen code cannot be redeemed without the verifier.
- What do you check on an ID token? The signature, with a key from the JWKS and an allowed asymmetric algorithm - never none or HS256, which let anyone forge a token. Then the exact issuer, an audience containing my client, azp when present, exp and nbf with small skew, the nonce, and a present subject. Only then are claims believed.
- Why do you need PKCE when you already have a client secret? The secret proves who the app is; PKCE proves that the app redeeming the code is the app that asked for it. A stolen code is useless without the verifier, and browser or mobile apps cannot keep a secret at all, which is why OAuth 2.1 requires PKCE for every client.
- What are a discovery document and a JWKS, and how do you handle key rotation? The discovery document at /.well-known/openid-configuration lists the provider's endpoints and abilities; the JWKS at jwks_uri lists its public signing keys with key ids. Cache both, and when a token carries an unknown kid, fetch the keys again - at most once in a short interval.
- Why match users by subject id and not by email? The subject is stable and unique within its issuer; an email can change, be reused, or even be set by the user at some providers, as the nOAuth findings showed. Store the link as issuer plus subject, and use the email only to offer a link once, never to link silently.
- Why prove a domain with a DNS TXT record? Otherwise any company could connect its own provider and claim addresses at your domain. A random value at a dedicated DNS name proves control of the domain; read only records the name itself owns, never through a CNAME, and never move a held domain on one answer.
- How do you enforce SSO without locking a company out? Require one successful sign-in through the provider first, and name a break-glass administrator with a strong local sign-in. Refuse password sign-in at the domain with one neutral answer, end the other sessions, and alert on every break-glass use and check daily that the break-glass administrator can still sign in.
- When would you not use your framework's OpenID Connect handler? When the provider is chosen per customer at runtime, or every outbound call must pass your own SSRF and rate rules. Then you run the redirects yourself and keep a maintained library for the cryptography. For one fixed provider the handler is the better choice.
Related
- /help/technology/sign-in
- /help/technology/access
- /help/technology/private-edition