Jupyter error 403 Forbidden and '_xsrf' argument missing from POST (tested)
A Jupyter server answers 403 for two different reasons: the request has no valid token (Forbidden), or it is logged in by cookie but lacks the anti-forgery value ('_xsrf' argument missing from POST). Both are reproduced against a real server below, with the requests that fixed them.
1. Reproduce: the two kinds of Jupyter 403
run.py starts a Jupyter server with a known token (abc123, demo only) and calls its REST API the ways scripts and browsers do:
import json, os, subprocess, sys, time, urllib.request, urllib.error, http.cookiejar
# start a Jupyter server with a known token (demo only)
os.makedirs('/tmp/j403/root', exist_ok=True)
env = dict(os.environ, JUPYTER_DATA_DIR='/tmp/j403/data', JUPYTER_RUNTIME_DIR='/tmp/j403/rt', JUPYTER_CONFIG_DIR='/tmp/j403/cfg')
srv = subprocess.Popen([sys.executable, '-m', 'jupyter_server', '--port=8963', '--no-browser',
'--IdentityProvider.token=abc123', '--ServerApp.root_dir=/tmp/j403/root'],
env=env, stdout=subprocess.DEVNULL, stderr=open('/tmp/j403/server.log', 'w'))
B = 'http://127.0.0.1:8963'
for _ in range(100):
try: urllib.request.urlopen(B + '/api', timeout=1); break
except Exception: time.sleep(0.2)
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def call(label, path, method='GET', headers={}, body=None):
req = urllib.request.Request(B + path, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
try:
r = op.open(req, timeout=10); print(f'{label}: {r.status}')
except urllib.error.HTTPError as e:
msg = e.read().decode()[:120].replace('\n', ' ')
print(f'{label}: {e.code} {msg}')
call('1 no token GET /api/contents', '/api/contents')
call('2 wrong token GET /api/contents', '/api/contents', headers={'Authorization': 'token nope'})
call('3 ?token= GET /api/contents', '/api/contents?token=abc123')
call('4 header token POST /api/contents', '/api/contents', 'POST', {'Authorization': 'token abc123'}, {'type': 'notebook'})
# log in like a browser: the token in the URL sets a login cookie
call('5 token in URL GET /api/status?token=', '/api/status?token=abc123')
print(' cookies:', sorted(c.name for c in jar))
call('6 cookie, no _xsrf POST /api/contents', '/api/contents', 'POST', {}, {'type': 'notebook'})
call(' load a page GET /login', '/login')
print(' cookies:', sorted(c.name for c in jar))
xsrf = next((c.value for c in jar if c.name == '_xsrf'), None)
if xsrf:
call('7 cookie + X-XSRFToken POST /api/contents', '/api/contents', 'POST', {'X-XSRFToken': xsrf}, {'type': 'notebook'})
srv.terminate(); srv.wait()
print('--- server log lines with 403:')
for line in open('/tmp/j403/server.log'):
if ' 403 ' in line or 'xsrf' in line.lower(): print(line.rstrip()[:160])
Output (jupyter_server 2.21.1, Python 3.13 on Linux):
1 no token GET /api/contents: 403 {"message": "Forbidden", "reason": null}
2 wrong token GET /api/contents: 403 {"message": "Forbidden", "reason": null}
3 ?token= GET /api/contents: 200
4 header token POST /api/contents: 201
5 token in URL GET /api/status?token=: 200
cookies: ['username-127-0-0-1-8963']
6 cookie, no _xsrf POST /api/contents: 403 {"message": "'_xsrf' argument missing from POST", "reason": null}
load a page GET /login: 200
cookies: ['_xsrf', 'username-127-0-0-1-8963']
7 cookie + X-XSRFToken POST /api/contents: 201
--- server log lines with 403:
[W 2026-10-23 22:22:21.624 ServerApp] 403 GET /api/contents (@127.0.0.1) 0.58ms referer=None
[W 2026-10-23 22:22:21.625 ServerApp] 403 GET /api/contents (@127.0.0.1) 0.45ms referer=None
[W 2026-10-23 22:22:21.692 ServerApp] 403 POST /api/contents (127.0.0.1): '_xsrf' argument missing from POST
[W 2026-10-23 22:22:21.692 ServerApp] wrote error: "'_xsrf' argument missing from POST"
[W 2026-10-23 22:22:21.692 ServerApp] 403 POST /api/contents (b775a7775f134829b517104863dae9c2@127.0.0.1) 0.58ms referer=None
2. "403 Forbidden": no token or the wrong token
Requests 1 and 2 got 403 {"message": "Forbidden"}, and the server log shows 403 GET /api/contents (@127.0.0.1) with an empty user before the @: the request was not logged in at all. Fixes:
- Send the token:
Authorization: token <TOKEN>header (request 4) or?token=<TOKEN>in the URL (request 3). - Get the right token with
jupyter server list(orjupyter notebook list/jupyter lab list); each server start generates a new one unless you set it. A token from an older run gives the same 403. - In VS Code or another client connecting to a remote server, paste the full URL including
?token=....
3. "'_xsrf' argument missing from POST": logged in by cookie
After the token was used in the URL, the server set a login cookie (username-127-0-0-1-8963). A POST using only that cookie (request 6) was refused with 403 '_xsrf' argument missing from POST: cookie-authenticated requests must also prove they came from a Jupyter page. Loading a page (GET /login) set an _xsrf cookie, and echoing it back in an X-XSRFToken header (request 7) gave 201. Requests that authenticate with the Authorization: token header (request 4) are not checked for _xsrf.
- Scripts: use the token header instead of cookies.
- Browsers: this usually means a stale tab or blocked cookies. Reload the page (or log in again) so a fresh
_xsrfcookie is set, and check that a proxy or browser extension is not stripping cookies or headers.
Related: Jupyter kernel not found, Jupyter kernel keeps dying.
Frequently asked questions
Why does Jupyter return 403 Forbidden?
The request has no token or a wrong one (often from an older server run). Send Authorization: token TOKEN or ?token=TOKEN; jupyter server list shows the current token.
How do I fix "_xsrf argument missing from POST" in Jupyter?
Cookie-authenticated POSTs need the _xsrf cookie echoed in an X-XSRFToken header. In a browser, reload or log in again; in scripts, authenticate with the token header instead.
Does the Jupyter token header need an XSRF token?
No. In the test, a POST with Authorization: token got 201 without any _xsrf value.
Written with AI assistance. Measured on 2026-10-23 with jupyter_server 2.21.1 and Python 3.13 on Linux. The script ran verbatim and the output was pasted unedited. PlainTables is our extension; Jupyter is free.