## TL;DR

Fix the forwarded protocol and host headers so the callback URL the hub builds matches the one the OAuth provider expects.
The proxy is likely terminating TLS while the hub thinks it is on plain HTTP, so the redirect URI and the state cookie disagree.
Set the proxy to forward X-Forwarded-Proto and X-Forwarded-Host, and tell the hub to trust those headers.

```text
jupyterhub oauth callback failed: state mismatch behind a reverse proxy
```

## Use this when

- OAuth login redirects back and fails with a state mismatch or invalid state error
- Login works when hitting the hub directly but fails through the reverse proxy
- The OAuth provider log shows a redirect_uri mismatch mentioning http instead of https

## Not for this skill when

- Login succeeds but spawn returns 403 - that is item 101
- The callback succeeds and the failure is at spawn - that is an authorization problem
- The provider rejects the client id or secret - that is an OAuth app registration problem

## Steps

1. Capture the failing redirect: watch the browser network log during login.
   Expected: the authorize URL uses one scheme (https) while the hub later builds the callback with another (http).

2. Configure the reverse proxy to forward the original scheme and host: set X-Forwarded-Proto to https and X-Forwarded-Host to the public hostname.
   Expected: the hub receives both headers on every request.

3. Tell the hub to trust the proxy headers: c.JupyterHub.trust_xheaders = True, and set c.JupyterHub.bind_url to the public https URL. Restart the hub.
   Expected: the hub generates callback URLs with the public https scheme.

4. Confirm the OAuth app registration lists the exact callback URL the hub now builds, including scheme, host, and the /hub/oauth_callback path.
   Expected: character-for-character match with the hub log line showing the redirect URI.

5. Log in again through the proxy.
   Expected: the callback completes, the state validates, and the user lands on the spawn page with no mismatch error.

## Tool compatibility

JupyterHub 4.x / 5.x, OAuthenticator 16.x, nginx / Traefik / cloud ingress, any OAuth2 provider

## Variant phrasings

### JupyterHub oauth state mismatch behind nginx

Same fix applies: see the Steps section above.

### oauth_callback failed invalid state jupyterhub

Same fix applies: see the Steps section above.

### JupyterHub login loop redirect_uri mismatch proxy

Same fix applies: see the Steps section above.

## Why it happens

OAuth2 ties the authorization request to the callback with a state value stored in a cookie. Behind a TLS-terminating proxy, the hub sees plain HTTP unless the proxy forwards the original scheme, so it builds a callback URL the provider never registered and the cookie scoping disagrees with the browser. The provider then rejects the exchange or the hub rejects the state, and the user bounces between login and callback. Trusting the forwarded headers makes the hub reason about the public URL instead of the internal one, which is what both the provider registration and the browser expect.

## Edge cases / pitfalls

- trust_xheaders must only be set when a trusted proxy strips spoofed headers; an open hub directly on the internet should not trust client-supplied X-Forwarded headers.
- Secure cookies set without the Secure flag over http can silently drop the state cookie; keep everything https end to browser.
- Some providers require the callback URL registered without a trailing slash; match it exactly.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_pDkxry81caTf9xe2lOfy5g
