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:

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.

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.