Backend / Authentication / SSO / 12_common_pitfalls.md

SSO Common Pitfalls

Updated 6 interview angles 8 min read source
On this page20
  1. “It works in dev, breaks in prod”
  2. Clock skew
  3. Certificate / key rotation
  4. redirect_uri mismatch
  5. CORS errors during the auth flow
  6. “id_token validation failed: nonce mismatch”
  7. Lost state on the callback
  8. aud validation surprises
  9. Token in URL
  10. Session cookie not sent on callback
  11. CSRF on logout
  12. Browser back button after logout
  13. SAML response too large
  14. Token leaks via Sentry / error reports
  15. Refresh token never expires
  16. “Wrong tenant” — cross-tenant leak
  17. email_verified: false accepted as identity
  18. “User can’t log in for some reason” — diagnostic checklist
  19. Common interview confusions
  20. Interview angle

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 uses http://localhost:8000/callback, prod uses https://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 Secure over localhost (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

text
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:
python
# ±60s tolerance
jwt.decode(token, key, algorithms=["RS256"], leeway=60)

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

text
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 (/callback vs /callback/).
  • Protocol (http:// vs https://).
  • Subdomain (www.example.com vs example.com).
  • Port (:443 explicit vs implicit).
  • Path case (/Callback vs /callback).

Fix: registered URIs are exact-match. Standardize one form everywhere.

CORS errors during the auth flow

text
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

text
"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

text
JWT validation error: audience mismatch

ID token’s aud doesn’t include your client_id.

Causes:

  • App configured with wrong client_id.
  • IdP’s azp is set but aud doesn’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:

text
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.

After the IdP redirects back to your callback, the user’s browser doesn’t send your session cookie. Application loses context.

Causes:

  • SameSite=Strict blocks 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:

html
<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:

python
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:

python
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 (Multi-Tenant SSO). 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:

  1. Look at the IdP’s logs first. The IdP often logs the reason (MFA failed, group required, license expired).
  2. Capture the error response — most IdPs include an error code + description in the callback URL.
  3. Decode the ID token (if you got one) — jwt.io shows claims. Validate iss, aud, exp, nonce manually.
  4. Inspect the SAML response — base64-decode the POST body, then verify the assertion’s structure and signature offline.
  5. Compare timestamps — clock skew? Token expired by the time you validated?
  6. Check JWKS / cert freshness — has the IdP rotated recently?
  7. Confirm redirect_uri exact-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 works after 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 6

  • “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 state should 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.