SSO Common Pitfalls
The mistakes that break SSO in production. Symptoms first, so you recognize them; root causes second; fixes third.
“It works in dev, breaks in prod”
The classic. Causes:
- Different IdP environments: dev points at Okta-preview, prod at Okta. Different signing certs, different metadata.
- Different
redirect_uri: dev useshttp://localhost:8000/callback, prod useshttps://app.example.com/callback. Both must be registered. - Different client_id / secret: env-var-driven, but easy to mix up.
- HTTPS-only cookies in prod — Set-Cookie with
Secureoverlocalhost(no TLS in dev) drops silently if you didn’t think about it.
Fix: same flow in dev as prod (HTTPS via reverse proxy / tunnel like ngrok), same IdP if possible (separate “client app” within the same tenant).
Clock skew
jwt.exceptions.ExpiredSignatureError: Signature has expired
App and IdP clocks differ by 30+ seconds. Token’s exp is “valid” by IdP clock but “expired” by app clock.
Causes: VM clock drift, NTP issues, Docker container time on macOS.
Fix:
- Sync clocks via NTP (
chronyd,systemd-timesyncd). - Allow leeway in token verification:
jwt.decode(token, key, algorithms=["RS256"], leeway=60) # ±60s tolerance
Authlib defaults to ~5s leeway; PyJWT default is 0 (strict). Set explicit 30–60s for production.
Same for SAML’s NotBefore/NotOnOrAfter — most libraries have a leeway parameter.
Certificate / key rotation
IdP rotates its signing cert. Old assertions are signed with old key; new ones with new key. If your app cached the old cert and doesn’t refetch, validation fails.
Causes:
- App caches certs forever.
- App hardcoded a single cert in env vars.
- Customer didn’t notify you of rotation (for paste-in configs).
Fix:
- For OIDC: re-fetch JWKS periodically; on signature failure, try refetching once before giving up.
- For SAML with metadata URL: fetch metadata daily.
- For SAML with static cert: notify customer well before expiry; document rotation procedure.
Some IdPs publish multiple valid certs during rotation periods; your app should accept assertions signed by any of them.
redirect_uri mismatch
error=redirect_uri_mismatch
The URL the app sends to the IdP doesn’t EXACTLY match a registered redirect URI at the IdP.
Common mismatches:
- Trailing slash (
/callbackvs/callback/). - Protocol (
http://vshttps://). - Subdomain (
www.example.comvsexample.com). - Port (
:443explicit vs implicit). - Path case (
/Callbackvs/callback).
Fix: registered URIs are exact-match. Standardize one form everywhere.
CORS errors during the auth flow
Access to fetch at 'https://idp.example.com/token' from origin 'https://app.example.com' has been blocked by CORS policy
SPA tries to call the token endpoint directly. Usually you DON’T — the token exchange happens server-side. SPAs should:
- Use auth code flow with PKCE (no client_secret).
- The redirect comes back to YOUR backend, not the SPA.
- Backend exchanges the code, sets a session cookie, redirects to the SPA.
If you must call the token endpoint from the SPA: ensure the IdP’s CORS allows your origin (most don’t for confidential clients).
“id_token validation failed: nonce mismatch”
The nonce in the ID token doesn’t match what your app sent.
Causes:
- App didn’t store the nonce between authorize and callback.
- App stored it in a session that was lost.
- User started two parallel logins; nonces got mixed up.
Fix: store nonce server-side keyed by state. Validate exactly.
Lost state on the callback
"state" parameter mismatch
The state value the IdP returns isn’t one your app recognizes.
Causes:
- App restarted between authorize and callback; in-memory state lost.
- Multiple processes / containers without shared state; the callback hit a different replica.
- Cookie didn’t persist across the redirect.
Fix: store state in shared storage (Redis, DB). Set the cookie carrying state to be valid across the entire flow.
aud validation surprises
JWT validation error: audience mismatch
ID token’s aud doesn’t include your client_id.
Causes:
- App configured with wrong client_id.
- IdP’s
azpis set butauddoesn’t include the client (rare). - Token from a different OIDC app (mixing test/prod again).
Fix: ensure your app’s client_id matches one of the aud values; check for azp (authorized party) edge cases.
Token in URL
A common security review finding:
GET /callback?id_token=eyJhbG... HTTP/1.1
URL params are logged everywhere: access logs, browser history, referer headers, third-party analytics.
Causes: implicit flow (deprecated), bad fragment handling.
Fix: use auth code flow. Tokens never appear in URLs; they come back via the token endpoint server-to-server.
Session cookie not sent on callback
After the IdP redirects back to your callback, the user’s browser doesn’t send your session cookie. Application loses context.
Causes:
SameSite=Strictblocks cookies on cross-site navigation (the IdP is a different origin).- Wrong cookie domain.
- Cookie was set on a different path.
Fix: SameSite=Lax is the standard for SSO callbacks. Strict blocks the SSO flow.
CSRF on logout
User visits an attacker’s page:
<img src="https://yourapp.com/logout">
Logout fires; user is annoyed.
Causes: logout endpoint is GET, no CSRF protection.
Fix: logout is POST with CSRF token, or rely on SameSite=Lax cookies (top-level GET doesn’t carry the cookie). For the cleanest UX: a “Logged out” page that POSTs from a JS click.
Browser back button after logout
User logs out, presses back, sees cached pages from before logout.
Causes: browser cache; no Cache-Control on sensitive pages.
Fix:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
For authenticated pages. The browser won’t serve stale.
SAML response too large
For browsers POSTing a SAML response, the form body can exceed limits in proxies or app servers (default ~1 MB). Especially with encrypted assertions + group membership in many groups.
Causes: large AttributeStatement (long group lists), embedded certs.
Fix: increase body limit in nginx/proxy (client_max_body_size). Or move group claims to a separate API call.
Token leaks via Sentry / error reports
A 500 error from your token-exchange code captures the full token in the exception report. Now Sentry has long-lived bearer tokens.
Causes: error reporters scrape request data by default.
Fix: configure Sentry’s data scrubbing:
sentry_sdk.init(
dsn=...,
send_default_pii=False,
before_send=scrub_sensitive,
)
def scrub_sensitive(event, hint):
if "request" in event:
headers = event["request"].get("headers", {})
for k in list(headers.keys()):
if k.lower() in ("authorization", "cookie", "x-api-key"):
headers[k] = "[Filtered]"
return event
Refresh token never expires
A long-lived refresh token (years, no rotation) is effectively a persistent password.
Causes: misconfigured at IdP, no rotation.
Fix:
- Enable refresh token rotation at the IdP.
- Cap absolute lifetime at the IdP (e.g. 30 days).
- Revoke on logout.
“Wrong tenant” — cross-tenant leak
User from Acme accidentally lands in Globex’s tenant. The auth flow’s tenant binding broke somewhere.
Causes:
- Callback handler doesn’t validate tenant from state.
- One client_id across all tenants.
- Subdomain routing didn’t scope cookies.
Fix: tenant binding throughout the flow (10_multi_tenant_sso.md). Scope cookies. Verify audience matches expected tenant on assertion.
email_verified: false accepted as identity
Your app uses email as the identifier and trusts it without checking email_verified. An attacker creates an IdP account with the victim’s email (some IdPs don’t require email verification).
Causes: matching on email instead of sub; ignoring email_verified.
Fix: match on sub. If you must use email for human display, check email_verified and reject if false.
“User can’t log in for some reason” — diagnostic checklist
When SSO breaks for a user:
- Look at the IdP’s logs first. The IdP often logs the reason (MFA failed, group required, license expired).
- Capture the error response — most IdPs include an error code + description in the callback URL.
- Decode the ID token (if you got one) —
jwt.ioshows claims. Validateiss,aud,exp,noncemanually. - Inspect the SAML response — base64-decode the POST body, then verify the assertion’s structure and signature offline.
- Compare timestamps — clock skew? Token expired by the time you validated?
- Check JWKS / cert freshness — has the IdP rotated recently?
- Confirm
redirect_uriexact-matches what’s registered.
Most SSO bugs are config drift, not code. Your validators are usually right; the inputs are wrong.
Common interview confusions
- “SSO
just worksafter first setup.” — every cert rotation, IdP migration, redirect URI tweak, claim format change can break it. Treat SSO as a maintained integration. - “All errors are exploitable.” — most are configuration. The exploitable bugs (XSW, alg none, missing audience check) are library/code issues; configure carefully, use current libraries.
- “You can keep using a library version with known CVEs if you don’t use the vulnerable feature.” — usually false; auth code paths interact. Update.
Interview angle
- “You’ve enabled SSO with a customer’s IdP and login fails with ‘redirect_uri mismatch.’ What do you check?” — exact-match comparison: protocol, host, port, path, trailing slash, case. Often the registered URI has a trailing slash and your config doesn’t (or vice versa).
- “Tokens are ‘expired’ immediately after issuance — why?” — clock skew. App’s clock is behind the IdP. Sync via NTP and allow 30–60s leeway in validation.
- “Your app validates tokens correctly but signature checks fail intermittently after the IdP did maintenance — why?” — cert/JWKS rotation. Cache the keys but re-fetch on signature failure; or refresh keys periodically (every few hours).
- “How would you safely log SSO callbacks for debugging?” — log the metadata (issuer, state, error fields) but scrub authorization headers, codes, tokens, and PII. Configure error reporters (Sentry) to filter sensitive request data.
- “A user complains they got logged into the wrong tenant. What’s the most likely root cause?” — missing tenant binding on the callback. The
stateshould encode the tenant; the assertion’s audience should match a tenant-specific SP entity ID. If either step skipped, cross-tenant leak is possible. - “Why might the same SSO library work for Okta but fail for Azure AD?” — vendor quirks in claim names (groups as UUIDs in AzureAD), PATCH formats (SCIM), endpoint paths, supported algorithms. Test against each IdP your customers use.