dashboard/frontend/app.js:3910 iterates the CSRF cookie names dev-first:
for (const name of ['atl_csrf', '__Host-atl_csrf']) {
dashboard/backend/csrf.py:79 reads them host-first, unconditionally:
for name in (_HOST_CSRF, _DEV_CSRF):
Double-submit only compares equal when both sides pick the same cookie, so wherever the jar holds both with different values the frontend submits one token and the backend validates against the other — every write 403s until the user clears cookies.
When both coexist. The backend sets only one (csrf_cookie_name(), :52, on cookie_secure()), so this is not a steady state — it is a transition. A browser holding atl_csrf from before ATL_COOKIE_SECURE/RENDER became truthy keeps it (the __Host- prefix forbids Domain, so the new cookie cannot overwrite the old name), and the server then issues __Host-atl_csrf beside it. :74 clears both, so a logout/login cycle resolves it — which is also why this can look intermittent and user-specific.
Fix: swap the array to ['__Host-atl_csrf', 'atl_csrf'] to match csrf.py:79.
Introduced by #285. The same defect was copied into dashboard/frontend/js/admin-shell.js and fixed there in #488, which deliberately leaves this call site alone — the two are independent copies, and this one is not that PR's to change.
A regression test needs to set both cookies to different values; the existing CSRF tests set one at a time and are structurally blind to ordering.
🤖 Filed with Claude Code
dashboard/frontend/app.js:3910iterates the CSRF cookie names dev-first:dashboard/backend/csrf.py:79reads them host-first, unconditionally:Double-submit only compares equal when both sides pick the same cookie, so wherever the jar holds both with different values the frontend submits one token and the backend validates against the other — every write 403s until the user clears cookies.
When both coexist. The backend sets only one (
csrf_cookie_name(),:52, oncookie_secure()), so this is not a steady state — it is a transition. A browser holdingatl_csrffrom beforeATL_COOKIE_SECURE/RENDERbecame truthy keeps it (the__Host-prefix forbidsDomain, so the new cookie cannot overwrite the old name), and the server then issues__Host-atl_csrfbeside it.:74clears both, so a logout/login cycle resolves it — which is also why this can look intermittent and user-specific.Fix: swap the array to
['__Host-atl_csrf', 'atl_csrf']to matchcsrf.py:79.Introduced by #285. The same defect was copied into
dashboard/frontend/js/admin-shell.jsand fixed there in #488, which deliberately leaves this call site alone — the two are independent copies, and this one is not that PR's to change.A regression test needs to set both cookies to different values; the existing CSRF tests set one at a time and are structurally blind to ordering.
🤖 Filed with Claude Code