Repository: jupyterhub/jupyterhub

Issue:
Auth expiration problems with JupyterLab 3.x
<!-- 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. -->
After upgrading to JupyterLab 3.0, everything works fine until the auth token expires. Then, the UI fails to refresh the auth token due to CORS, and as a side effect it continues setting more and more cookies until its requests start getting 400 Bad Request because of the large Cookie header.

#### Expected behaviour
<!-- Tell us what you thought would happen. -->
JupyterLab continues working as long as the tab is open

#### Actual behaviour
<!-- Tell us what actually happens. -->
A popup appears:
```
Server Connection Error
A connection to the Jupyter server could not be established. JupyterLab will continue trying to reconnect. Check your network connection or Jupyter server configuration.
```
In Chrome's Console, you can see that it's failing due to CORS:

```
lab:1 Access to fetch at 'https://jupyter.mit.edu/hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=REMOVED' (redirected from 'https://quentin.jupyter.mit.edu/user/quentin/api/contents/REMOVED?content=1&1610401550583') from origin 'https://quentin.jupyter.mit.edu' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
serverconnection.js:207 GET https://jupyter.mit.edu/hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=REMOVED net::ERR_FAILED
```

It looks like JupyterLab is trying to fetch /user/$user/api/contents, the auth is expired so it's redirected from $user.jupyter/user to jupyter/hub for auth, then after N cycles of that,

```
serverconnection.js:207 GET https://quentin.jupyter.mit.edu/user/quentin/api/contents/REMOVED?content=1&1610401672210 400 (Bad Request)
```

and looking at my cookies I now have cookies named `jupyterhub-user-quentin-oauth-state-FycHkGir` (for 15 different random strings, as well as one with no random suffix). Deleting those cookies and refreshing the page loads JupyterLab correctly.

### How to reproduce
<!-- Use this section to describe the steps that a user would take to experience this bug. -->

1. Install the latest JupyterHub and JupyterLab
2. (Probably) configure JupyterHub to use subdomains
3. Open JupyterLab and leave the tab open.

### 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.
 -->
JupyterHub and JupyterLab are installed directly from PIP in a VirtualEnv. All the config files are available at: https://github.com/sipb/jupyter/tree/master/ansible/roles/jupyter-jupyter/files, including the JupyterHub config file itself.
 - OS:
 <!-- [e.g. ubuntu 20.04, macOS 11.0] -->
Ubuntu 20.04
 - Version(s):
 <!-- e.g. jupyterhub --version, python --version --->
```
$ jupyterhub --version
1.3.0
$ python --version
Python 3.8.5
$ jupyter lab --version
3.0.3
```

<details><summary>Full environment</summary>

```
AccessControl==5.0
acme==1.1.0
Acquisition==4.7
aggdraw==1.3.12
aiosqlite==0.16.0
alabaster==0.7.8
alembic==1.4.3
ansible==2.9.6
anyio==2.0.2
apache-libcloud==2.8.0
appdirs==1.4.3
argcomplete==1.8.1
argh==0.26.2
argon2-cffi==20.1.0
asn1crypto==1.4.0
astroid==2.4.2
astroplan==0.7
astropy==4.2
astrowidgets==0.1.1
async-generator==1.10
asyncpg==0.21.0
atomicwrites==1.4.0
attrs==20.3.0
AuthEncoding==4.2
Authlib==0.15.2
Automat==0.8.0
autopep8==1.5.4
Babel==2.9.0
backcall==0.2.0
beautifulsoup4==4.9.3
biopython==1.78
bitarray==1.6.1
bkcharts==0.2
bleach==3.2.1
blinker==1.4
blosc==1.10.1
bokeh==2.2.3
boto==2.49.0
boto3==1.9.253
botocore==1.16.19
Bottleneck==1.3.2
brotlipy==0.7.0
BTrees==4.7.2
CacheControl==0.12.6
Cartopy==0.18.0
certbot==0.40.0
certbot-apache==0.39.0
certbot-dns-route53==0.35.1
certifi==2019.11.28
certipy==0.1.3
cffi==1.14.4
cftime==1.2.1
Chameleon==3.8.1
chardet==4.0.0
click==7.1.2
clingkernel==0.0.2
cloud-init==20.2
cloudpickle==1.6.0
clyent==1.2.1
colorama==0.4.4
command-not-found==0.3
ConfigArgParse==0.13.0
configobj==5.0.6
constantly==15.1.0
contextlib2==0.6.0.post1
cryptography==3.3.1
cycler==0.10.0
cypari==2.4.0
Cython==0.29.21
cytoolz==0.11.0
dask==2020.12.0
databases==0.3.2
DateTime==4.3
dblatex===0.3.11py3
dbus-python==1.2.16
decorator==4.4.2
defusedxml==0.6.0
devscripts===2.20.2ubuntu2
diff-match-patch==20200713
distlib==0.3.0
distro==1.4.0
distro-info===0.23ubuntu1
dnspython==1.16.0
DocumentTemplate==4.0
docutils==0.16
ec2-hibinit-agent==1.0.0
entrypoints==0.3
et-xmlfile==1.0.1
ExtensionClass==4.5.0
fastapi==0.61.2
fastcache==1.1.0
filelock==3.0.12
flake8==3.8.4
Flask==1.1.2
fsspec==0.8.5
future==0.18.2
FXrays==1.3.3
gbp==0.9.19
GDAL==3.0.4
geomag-algorithms==1.3.5
gevent==20.12.1
ginga==3.1.0
gitdb==4.0.5
GitPython==3.1.11
glob2==0.7
gmpy2==2.1.0b3
gpg===1.13.1-unknown
greenlet==0.4.17
gssapi==1.6.12
gunicorn==20.0.4
h11==0.9.0
h2==3.2.0
h5py==3.1.0
hdmedians==0.14.1
HeapDict==1.0.1
hibagent==1.0.1
hpack==3.0.0
hstspreload==2020.10.20
html5lib==1.1
httplib2==0.14.0
httptools==0.1.1
httpx==0.11.1
hyperframe==5.2.0
hyperlink==19.0.0
icu==0.0.1
idna==3.1
imageio==2.9.0
imagesize==1.2.0
importlib-metadata==3.4.0
incremental==16.10.1
iniconfig==1.1.1
intel-openmp==2021.1.2
intervaltree==3.1.0
ipaddr==2.2.0
ipyevents==0.8.1
ipykernel==5.3.4
ipympl==0.6.2
ipython==7.19.0
ipython-genutils==0.2.0
ipywidgets==7.6.3
isort==5.7.0
itsdangerous==1.1.0
jdcal==1.4.1
jedi==0.17.2
jeepney==0.5.0
Jinja2==2.11.2
jmespath==0.9.4
joblib==1.0.0
josepy==1.2.0
json5==0.9.5
jsonpatch==1.22
jsonpointer==2.0
jsonschema==3.2.0
jupyter-archive==3.0.0
jupyter-c-kernel==1.2.2
jupyter-client==6.1.7
jupyter-core==4.6.3
jupyter-lsp==1.0.0
jupyter-server==1.2.1
jupyter-telemetry==0.1.0
jupyterhub==1.3.0
jupyterhub-systemdspawner==0.15.0
jupyterlab==3.0.3
jupyterlab-apod==0.1.0
jupyterlab-git==0.23.3
jupyterlab-hdf==0.5.1
jupyterlab-kernelspy==3.0.1
jupyterlab-latex==2.0.0
jupyterlab-lsp==3.0.0
jupyterlab-pygments==0.1.2
jupyterlab-python-file==0.5.2
jupyterlab-quickopen==1.0.0
jupyterlab-server==2.1.1
jupyterlab-widgets==1.0.0
keyring==21.8.0
kiwisolver==1.3.1
language-selector==0.1
launchpadlib==1.10.13
lazr.restfulclient==0.14.2
lazr.uri==1.0.3
lazy-object-proxy==1.5.2
libarchive-c==2.9
lief==0.10.1
llvmlite==0.35.0
locket==0.2.0
lockfile==0.12.2
lxml==4.6.2
lz4==3.1.1
Mako==1.1.3
MarkupSafe==1.1.1
matplotlib==3.3.3
mccabe==0.6.1
metakernel==0.27.5
mistune==0.8.4
mkl==2021.1.1
mock==4.0.3
more-itertools==8.6.0
mpmath==1.1.0
mprofile==0.0.10
msgpack==1.0.2
MultiMapping==4.1
multipledispatch==0.6.0
natsort==7.0.1
nbclassic==0.2.6
nbclient==0.5.1
nbconvert==6.0.7
nbdime==2.1.0
nbformat==5.0.8
nest-asyncio==1.4.3
netaddr==0.7.19
netCDF4==1.5.4
netifaces==0.10.4
networkx==2.5
nltk==3.5
nose==1.3.7
notebook==6.1.6
ntlm-auth==1.1.0
numba==0.52.0
numexpr==2.7.2
numpy==1.19.5
oauthenticator==0.12.3
oauthlib==3.1.0
obspy==1.2.2
octave-kernel==0.32.0
olefile==0.46
openpyxl==3.0.5
packaging==20.8
pamela==1.0.0
pandas==1.2.0
pandoc==1.0.2
pandocfilters==1.4.3
pari-jupyter==1.3.2
parsedatetime==2.4
parso==0.7.1
partd==1.1.0
PasteDeploy==2.1.1
path==15.0.1
pathlib2==2.3.5
pathtools==0.1.2
patsy==0.5.1
pbr==5.4.5
pep517==0.8.2
pep8==1.7.1
Persistence==3.0
persistent==4.6.4
pexpect==4.8.0
pickleshare==0.7.5
Pillow==8.1.0
pkginfo==1.6.1
plink==2.3.1
pluggy==0.13.1
ply==3.11
progress==1.5
prometheus-client==0.9.0
prompt-toolkit==3.0.10
protobuf==3.6.1
psutil==5.8.0
psycopg2-binary==2.8.6
ptyprocess==0.7.0
py==1.10.0
PyAFS==0.2.3
pyasn1==0.4.2
pyasn1-modules==0.2.1
pycodestyle==2.6.0
pycosat==0.6.3
pycparser==2.20
pycrypto==2.6.1
pycurl==7.43.0.6
pydantic==1.7.2
pydocstyle==5.1.1
pyerfa==1.7.1.1
pyflakes==2.2.0
Pygments==2.7.3
PyGObject==3.36.0
PyHamcrest==1.9.0
PyHesiod==0.2.13
PyICU==2.4.2
PyJWT==1.7.1
pykerberos==1.1.14
pylint==2.6.0
pymacaroons==0.13.0
PyNaCl==1.3.0
pyodbc==4.0.30
pyOpenSSL==20.0.1
pyparsing==2.4.7
pypng==0.0.20
pypprof==0.0.1
pyproj==3.0.0.post1
pyRFC3339==1.1
pyrsistent==0.17.3
pyserial==3.4
pyshp==2.1.2
PySocks==1.7.1
pytest==6.2.1
python-apt==2.0.0+ubuntu0.20.4.1
python-augeas==0.5.0
python-dateutil==2.8.1
python-debian===0.1.36ubuntu1
python-dotenv==0.15.0
python-editor==1.0.4
python-gettext==4.0
python-json-logger==2.0.1
python-jsonrpc-server==0.4.0
python-language-server==0.36.2
python-magic==0.4.16
pytoml==0.1.21
pytz==2020.5
PyWavelets==1.1.1
pywinrm==0.3.0
pyxdg==0.27
PyYAML==5.3.1
pyzmq==20.0.0
QtPy==1.9.0
regex==2020.11.13
requests==2.25.1
requests-kerberos==0.12.0
requests-ntlm==1.1.0
requests-toolbelt==0.8.0
requests-unixsocket==0.2.0
RestrictedPython==5.1
retrying==1.3.3
rfc3986==1.4.0
roman==2.0.0
rope==0.18.0
ruamel.yaml==0.16.12
ruamel.yaml.clib==0.2.2
s3transfer==0.3.3
scikit-bio==0.5.6
scikit-dataaccess==1.9.17.post2
scikit-image==0.18.1
scikit-learn==0.24.0
scikit-rf==0.15.5
scipy==1.6.0
seaborn==0.11.1
SecretStorage==3.3.0
selinux==3.0
Send2Trash==1.5.0
service-identity==18.1.0
Shapely==1.7.1
simplegeneric==0.8.1
simplejson==3.16.0
singledispatch==3.4.0.3
sip==6.0.0
sipb-jupyter==1.0
six==1.15.0
smmap==3.0.4
snappy==2.8
snappy-manifolds==1.1.2
sniffio==1.2.0
snowballstemmer==2.0.0
sortedcollections==1.2.3
sortedcontainers==2.3.0
soupsieve==2.1
spherogram==1.8.3
Sphinx==1.8.5
sphinx-rtd-theme==0.4.3
SQLAlchemy==1.3.22
SQLAlchemy-Utc==0.11.0
ssh-import-id==5.10
starlette==0.13.6
statsmodels==0.12.1
sympy==1.7.1
systemd-python==234
tables==3.6.1
tbb==2021.1.1
tblib==1.7.0
terminado==0.9.2
testpath==0.4.4
threadpoolctl==2.1.0
tifffile==2020.10.1
toml==0.10.2
toolz==0.11.1
tornado==6.1
tqdm==4.56.0
traitlets==5.0.5
transaction==3.0.0
Twisted==18.9.0
typing==3.7.4.3
typing-extensions==3.7.4.3
ubuntu-advantage-tools==20.3
ufw==0.36
ujson==4.0.1
unattended-upgrades==0.1
unicodecsv==0.14.1
unidiff==0.5.5
urllib3==1.26.2
uvicorn==0.12.2
uvloop==0.14.0
varlink==30.3.1
virtualenv==20.0.17
wadllib==1.3.3
waitress==1.4.4
watchdog==1.0.2
watchgod==0.6
wcwidth==0.2.5
webencodings==0.5.1
WebOb==1.8.6
websockets==8.1
WebTest==2.0.35
Werkzeug==1.0.1
widgetsnbextension==3.5.1
wrapt==1.12.1
WSGIProxy2==0.4.6
wurlitzer==2.0.1
xarray==0.16.1
xlrd==2.0.1
XlsxWriter==1.3.7
xlwt==1.3.0
xmltodict==0.12.0
yapf==0.30.0
z3c.pt==3.3.0
zc.lockfile==2.0
ZConfig==3.5.0
zExceptions==4.1
zict==2.0.0
zipp==3.4.0
ZODB==5.6.0
zodbpickle==2.0.0
Zope==5.1
zope.annotation==4.7.0
zope.browser==2.3
zope.browsermenu==4.4
zope.browserpage==4.4.0
zope.browserresource==4.4
zope.cachedescriptors==4.3.1
zope.component==4.3.0
zope.configuration==4.4.0
zope.container==4.4.0
zope.contentprovider==4.2.1
zope.contenttype==4.5.0
zope.deferredimport==4.3.1
zope.deprecation==4.4.0
zope.dottedname==4.3
zope.event==4.5.0
zope.exceptions==4.4
zope.filerepresentation==5.0.0
zope.globalrequest==1.5
zope.hookable==5.0.0
zope.i18n==4.7.0
zope.i18nmessageid==5.0.1
zope.interface==5.2.0
zope.lifecycleevent==4.3
zope.location==4.2
zope.pagetemplate==4.5.0
zope.processlifetime==2.3.0
zope.proxy==4.3.5
zope.ptresource==4.2.0
zope.publisher==5.2.1
zope.schema==6.0.0
zope.security==5.1.1
zope.sequencesort==4.1.2
zope.site==4.4.0
zope.size==4.3
zope.structuredtext==4.3
zope.tal==4.4
zope.tales==5.1
zope.testbrowser==5.5.1
zope.testing==4.7
zope.traversing==4.4.1
zope.viewlet==4.2.1
zprofile==1.0.11
zstd==1.4.8.1
```
</details>

<details><summary>Configuration</summary>
<!--
For JupyterHub, especially include information such as what Spawner and Authenticator are being used.
Be careful not to share any sensitive information.
You can paste jupyterhub_config.py below.
To exclude lots of comments and empty lines from auto-generated jupyterhub_config.py, you can do:
    grep -v '\(^#\|^[[:space:]]*$\)' jupyterhub_config.py
-->

Visible at https://github.com/sipb/jupyter/blob/master/ansible/roles/jupyter-jupyter/files/jupyterhub_config.py
</details>

<details><summary>Logs</summary>


```
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [W 2021-01-11 21:46:09.421 SingleUserNotebookApp auth:551] Token stored in cookie may have expired
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [D 2021-01-11 21:46:09.421 SingleUserNotebookApp auth:504] No user identified
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [W 2021-01-11 21:46:09.421 SingleUserNotebookApp auth:682] Detected unused OAuth state cookies
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [D 2021-01-11 21:46:09.421 SingleUserNotebookApp auth:835] Redirecting to login url: https://jupyter.mit.edu/hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2FquentinREMOVED
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [I 2021-01-11 21:46:09.422 SingleUserNotebookApp log:181] 302 GET /user/quentin/api/terminals?1610401569386 -> https://jupyter.mit.edu/hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.eduREMOVED
```
```
Jan 11 21:46:00 jupyter.mit.edu jupyterhub[448805]: [W 2021-01-11 21:46:00.731 JupyterHub log:181] 405 OPTIONS /hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=[secret] >
Jan 11 21:46:09 jupyter.mit.edu jupyterhub[448805]: [W 2021-01-11 21:46:09.477 JupyterHub log:181] 405 OPTIONS /hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=[secret] >
Jan 11 21:46:10 jupyter.mit.edu jupyterhub[448805]: [W 2021-01-11 21:46:10.801 JupyterHub log:181] 405 OPTIONS /hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=[secret] >
Jan 11 21:46:18 jupyter.mit.edu jupyterhub[448805]: [W 2021-01-11 21:46:18.826 JupyterHub log:181] 405 OPTIONS /hub/api/oauth2/authorize?client_id=jupyterhub-user-quentin&redirect_uri=https%3A%2F%2Fquentin.jupyter.mit.edu%2Fuser%2Fquentin%2Foauth_callback&response_type=code&state=[secret] >
```
</details>



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

Is there anything I can do to help move this bug forward? This is a major usability regression for us, since it's hard for users to figure out how to clear their cookies.
Can you check whether it also occurs with jupyter notebook?
Jupyter Notebook appears to not have any similar auth refresh logic; it appears to just have one long-running WebSocket. So I think "it doesn't occur but that's not a useful test"?
I'm still catching up on holiday backlogs, but will focus on this one this week.

To be clear, it was updating to jupyterlab 3.0 that caused this change? It may be that something changed in jupyterlab's error handling that it no longer prompts a fresh login, or the switch from notebook to jupyter_server. If refreshing the page fixes the issue, it is likely this needs to be brought up with jupyterlab or jupyter_server instead of jupyterhub.

oauth state cookies should expire after a maximum of 10 minutes. They are also cleared on successful login, so I don't think this flooding state cookie issue is an important one when ~anything is working properly. It should also go away if you wait a few minutes and come back.

I think this log line indicates the key bug:

```
Jan 11 21:46:09 jupyter.mit.edu bash[452754]: [I 2021-01-11 21:46:09.422 SingleUserNotebookApp log:181] 302 GET /user/quentin/api/terminals?1610401569386 -> ...
```

API handlers should never redirect on authentication failure. They should always 403. This is the root of the many login attempts. API requests cannot trigger a new login, so the redirects are spurious and never going to work. We just need to track down which code is responsible for these redirects. I suspect it's in jupyterhub's singleuser-wrapping code hooking up jupyterhub auth taking priority over APIHandler's force of 403 here.

In case it's relevant there's a report of SingleUserNotebookApp with JupyterLAb 3.0.5 giving a 404 in https://github.com/jupyterhub/zero-to-jupyterhub-k8s/issues/2015

Resolving patch:
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -19,6 +19,7 @@
 import time
 import uuid
 import warnings
+from unittest import mock
 from urllib.parse import quote
 from urllib.parse import urlencode
 
@@ -832,8 +833,12 @@ def get_login_url(self):
             # add state argument to OAuth url
             state = self.hub_auth.set_state_cookie(self, next_url=self.request.uri)
             login_url = url_concat(login_url, {'state': state})
-        app_log.debug("Redirecting to login url: %s", login_url)
-        return login_url
+        # temporary override at setting level,
+        # to allow any subclass overrides of get_login_url to preserve their effect
+        # for example, APIHandler raises 403 to prevent redirects
+        with mock.patch.dict(self.application.settings, {"login_url": login_url}):
+            app_log.debug("Redirecting to login url: %s", login_url)
+            return super().get_login_url()
 
     def check_hub_user(self, model):
         """Check whether Hub-authenticated user or service should be allowed.
diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py
--- a/jupyterhub/singleuser/mixins.py
+++ b/jupyterhub/singleuser/mixins.py
@@ -9,11 +9,10 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 import asyncio
-import importlib
 import json
 import os
 import random
-from datetime import datetime
+import warnings
 from datetime import timezone
 from textwrap import dedent
 from urllib.parse import urlparse
@@ -23,7 +22,6 @@
 from tornado import ioloop
 from tornado.httpclient import AsyncHTTPClient
 from tornado.httpclient import HTTPRequest
-from tornado.web import HTTPError
 from tornado.web import RequestHandler
 from traitlets import Any
 from traitlets import Bool
@@ -94,9 +92,19 @@ def is_token_authenticated(handler):
 
     @staticmethod
     def get_user(handler):
-        """alternative get_current_user to query the Hub"""
-        # patch in HubAuthenticated class for querying the Hub for cookie authentication
-        if HubAuthenticatedHandler not in handler.__class__.__bases__:
+        """alternative get_current_user to query the Hub
+
+        Thus shouldn't be called anymore because HubAuthenticatedHandler
+        should have already overridden get_current_user().
+
+        Keep here to prevent unlikely circumstance from losing auth.
+        """
+        if HubAuthenticatedHandler not in handler.__class__.mro():
+            warnings.warn(
+                f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro()",
+                RuntimeWarning,
+                stacklevel=2,
+            )
             handler.__class__ = type(
                 handler.__class__.__name__,
                 (HubAuthenticatedHandler, handler.__class__),
@@ -691,6 +699,7 @@ def make_singleuser_app(App):
     """
 
     empty_parent_app = App()
+    log = empty_parent_app.log
 
     # detect base classes
     LoginHandler = empty_parent_app.login_handler_class
@@ -707,6 +716,26 @@ def make_singleuser_app(App):
                 "{}.base_handler_class must be defined".format(App.__name__)
             )
 
+    # patch-in HubAuthenticatedHandler to BaseHandler,
+    # so anything inheriting from BaseHandler uses Hub authentication
+    if HubAuthenticatedHandler not in BaseHandler.__bases__:
+        new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__
+        log.debug(
+            f"Patching {BaseHandler}{BaseHandler.__bases__} -> {BaseHandler}{new_bases}"
+        )
+        BaseHandler.__bases__ = new_bases
+        # We've now inserted our class as a parent of BaseHandler,
+        # but we also need to ensure BaseHandler *itself* doesn't
+        # override the public tornado API methods we have inserted.
+        # If they are defined in BaseHandler, explicitly replace them with our methods.
+        for name in ("get_current_user", "get_login_url"):
+            if name in BaseHandler.__dict__:
+                log.debug(
+                    f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"
+                )
+                method = getattr(HubAuthenticatedHandler, name)
+                setattr(BaseHandler, name, method)
+
     # create Handler classes from mixins + bases
     class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
         pass


Test patch:
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -404,9 +404,10 @@ def _default_url(self):
         Should be:
 
         - authenticated, so we are testing auth
-        - always available (i.e. in base ServerApp and NotebookApp
+        - always available (i.e. in mocked ServerApp and NotebookApp)
+        - *not* an API handler that raises 403 instead of redirecting
         """
-        return "/api/status"
+        return "/tree"
 
     _thread = None
 
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -21,6 +21,7 @@ async def test_singleuser_auth(app):
     user = app.users['nandy']
     if not user.running:
         await user.spawn()
+        await app.proxy.add_user(user)
     url = public_url(app, user)
 
     # no cookies, redirects to login page
@@ -28,6 +29,11 @@ async def test_singleuser_auth(app):
     r.raise_for_status()
     assert '/hub/login' in r.url
 
+    # unauthenticated /api/ should 403, not redirect
+    api_url = url_path_join(url, "api/status")
+    r = await async_requests.get(api_url, allow_redirects=False)
+    assert r.status_code == 403
+
     # with cookies, login successful
     r = await async_requests.get(url, cookies=cookies)
     r.raise_for_status()