OIDC login via Discourse iOS app occasionally fails with csrf_detected on callback

Hi,

I’m running Discourse ( 2026.2.0-latest (f7cec86997))with OpenID Connect (Azure / Entra ID as IdP).

I’ve noticed an occasional login failure that only seems to occur when users attempt to sign in via the Discourse iOS app.

From the server logs, the flow looks like:

POST /auth/oidc
GET  /auth/oidc/callback?...state=...
(oidc) Authentication failure! csrf_detected

The callback does reach Discourse, but the CSRF/state validation fails, so no user account is created.

The surrounding logs suggest this is happening in the app handoff flow:
application_name=Discourse - iPhone
auth_redirect=discourse://auth_redirect

From the user’s perspective, nothing obvious appears - they’re simply returned to the login screen and often don’t remember seeing an error.

This doesn’t seem to occur when logging in via Safari or desktop browsers.

My assumption is that this is related to iOS cookie partitioning / context switching between the in-app browser and the app callback.

I just wanted to sanity-check:
• whether this is expected behaviour with OIDC + the iOS app
• and whether there are any recommended mitigations beyond ensuring a strict canonical HTTPS origin

Thanks - happy to provide anonymised log snippets if helpful.

Extra data point from nginx access logs:

A representative failure (2026-01-25 11:44:10 UTC) shows the OIDC callback request is coming from an iOS in-app browser UA (Snapchat), not the Discourse iOS app webview UA:

GET /auth/oidc/callback?...state=... 302
UA: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) ... Snapchat/13.76.1.0 (like Safari/..., panda)
Referer: https://login.microsoftonline.com/

Immediately followed by:
GET /auth/failure?message=csrf_detected&strategy=oidc

So it looks like the OAuth flow is sometimes initiated inside an iOS in-app browser (Snapchat/other),
then the handoff occurs (I’ve also seen logs containing auth_redirect=discourse://auth_redirect),
and the session cookie/state doesn’t survive consistently.

Current setting: SiteSetting.same_site_cookies = "Lax".

Question: is Discourse’s mobile app auth flow expected to be reliable when the login is initiated from iOS in-app browsers that then deep-link into the Discourse app?
Would switching same_site_cookies to “None” be the recommended mitigation here, or is there a better approach?

Following up with some additional investigation and confirmation from real-world usage.

After digging further, I think this is indeed an iOS in-app browser (WKWebView) limitation rather than anything specific to Discourse or Azure configuration.

What I’ve been able to confirm

From nginx + Rails logs and user testing:
• The OAuth flow is sometimes initiated inside an iOS in-app browser (e.g. Snapchat)
• Microsoft login (login.microsoftonline.com) is loaded inside that in-app browser
• The OIDC callback does reach Discourse successfully
• But the session cookie containing the state value does not survive
• Resulting in a deterministic:

GET /auth/failure?message=csrf_detected&strategy=oidc

This occurs even though the referer is Microsoft and the redirect URI is correct.

A representative UA:

Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X)
Snapchat/13.76.1.0 (like Safari/..., panda)

So although the UI “oneboxes” Microsoft nicely, the underlying browser is still Snapchat’s WKWebView, not Safari / ASWebAuthenticationSession.

Theme component interception does not work

I attempted to mitigate this client-side using a theme component:
• detecting known in-app browser UAs
• blocking /auth/oidc links
• displaying a persistent overlay instructing users to open in Safari/Chrome

However, this does not reliably intercept the flow, because:
• the OAuth redirect is initiated server-side
• the state cookie must already exist before client JS runs
• by the time the theme loads, the damage is already done

So a theme component cannot reliably prevent this failure mode.

What does work reliably

The only mitigation I’ve found that works consistently is overriding the site text:

login.omniauth_error.csrf_detected

to explicitly explain that:
• the login failed due to an in-app browser
• users should open the site in Safari or Chrome
• then retry login

This message is rendered server-side after the failure and therefore appears even in broken in-app browser contexts.

That has significantly reduced user confusion, since previously users were silently returned to the login page and often didn’t realise anything went wrong.

About SameSite cookies

I have not switched same_site_cookies to “None”.

Given this is a WKWebView isolation issue (rather than cross-site navigation in Safari), changing SameSite doesn’t appear to address the root cause and may introduce unnecessary security trade-offs.

Open question

Given the above, I wanted to sanity-check whether:
• this behaviour is considered expected when OAuth is initiated from iOS in-app browsers
• and whether Discourse intends to support that flow, or simply document that login must occur from a real browser

It might also be useful for Discourse to surface a more explicit default message for csrf_detected in OmniAuth contexts, as this failure mode seems increasingly common with student users arriving via Snapchat / Instagram links.

Happy to provide more anonymised logs if useful - but at this point the behaviour seems very consistent and reproducible.

Thanks for taking a look.

One additional data point that may help confirm this is a deterministic WKWebView limitation rather than intermittent behaviour.

By correlating nginx access logs across multiple failures, I’ve found that the same OIDC state value is repeatedly reused by the in-app browser when users retry login.

Example (sanitised):
• Same state hash seen 14 times
• All requests from:

Snapchat/13.77.0.51 (like Safari…, panda)

• Spread across ~1 hour of repeated login attempts
• Each attempt results in:

/auth/oidc/callback → /auth/failure?message=csrf_detected

This strongly suggests that:
• the browser never successfully stores the session cookie containing the original state
• each retry reuses the same stale state parameter
• Discourse is therefore correctly rejecting the callback every time

In contrast, when the same user opens the link in Safari, a new state is generated and the login succeeds immediately.

So this appears to be a fully deterministic failure mode in certain iOS in-app browsers rather than a timing or race condition.

From Discourse’s perspective, the CSRF protection is behaving exactly as intended - the browser environment simply cannot maintain the required session continuity.

I think this further supports that:
• this is not something a theme component or client JS can mitigate
• and that the only reliable handling is server-side messaging and documentation

Posting in case this data point is useful for confirming expected behaviour.