backend / authentication / sso / 12_common_pitfalls.md

SSO Common Pitfalls

6 interview angles 8 min read source

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

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 (/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

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

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:

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

  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

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