Skip to content

fix(auth): persist csrf_token cookie for the access_token lifetime - #3872

Merged
WillemJiang merged 1 commit into
bytedance:mainfrom
ajayr:fix/csrf-cookie-persistent-lifetime
Jul 2, 2026
Merged

WillemJiang merged 1 commit into
bytedance:mainfrom
ajayr:fix/csrf-cookie-persistent-lifetime

Conversation

@ajayr

@ajayr ajayr commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Problem

The csrf_token double-submit cookie is set as a session cookie (no max_age), while the access_token cookie is persistent over HTTPS (max_age = token_expiry_days, see _set_session_cookie). The two cookies represent one session but had different lifetimes.

This breaks iOS Safari home-screen PWAs (sites added to the Home Screen). When iOS terminates the standalone web app (background memory pressure, reboot, idle), it evicts session cookies but keeps persistent ones. On reopen:

  • access_token (persistent) survives → GET /api/v1/auth/me is CSRF-exempt → the user still appears logged in.
  • csrf_token (session-only) is gone → the frontend's readCsrfCookie() returns null and sends no X-CSRF-Token header.
  • The first state-changing request (e.g. sending a chat message) fails:
    HTTP 403: {"detail":"CSRF token missing. Include X-CSRF-Token header."}
    
  • Only logging out and back in (which re-mints the cookie) restores it — until the next eviction.

Only manifests over HTTPS, which is why plain-HTTP local dev never reproduces it.

Fix

Give csrf_token the same max_age as access_token at both places it is minted:

  • CSRFMiddleware — auth POST responses (csrf_middleware.py)
  • _set_csrf_cookie — OIDC GET callback (routers/auth.py)

Both mirror _set_session_cookie's ... if is_https else None guard, so the double-submit pair always shares a lifetime: persistent together over HTTPS, session-only together over plain HTTP. CSRF-token lifetime is not a security boundary (double-submit security comes from same-origin reads), so persisting it is safe.

Tests

Added to backend/tests/test_auth_type_system.py:

  • test_csrf_cookie_persistent_on_https — HTTPS register sets csrf_token with max_age, paired with the persistent access_token.
  • test_csrf_cookie_session_only_on_http — plain HTTP keeps it a session cookie (dev behavior unchanged).
  • test_oidc_callback_csrf_cookie_persistent_on_https — the OIDC-callback helper is persistent over HTTPS too.

🤖 Generated with Claude Code

The csrf_token double-submit cookie is set without max_age (a session
cookie), while the access_token cookie is persistent over HTTPS
(max_age = token_expiry_days, see _set_session_cookie). The two cookies
represent one session but had different lifetimes.

iOS Safari home-screen PWAs evict session cookies when iOS terminates the
standalone web app, but keep persistent ones. On reopen the user still
appears logged in (the persistent access_token survives and GET
/api/v1/auth/me is CSRF-exempt), but the session-only csrf_token is gone,
so the frontend's readCsrfCookie() returns null and sends no X-CSRF-Token
header. The first state-changing request then fails with 403 "CSRF token
missing. Include X-CSRF-Token header." Only re-login restored it. This
only manifests over HTTPS, which is why plain-HTTP local dev never sees it.

Give csrf_token the same max_age as access_token at both mint sites --
CSRFMiddleware (auth POSTs) and _set_csrf_cookie (OIDC GET callback) --
mirroring _set_session_cookie's `... if is_https else None` guard so the
double-submit pair always shares a lifetime: persistent together over
HTTPS, session-only together over plain HTTP.

Regression tests in tests/test_auth_type_system.py:
- test_csrf_cookie_persistent_on_https
- test_csrf_cookie_session_only_on_http
- test_oidc_callback_csrf_cookie_persistent_on_https

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/S PR changes 20-100 lines labels Jun 29, 2026
@WillemJiang WillemJiang added this to the 2.1.0 milestone Jul 2, 2026
@WillemJiang
WillemJiang merged commit 70d53da into bytedance:main Jul 2, 2026
11 checks passed
jtaynl added a commit to jtaynl/deer-flow that referenced this pull request Jul 3, 2026
Conflict (1): community/aio_sandbox/aio_sandbox.py — resolved TAKE-UPSTREAM.
Upstream bytedance#3871 reworked execute_command to (self, command, env=None, timeout=None),
which SUPERSEDES our local timeout= shim (added 2026-06-30 for bytedance#3864) AND adds the
new env= for request-scoped skill secrets. Dropping our shim — our fork divergence
shrinks by one patch. Normal bash path (env=None) is byte-identical (legacy
shell.exec_command); the timeout= call from tools.py no longer TypeErrors.

The other 12 auto-merged clean. Notable: bytedance#3904 unified DB config (no-op for us —
our checkpointer.type=postgres section takes precedence), bytedance#3903 recursion clamp
(ceiling 1000 = our value → no-op), bytedance#3872 csrf cookie lifetime fix (positive),
#4fcb subagent step history (persists to existing run_events table, no migration),
bytedance#3871 request-scoped secrets. No new alembic migration → head stays 0002.
i18n auto-merged, WRI branding preserved. Login/setup pages untouched.
yogyoho added a commit to yogyoho/eai-flow-main that referenced this pull request Jul 3, 2026
…n 生命周期

70d53da csrf_middleware import get_auth_config+is_auth_disabled; auth.py OIDC段取eai版(skip bytedance#3506)
(c002596 todo counter 清理已前置commit)
ajayr added a commit to ajayr/deer-flow that referenced this pull request Jul 15, 2026
Conflict resolutions:
- docker/docker-compose.yaml: kept local dir-mount hot-reload hardening
  (103f179) for config.yaml/extensions_config.json; adopted upstream's
  new Redis stream-bridge service, depends_on, and env wiring.
- backend/.../community/crawl4ai/* + backend/tests/test_crawl4ai_tools.py:
  took upstream (bytedance#3821/bytedance#3942) — our provider plus SSRF guard, timeout
  coercion, and expanded tests. Module is now upstream-tracked.
- csrf_middleware.py/auth.py auto-merged: our bytedance#3872 fix landed upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marvin9551 pushed a commit to marvin9551/deer-flow that referenced this pull request Aug 21, 2026
…ytedance#3872)

The csrf_token double-submit cookie is set without max_age (a session
cookie), while the access_token cookie is persistent over HTTPS
(max_age = token_expiry_days, see _set_session_cookie). The two cookies
represent one session but had different lifetimes.

iOS Safari home-screen PWAs evict session cookies when iOS terminates the
standalone web app, but keep persistent ones. On reopen the user still
appears logged in (the persistent access_token survives and GET
/api/v1/auth/me is CSRF-exempt), but the session-only csrf_token is gone,
so the frontend's readCsrfCookie() returns null and sends no X-CSRF-Token
header. The first state-changing request then fails with 403 "CSRF token
missing. Include X-CSRF-Token header." Only re-login restored it. This
only manifests over HTTPS, which is why plain-HTTP local dev never sees it.

Give csrf_token the same max_age as access_token at both mint sites --
CSRFMiddleware (auth POSTs) and _set_csrf_cookie (OIDC GET callback) --
mirroring _set_session_cookie's `... if is_https else None` guard so the
double-submit pair always shares a lifetime: persistent together over
HTTPS, session-only together over plain HTTP.

Regression tests in tests/test_auth_type_system.py:
- test_csrf_cookie_persistent_on_https
- test_csrf_cookie_session_only_on_http
- test_oidc_callback_csrf_cookie_persistent_on_https

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jihtsan pushed a commit to jihtsan/dnx-deer-flow that referenced this pull request Aug 29, 2026
…ytedance#3872)

The csrf_token double-submit cookie is set without max_age (a session
cookie), while the access_token cookie is persistent over HTTPS
(max_age = token_expiry_days, see _set_session_cookie). The two cookies
represent one session but had different lifetimes.

iOS Safari home-screen PWAs evict session cookies when iOS terminates the
standalone web app, but keep persistent ones. On reopen the user still
appears logged in (the persistent access_token survives and GET
/api/v1/auth/me is CSRF-exempt), but the session-only csrf_token is gone,
so the frontend's readCsrfCookie() returns null and sends no X-CSRF-Token
header. The first state-changing request then fails with 403 "CSRF token
missing. Include X-CSRF-Token header." Only re-login restored it. This
only manifests over HTTPS, which is why plain-HTTP local dev never sees it.

Give csrf_token the same max_age as access_token at both mint sites --
CSRFMiddleware (auth POSTs) and _set_csrf_cookie (OIDC GET callback) --
mirroring _set_session_cookie's `... if is_https else None` guard so the
double-submit pair always shares a lifetime: persistent together over
HTTPS, session-only together over plain HTTP.

Regression tests in tests/test_auth_type_system.py:
- test_csrf_cookie_persistent_on_https
- test_csrf_cookie_session_only_on_http
- test_oidc_callback_csrf_cookie_persistent_on_https

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/S PR changes 20-100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants