Repository: jupyterhub/jupyterhub

Issue:
Empty Admin page when the hub is behind nginx proxy
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description
<!-- Use this section to clearly and concisely describe the bug. -->

Merry Christmas!

The Admin settings of jupyterhub v. 2.0.1 are not shown if the hub is behind a reverse nginx proxy.
The symptoms appear to be similar to #3711 except that they are now not caused by `base_url`.

#### Expected behaviour
<!-- Tell us what you thought would happen. -->

Admin page when the hub is accessed directly:

<img width="684" alt="Screenshot 2021-12-25 at 09 32 58" src="https://user-images.githubusercontent.com/2140361/147381008-67c6e49d-a583-47d0-aab2-bc1246976b1e.png">


#### Actual behaviour
<!-- Tell us what actually happens. -->

Admin page when the hub is accessed through a proxy:

<img width="683" alt="Screenshot 2021-12-25 at 09 34 48" src="https://user-images.githubusercontent.com/2140361/147381033-be241b38-db3f-410c-a439-2d4eb3f71f33.png">

This message looks suspicious (see the full log below):
```
jupyterhub_1  | [W 2021-12-25 08:58:25.156 JupyterHub base:89] Blocking Cross Origin API request.  Referer: http://127.0.0.1:8080/hub/admin, Host: 127.0.0.1, Host URL: http://127.0.0.1/hub/
```

There is no problem with `jupyterhub/jupyterhub:1.5.0`

### How to reproduce
<!-- Use this section to describe the steps that a user would take to experience this bug. -->

1. Ensure the following directory structure:
```
.
├── docker-compose.yml
├── jupyterhub
│   ├── Dockerfile
│   └── jupyterhub_config.py
└── nginx
    ├── Dockerfile
    └── nginx.conf
```
2. Ensure the following file contents:
  - `docker-compose.yml`:
```
version: '3'
services:
  nginx: 
    image: nginx
    build: nginx
    ports:
      - 8080:80
      
  jupyterhub:
    image: jupyterhub
    build: jupyterhub
    expose:
      - "8000"
```
  - `jupyterhub/Dockerfile`:
```
FROM jupyterhub/jupyterhub:2.0.1

COPY jupyterhub_config.py /etc/jupyterhub/

CMD jupyterhub -f /etc/jupyterhub/jupyterhub_config.py --debug
```
- `jupyterhub/jupyterhub_config.py`:
```
c.Authenticator.admin_users = {'user'}
c.JupyterHub.authenticator_class = 'dummy'
```
- `nginx/Dockerfile`:
```
FROM nginx:1.21.4

COPY nginx.conf /etc/nginx/
```
- `nginx/nginx.conf` ([as documented](https://jupyterhub.readthedocs.io/en/stable/reference/config-proxy.html#nginx) but without ssl):
```
events {}
http {
  server {
    listen 0.0.0.0:80;
    server_name localhost;

    location / {
      proxy_pass http://jupyterhub:8000/;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

      # websocket headers
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection upgrade;
      proxy_set_header X-Scheme $scheme;

      proxy_buffering off;
    }

    real_ip_header proxy_protocol;
    set_real_ip_from 127.0.0.1;

  }

}
```
3. Build containers: 
`docker-compose build`
4. Start the containers:
`docker-compose up`
5. Visit the page: http://127.0.0.1:8080/hub/admin
6. Enter the user name `user`

### Your personal set up
<!--
Tell us a little about the system you're using.
Please include information about how you installed,
e.g. are you using a distribution such as zero-to-jupyterhub or the-littlest-jupyterhub.
 -->

 - OS: MacOS 12.1
 - Version(s):
```
% docker --version    
Docker version 20.10.11, build dea9396
% docker-compose --version
Docker Compose version v2.2.1
```

<details><summary>Logs</summary>

- docker-compose:
```
% docker-compose up
[+] Running 2/0
 ⠿ Container jupyterhub-test1_jupyterhub_1  Created                                                                                 0.0s
 ⠿ Container jupyterhub-test1_nginx_1       Created                                                                                 0.0s
Attaching to jupyterhub_1, nginx_1
nginx_1       | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
nginx_1       | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
nginx_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
nginx_1       | 10-listen-on-ipv6-by-default.sh: info: IPv6 listen already enabled
nginx_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
nginx_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
nginx_1       | /docker-entrypoint.sh: Configuration complete; ready for start up
jupyterhub_1  | [D 2021-12-25 08:58:14.902 JupyterHub application:731] Looking for /etc/jupyterhub/jupyterhub_config in /srv/jupyterhub
jupyterhub_1  | [D 2021-12-25 08:58:14.902 JupyterHub application:753] Loaded config file: /etc/jupyterhub/jupyterhub_config.py
jupyterhub_1  | [I 2021-12-25 08:58:14.912 JupyterHub app:2766] Running JupyterHub version 2.0.1
jupyterhub_1  | [I 2021-12-25 08:58:14.912 JupyterHub app:2796] Using Authenticator: jupyterhub.auth.DummyAuthenticator-2.0.1
jupyterhub_1  | [I 2021-12-25 08:58:14.912 JupyterHub app:2796] Using Spawner: jupyterhub.spawner.LocalProcessSpawner-2.0.1
jupyterhub_1  | [I 2021-12-25 08:58:14.912 JupyterHub app:2796] Using Proxy: jupyterhub.proxy.ConfigurableHTTPProxy-2.0.1
jupyterhub_1  | [I 2021-12-25 08:58:14.924 JupyterHub app:1606] Loading cookie_secret from /srv/jupyterhub/jupyterhub_cookie_secret
jupyterhub_1  | [D 2021-12-25 08:58:14.930 JupyterHub app:1773] Connecting to db: sqlite:///jupyterhub.sqlite
jupyterhub_1  | [D 2021-12-25 08:58:14.959 JupyterHub orm:955] database schema version found: 833da8570507
jupyterhub_1  | [I 2021-12-25 08:58:15.017 JupyterHub proxy:496] Generating new CONFIGPROXY_AUTH_TOKEN
jupyterhub_1  | [D 2021-12-25 08:58:15.017 JupyterHub app:2019] Loading roles into database
jupyterhub_1  | [I 2021-12-25 08:58:15.035 JupyterHub app:1924] Not using allowed_users. Any authenticated user will be allowed.
jupyterhub_1  | [D 2021-12-25 08:58:15.042 JupyterHub app:2278] Purging expired APITokens
jupyterhub_1  | [D 2021-12-25 08:58:15.047 JupyterHub app:2278] Purging expired OAuthCodes
jupyterhub_1  | [D 2021-12-25 08:58:15.053 JupyterHub app:2111] Loading role assignments from config
jupyterhub_1  | [D 2021-12-25 08:58:15.076 JupyterHub app:2424] Initializing spawners
jupyterhub_1  | [D 2021-12-25 08:58:15.080 JupyterHub app:2555] Loaded users:
jupyterhub_1  |     
jupyterhub_1  | [I 2021-12-25 08:58:15.081 JupyterHub app:2835] Initialized 0 spawners in 0.006 seconds
jupyterhub_1  | [W 2021-12-25 08:58:15.087 JupyterHub proxy:565] Found proxy pid file: /srv/jupyterhub/jupyterhub-proxy.pid
jupyterhub_1  | [W 2021-12-25 08:58:15.088 JupyterHub proxy:577] Proxy no longer running at pid=12
jupyterhub_1  | [D 2021-12-25 08:58:15.088 JupyterHub proxy:618] Removing proxy pid file jupyterhub-proxy.pid
jupyterhub_1  | [W 2021-12-25 08:58:15.090 JupyterHub proxy:687] Running JupyterHub without SSL.  I hope there is SSL termination happening somewhere else...
jupyterhub_1  | [I 2021-12-25 08:58:15.090 JupyterHub proxy:691] Starting proxy @ http://:8000
jupyterhub_1  | [D 2021-12-25 08:58:15.090 JupyterHub proxy:692] Proxy cmd: ['configurable-http-proxy', '--ip', '', '--port', '8000', '--api-ip', '127.0.0.1', '--api-port', '8001', '--error-target', 'http://127.0.0.1:8081/hub/error']
jupyterhub_1  | [D 2021-12-25 08:58:15.098 JupyterHub proxy:610] Writing proxy pid file: jupyterhub-proxy.pid
jupyterhub_1  | 08:58:16.203 [ConfigProxy] info: Proxying http://*:8000 to (no default)
jupyterhub_1  | 08:58:16.210 [ConfigProxy] info: Proxy API at http://127.0.0.1:8001/api/routes
jupyterhub_1  | [D 2021-12-25 08:58:16.219 JupyterHub proxy:728] Proxy started and appears to be up
jupyterhub_1  | [D 2021-12-25 08:58:16.221 JupyterHub proxy:821] Proxy: Fetching GET http://127.0.0.1:8001/api/routes
jupyterhub_1  | 08:58:16.244 [ConfigProxy] info: 200 GET /api/routes 
jupyterhub_1  | [I 2021-12-25 08:58:16.251 JupyterHub app:3084] Hub API listening on http://127.0.0.1:8081/hub/
jupyterhub_1  | [D 2021-12-25 08:58:16.252 JupyterHub proxy:343] Fetching routes to check
jupyterhub_1  | [D 2021-12-25 08:58:16.252 JupyterHub proxy:821] Proxy: Fetching GET http://127.0.0.1:8001/api/routes
jupyterhub_1  | 08:58:16.265 [ConfigProxy] info: 200 GET /api/routes 
jupyterhub_1  | [D 2021-12-25 08:58:16.271 JupyterHub proxy:346] Checking routes
jupyterhub_1  | [I 2021-12-25 08:58:16.273 JupyterHub proxy:431] Adding route for Hub: / => http://127.0.0.1:8081
jupyterhub_1  | [D 2021-12-25 08:58:16.276 JupyterHub proxy:821] Proxy: Fetching POST http://127.0.0.1:8001/api/routes/
jupyterhub_1  | 08:58:16.282 [ConfigProxy] info: Adding route / -> http://127.0.0.1:8081
jupyterhub_1  | 08:58:16.287 [ConfigProxy] info: Route added / -> http://127.0.0.1:8081
jupyterhub_1  | 08:58:16.290 [ConfigProxy] info: 201 POST /api/routes/ 
jupyterhub_1  | [I 2021-12-25 08:58:16.293 JupyterHub app:3150] JupyterHub is now running at http://:8000
jupyterhub_1  | [D 2021-12-25 08:58:16.296 JupyterHub app:2759] It took 1.401 seconds for the Hub to start
jupyterhub_1  | [I 2021-12-25 08:58:17.444 JupyterHub log:189] 302 GET /hub/admin -> /hub/login?next=%2Fhub%2Fadmin (@172.20.0.1) 1.23ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:17 +0000] "GET /hub/admin HTTP/1.1" 302 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [I 2021-12-25 08:58:17.534 JupyterHub log:189] 200 GET /hub/login?next=%2Fhub%2Fadmin (@172.20.0.1) 43.03ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:17 +0000] "GET /hub/login?next=%2Fhub%2Fadmin HTTP/1.1" 200 5473 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:17.563 JupyterHub log:189] 304 GET /hub/static/js/admin-react.js (@172.20.0.1) 1.67ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:17 +0000] "GET /hub/static/js/admin-react.js HTTP/1.1" 304 0 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:24.862 JupyterHub roles:449] Assigning default role to User user
jupyterhub_1  | [D 2021-12-25 08:58:24.875 JupyterHub base:557] Setting cookie jupyterhub-session-id: {'httponly': True, 'path': '/'}
jupyterhub_1  | [D 2021-12-25 08:58:24.876 JupyterHub base:561] Setting cookie for user: jupyterhub-hub-login
jupyterhub_1  | [D 2021-12-25 08:58:24.877 JupyterHub base:557] Setting cookie jupyterhub-hub-login: {'httponly': True, 'path': '/hub/'}
jupyterhub_1  | [I 2021-12-25 08:58:24.878 JupyterHub base:797] User logged in: user
jupyterhub_1  | [I 2021-12-25 08:58:24.884 JupyterHub log:189] 302 POST /hub/login?next=%2Fhub%2Fadmin -> /hub/admin (user@172.20.0.1) 30.40ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:24 +0000] "POST /hub/login?next=%2Fhub%2Fadmin HTTP/1.1" 302 0 "http://127.0.0.1:8080/hub/login?next=%2Fhub%2Fadmin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:24.937 JupyterHub scopes:488] Checking access via scope admin:users
jupyterhub_1  | [D 2021-12-25 08:58:24.937 JupyterHub scopes:386] Unrestricted access to /hub/admin via admin:users
jupyterhub_1  | [D 2021-12-25 08:58:24.937 JupyterHub scopes:488] Checking access via scope admin:servers
jupyterhub_1  | [D 2021-12-25 08:58:24.938 JupyterHub scopes:386] Unrestricted access to /hub/admin via admin:servers
jupyterhub_1  | [D 2021-12-25 08:58:24.954 JupyterHub user:347] Creating <class 'jupyterhub.spawner.LocalProcessSpawner'> for user:
jupyterhub_1  | [I 2021-12-25 08:58:24.963 JupyterHub log:189] 200 GET /hub/admin (user@172.20.0.1) 48.74ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:24 +0000] "GET /hub/admin HTTP/1.1" 200 5162 "http://127.0.0.1:8080/hub/login?next=%2Fhub%2Fadmin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /logo HTTP/1.1" 302 0 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [I 2021-12-25 08:58:25.021 JupyterHub log:189] 302 GET /logo -> /hub/logo (@172.20.0.1) 3.75ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/static/js/admin-react.js HTTP/1.1" 304 0 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:25.062 JupyterHub log:189] 304 GET /hub/static/js/admin-react.js (@172.20.0.1) 2.92ms
jupyterhub_1  | [D 2021-12-25 08:58:25.079 JupyterHub log:189] 200 GET /hub/logo (@172.20.0.1) 3.64ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/logo HTTP/1.1" 200 11453 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:25.142 JupyterHub log:189] 200 GET /hub/static/js/admin.js?v=20211225085815 (@172.20.0.1) 4.37ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/static/js/admin.js?v=20211225085815 HTTP/1.1" 200 8013 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [W 2021-12-25 08:58:25.156 JupyterHub base:89] Blocking Cross Origin API request.  Referer: http://127.0.0.1:8080/hub/admin, Host: 127.0.0.1, Host URL: http://127.0.0.1/hub/
jupyterhub_1  | [D 2021-12-25 08:58:25.165 JupyterHub scopes:488] Checking access via scope list:groups
jupyterhub_1  | [D 2021-12-25 08:58:25.168 JupyterHub scopes:383] No access to /hub/api/groups via list:groups
jupyterhub_1  | [W 2021-12-25 08:58:25.169 JupyterHub scopes:496] Not authorizing access to /hub/api/groups. Requires any of [list:groups], not derived from scopes []
jupyterhub_1  | [W 2021-12-25 08:58:25.177 JupyterHub web:1787] 403 GET /hub/api/groups?offset=0&limit=50 (172.20.0.1): Action is not authorized with current scopes; requires any of [list:groups]
jupyterhub_1  | [W 2021-12-25 08:58:25.184 JupyterHub log:189] 403 GET /hub/api/groups?offset=0&limit=50 (@172.20.0.1) 40.88ms
jupyterhub_1  | [W 2021-12-25 08:58:25.190 JupyterHub base:89] Blocking Cross Origin API request.  Referer: http://127.0.0.1:8080/hub/admin, Host: 127.0.0.1, Host URL: http://127.0.0.1/hub/
jupyterhub_1  | [D 2021-12-25 08:58:25.190 JupyterHub scopes:488] Checking access via scope list:users
jupyterhub_1  | [D 2021-12-25 08:58:25.190 JupyterHub scopes:383] No access to /hub/api/users via list:users
jupyterhub_1  | [W 2021-12-25 08:58:25.191 JupyterHub scopes:496] Not authorizing access to /hub/api/users. Requires any of [list:users], not derived from scopes []
jupyterhub_1  | [W 2021-12-25 08:58:25.191 JupyterHub web:1787] 403 GET /hub/api/users?offset=0&limit=50 (172.20.0.1): Action is not authorized with current scopes; requires any of [list:users]
jupyterhub_1  | [W 2021-12-25 08:58:25.192 JupyterHub log:189] 403 GET /hub/api/users?offset=0&limit=50 (@172.20.0.1) 43.34ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/api/groups?offset=0&limit=50 HTTP/1.1" 403 105 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/api/users?offset=0&limit=50 HTTP/1.1" 403 104 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:25.227 JupyterHub log:189] 200 GET /hub/static/js/jhapi.js?v=20211225085815 (@172.20.0.1) 2.09ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/static/js/jhapi.js?v=20211225085815 HTTP/1.1" 200 4446 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
jupyterhub_1  | [D 2021-12-25 08:58:25.236 JupyterHub log:189] 200 GET /hub/static/components/moment/moment.js?v=20211225085815 (@172.20.0.1) 10.13ms
jupyterhub_1  | [D 2021-12-25 08:58:25.243 JupyterHub log:189] 200 GET /hub/static/js/utils.js?v=20211225085815 (@172.20.0.1) 17.01ms
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/static/components/moment/moment.js?v=20211225085815 HTTP/1.1" 200 173902 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
nginx_1       | 172.20.0.1 - - [25/Dec/2021:08:58:25 +0000] "GET /hub/static/js/utils.js?v=20211225085815 HTTP/1.1" 200 4383 "http://127.0.0.1:8080/hub/admin" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.2 Safari/605.1.15"
```

- Errors in the browser console (Safari 15.2):
```
[Error] Failed to load resource: the server responded with a status of 403 (Forbidden) (users, line 0)
[Error] Failed to load resource: the server responded with a status of 403 (Forbidden) (groups, line 0)
[Error] TypeError: undefined is not a function (near '...u.map...') — admin-react.js:2:28870
	ri (admin-react.js:2:146088)
	(anonymous function) (admin-react.js:2:148187)
	co (admin-react.js:2:112238)
	si (admin-react.js:2:149923)
	Cu (admin-react.js:2:166325)
	Cu
	(anonymous function) (admin-react.js:2:194357)
	xu (admin-react.js:2:163064)
	du (admin-react.js:2:159263)
	du
	(anonymous function) (admin-react.js:2:108745)
	(anonymous function) (admin-react.js:2:194357)
	Va (admin-react.js:2:108691)
	Ha (admin-react.js:2:108626)
	pu (admin-react.js:2:159359)
	notify (admin-react.js:2:919)
	(anonymous function) (admin-react.js:2:494)
	(anonymous function) (admin-react.js:2:569)
	(anonymous function)
	p (admin-react.js:2:52697)
	promiseReactionJob
```
</details>



Discussion:

Patch:
diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py
index 4d63ec99..2ec2dec0 100644
--- a/jupyterhub/apihandlers/auth.py
+++ b/jupyterhub/apihandlers/auth.py
@@ -16,6 +16,7 @@ from tornado import web
 from .. import orm
 from .. import roles
 from .. import scopes
+from ..utils import get_browser_protocol
 from ..utils import token_authenticated
 from .base import APIHandler
 from .base import BaseHandler
@@ -115,7 +116,10 @@ class OAuthHandler:
         # make absolute local redirects full URLs
         # to satisfy oauthlib's absolute URI requirement
         redirect_uri = (
-            self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
+            get_browser_protocol(self.request)
+            + "://"
+            + self.request.host
+            + redirect_uri
         )
         parsed_url = urlparse(uri)
         query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
index b107bf64..6cbb7659 100644
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -14,6 +14,7 @@ from tornado import web
 
 from .. import orm
 from ..handlers import BaseHandler
+from ..utils import get_browser_protocol
 from ..utils import isoformat
 from ..utils import url_path_join
 
@@ -60,6 +61,8 @@ class APIHandler(BaseHandler):
         """
         host_header = self.app.forwarded_host_header or "Host"
         host = self.request.headers.get(host_header)
+        if host and "," in host:
+            host = host.split(",", 1)[0].strip()
         referer = self.request.headers.get("Referer")
 
         # If no header is provided, assume it comes from a script/curl.
@@ -71,7 +74,8 @@ class APIHandler(BaseHandler):
             self.log.warning("Blocking API request with no referer")
             return False
 
-        proto = self.request.protocol
+        proto = get_browser_protocol(self.request)
+
         full_host = f"{proto}://{host}{self.hub.base_url}"
         host_url = urlparse(full_host)
         referer_url = urlparse(referer)
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
index ab37e21f..56cf1fa8 100644
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -49,6 +49,7 @@ from ..spawner import LocalProcessSpawner
 from ..user import User
 from ..utils import AnyTimeoutError
 from ..utils import get_accepted_mimetype
+from ..utils import get_browser_protocol
 from ..utils import maybe_future
 from ..utils import url_path_join
 
@@ -632,12 +633,10 @@ class BaseHandler(RequestHandler):
         next_url = self.get_argument('next', default='')
         # protect against some browsers' buggy handling of backslash as slash
         next_url = next_url.replace('\\', '%5C')
-        if (next_url + '/').startswith(
-            (
-                f'{self.request.protocol}://{self.request.host}/',
-                f'//{self.request.host}/',
-            )
-        ) or (
+        proto = get_browser_protocol(self.request)
+        host = self.request.host
+
+        if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
             self.subdomain_host
             and urlparse(next_url).netloc
             and ("." + urlparse(next_url).netloc).endswith(
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
index a08049a3..9315564d 100644
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -53,6 +53,7 @@ from traitlets import validate
 from traitlets.config import SingletonConfigurable
 
 from ..scopes import _intersect_expanded_scopes
+from ..utils import get_browser_protocol
 from ..utils import url_path_join
 
 
@@ -772,7 +773,7 @@ class HubOAuth(HubAuth):
             # OAuth that doesn't complete shouldn't linger too long.
             'max_age': 600,
         }
-        if handler.request.protocol == 'https':
+        if get_browser_protocol(handler.request) == 'https':
             kwargs['secure'] = True
         # load user cookie overrides
         kwargs.update(self.cookie_options)
@@ -812,7 +813,7 @@ class HubOAuth(HubAuth):
     def set_cookie(self, handler, access_token):
         """Set a cookie recording OAuth result"""
         kwargs = {'path': self.base_url, 'httponly': True}
-        if handler.request.protocol == 'https':
+        if get_browser_protocol(handler.request) == 'https':
             kwargs['secure'] = True
         # load user cookie overrides
         kwargs.update(self.cookie_options)
diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py
index 60464d78..1fc4aa89 100644
--- a/jupyterhub/utils.py
+++ b/jupyterhub/utils.py
@@ -683,3 +683,44 @@ def catch_db_error(f):
             return r
 
     return catching
+
+
+def get_browser_protocol(request):
+    """Get the _protocol_ seen by the browser
+
+    Like tornado's _apply_xheaders,
+    but in the case of multiple proxy hops,
+    use the outermost value (what the browser likely sees)
+    instead of the innermost value,
+    which is the most trustworthy.
+
+    We care about what the browser sees,
+    not where the request actually came from,
+    so trusting possible spoofs is the right thing to do.
+    """
+    headers = request.headers
+    # first choice: Forwarded header
+    forwarded_header = headers.get("Forwarded")
+    if forwarded_header:
+        first_forwarded = forwarded_header.split(",", 1)[0].strip()
+        fields = {}
+        forwarded_dict = {}
+        for field in first_forwarded.split(";"):
+            key, _, value = field.partition("=")
+            fields[key.strip().lower()] = value.strip()
+        if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
+            return fields["proto"].lower()
+        else:
+            app_log.warning(
+                f"Forwarded header present without protocol: {forwarded_header}"
+            )
+
+    # second choice: X-Scheme or X-Forwarded-Proto
+    proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
+    if proto_header:
+        proto_header = proto_header.split(",")[0].strip().lower()
+        if proto_header in {"http", "https"}:
+            return proto_header
+
+    # no forwarded headers
+    return request.protocol


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
index 1b777c74..1397ea63 100644
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -118,16 +118,39 @@ async def test_post_content_type(app, content_type, status):
         ("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
         # explicit ports, match
         ("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
-        # Test proxy defined headers taken into account by xheaders=True in
-        # https://github.com/jupyterhub/jupyterhub/blob/2.0.1/jupyterhub/app.py#L3065
+        # Test proxy protocol defined headers taken into account by utils.get_browser_protocol
         (
             "fake.example",
             {"netloc": "fake.example", "scheme": "https"},
-            # note {"X-Forwarded-Proto": "https"} does not work
             {'X-Scheme': 'https'},
             200,
         ),
+        (
+            "fake.example",
+            {"netloc": "fake.example", "scheme": "https"},
+            {'X-Forwarded-Proto': 'https'},
+            200,
+        ),
+        (
+            "fake.example",
+            {"netloc": "fake.example", "scheme": "https"},
+            {
+                'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
+                'X-Scheme': 'http',
+            },
+            200,
+        ),
+        (
+            "fake.example",
+            {"netloc": "fake.example", "scheme": "https"},
+            {
+                'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
+                'X-Scheme': 'https',
+            },
+            403,
+        ),
         ("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
+        ("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
     ],
 )
 async def test_cors_check(request, app, host, referer, extraheaders, status):
diff --git a/jupyterhub/tests/test_utils.py b/jupyterhub/tests/test_utils.py
index c75c9051..129bc0fa 100644
--- a/jupyterhub/tests/test_utils.py
+++ b/jupyterhub/tests/test_utils.py
@@ -2,12 +2,16 @@
 import asyncio
 import time
 from concurrent.futures import ThreadPoolExecutor
+from unittest.mock import Mock
 
 import pytest
 from async_generator import aclosing
 from tornado import gen
 from tornado.concurrent import run_on_executor
+from tornado.httpserver import HTTPRequest
+from tornado.httputil import HTTPHeaders
 
+from .. import utils
 from ..utils import iterate_until
 
 
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
     # verify that tornado gen and executor methods return awaitables
     assert (await t.on_executor()) == "executor"
     assert (await t.tornado_coroutine()) == "gen.coroutine"
+
+
+@pytest.mark.parametrize(
+    "forwarded, x_scheme, x_forwarded_proto, expected",
+    [
+        ("", "", "", "_attr_"),
+        ("for=1.2.3.4", "", "", "_attr_"),
+        ("for=1.2.3.4,proto=https", "", "", "_attr_"),
+        ("", "https", "http", "https"),
+        ("", "https, http", "", "https"),
+        ("", "https, http", "http", "https"),
+        ("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
+        ("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
+        ("for=1.2.3.4,proto=http", "https, http", "", "https"),
+        ("", "invalid, http", "", "_attr_"),
+    ],
+)
+def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
+    request = Mock(spec=HTTPRequest)
+    request.protocol = "_attr_"
+    request.headers = HTTPHeaders()
+    if x_scheme:
+        request.headers["X-Scheme"] = x_scheme
+    if x_forwarded_proto:
+        request.headers["X-Forwarded-Proto"] = x_forwarded_proto
+    if forwarded:
+        request.headers["Forwarded"] = forwarded
+
+    proto = utils.get_browser_protocol(request)
+    assert proto == expected