OIDC csrf_detected can mask user cancel / consent rejection - docs could clarify log inspection

AI-generated summary

Ethsim2 highlights a documentation gap regarding OIDC login failures where the generic csrf_detected error masks distinct upstream causes, specifically user-initiated cancellations or consent rejections from Identity Providers (IdPs) like Azure/Entra ID. While previous discussions addressed deterministic failures due to iOS WKWebView cookie isolation, this post clarifies that valid user actions (e.g., error=consent_required or error=access_denied) also result in the same csrf_detected failure in Discourse because the state cannot be validated. Consequently, administrators may incorrectly diagnose these as cookie or configuration issues when they are actually user decisions. Ethsim2 suggests updating documentation to explicitly state that csrf_detected can stem from user rejection and advises operators to inspect Admin → Logs → env / params to differentiate these cases from technical failures. Additionally, Ethsim2 notes a minor log-viewer quirk where special characters like & appear as escaped sequences (e.g., \u0026), urging admins to interpret these correctly to identify the true IdP error codes.

Continuing the discussion from OIDC login via Discourse iOS app occasionally fails with csrf_detected on callback:

Hi all,

This is a follow-up observation linked to my earlier thread about OIDC login failures initiated from iOS in-app browsers. That discussion focused on deterministic csrf_detected failures caused by WKWebView cookie isolation, which now seem well understood and expected.

This linked topic is about operator clarity, not a bug.


Observation

While investigating a series of OIDC login failures, I’ve noticed that the same surface error in Discourse:

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

can correspond to multiple, fundamentally different upstream causes, depending on what the IdP returned.

From the application UI alone, these cases are indistinguishable. The difference is only visible by inspecting Admin → Logs → env / params.


Examples seen in practice (Azure / Entra ID)

In addition to in-app browser cookie loss, I’ve observed callbacks where Entra ID explicitly returns structured errors such as:

User declined consent

error=consent_required
error_description=AADSTS65004: User declined to consent to access the app

User cancelled login

error=access_denied
error_subcode=cancel

In both cases:
• Azure successfully identified the user
• The user explicitly chose not to proceed (reject / cancel)
• Discourse receives the callback
• The flow ultimately resolves to /auth/failure?message=csrf_detected

From Discourse’s perspective, this is correct and secure behaviour - the state cannot be validated or completed - but the underlying reason is very different from a missing session cookie.


Why this matters for operators

Without checking the log env/params, an admin seeing repeated csrf_detected failures may reasonably assume:
• broken cookies
• SameSite misconfiguration
• mobile browser issues
• IdP instability

…when in reality, some of those failures are simply users choosing not to consent or cancelling the Microsoft prompt.

This distinction only becomes clear if you already know to inspect the raw log payload.


Suggestion (documentation / UX only)

I’m not suggesting any behavioural change to OmniAuth or CSRF handling.

It might be helpful if documentation or troubleshooting guidance explicitly noted that:
• csrf_detected can be the final error for multiple upstream IdP outcomes
• including explicit user actions like cancel or consent rejection
• and that admins should inspect Admin → Logs → env / params to distinguish these cases

This would make it easier for operators to:
• correctly diagnose login failures
• avoid unnecessary configuration changes
• and give users accurate guidance (“you cancelled / declined consent” vs “your browser blocked cookies”).


Context

For clarity: this is separate from the confirmed iOS in-app browser issue discussed in the linked topic. In that case, the IdP never reaches the point of user consent, whereas here the IdP explicitly reports user intent.

Both end up looking similar at the UI level unless logs are examined.


Thanks for reading - posting this mainly as a documentation clarity/data-point for others running OIDC in student-heavy environments where these cases occur frequently.

Happy to provide anonymised examples if useful.

Minor log-viewer gotcha (FYI)

One small thing that tripped me up while inspecting the logs:

In Admin → Logs → env → params, values may appear with escaped sequences such as:

\u0026

This is just JSON/Ruby escaping for &. For example:

error=access_denied\u0026error_subcode=cancel

should be read as:

error=access_denied&error_subcode=cancel

Nothing is being mangled or lost - it’s purely a display/serialization detail - but it’s worth knowing so operators don’t miss the underlying IdP signal when diagnosing csrf_detected failures.