Please help me understand why I shouldn't save CSRF tokens in session storage

I am new to webdevelopment and have written some tests in Playwright to test my webapplication. The tests are failing only on Webkit/safari, seemingly due to CSRF validation issues returning 403 Errors on unsafe (POST/PUT/DELETE) requests only.

Backend: api.example.com (Django) Frontend: app.example.com (React.js)

My current procedure:

  1. The user calls a dedicated endpoint api.example.com/api/auth/csrf/ which simply sets the csrf cookie using Set-Cookie default Django behaviour, and returns nothing in the response body.
  2. For a subsequent POST Request the csrftoken cookie is read and set as the X-CSRFToken header.
  3. The user logs in using a POST request and username password together with this header to get the sessionid in cookies.
  4. Subsequent POST requests with csrftoken and session_id in cookies fail, as my React.js application fails to create the X-CSRFToken header (empty header). This only happens for Webkit/Safari, not for Firefox or Chromium.

From what I understand, is that even though the Cookies are sent across subdomains, webkit does not allow cookies to be read from document.cookies across different subdomains when setting Samesite=lax, even if the domain is set to .example.com. This is in contrast with chromium and firefox that seemingly do allow this behaviour.

In all browsers, I have confirmed that the cookies are correctly being sent in unsafe requests across sub-domains, and that the Set-Cookie attribute in initial repsponse header indeed included the domain .example.com. However, the X-CSRFToken header remains empty in my POST request to api.example.com.

Best practice Procedure From what I can see, the best-practice solution would be to host the frontend and backend on example.com/app and example.com/api instead to circumvent this issue.

Question: However, I don't understand why I cannot just save the csrftoken to session storage, and use that to create the X-CSRFToken header. I do not see why this would be more vulnerable than reading out the csrftoken cookie from the cookiestorage.

My reasoning is as follows:

  • The csrftoken is already set to httponly=false, to allow it to be read and subsequently be used to set the X-CSRFToken header. As such, the csrftoken is already vulnerable for XSS attacks using the recommended approach by Django (https://docs.djangoproject.com/en/5.1/howto/csrf/)
  • Session storage is cannot be accessed cross-domain, and therefore does not add more vulnerabilities than a samesite='lax' cookie.
  • The cookie is still being used to validate the X-CSRFToken, which maintains the cross-site protection built-in by browsers.

What am I missing?

https://www.blackduck.com/glossary/what-is-csrf.html#:~:text=A%20CSRF%20token%20is%20a,token%20for%20every%20user%20session. https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

Using SameSite=Lax or Strict for your session cookies is a mitigation of CSRF vulnerabilities. CSRF tokens are as well. The only reason reason you still need CSRF tokens is if you're supporting scenarios where SameSite is not supported, so older browsers. Adding CSRF tokens doesn't hurt it's a type of defense-in-depth but no longer strictly required for normal requirements.

However, I don't understand why I cannot just save the csrftoken to session storage, and use that to create the X-CSRFToken

Your frontend javascript code can't read from the session directly, which is strictly on the server-side, so I don't know how this would work.

You don't have to send the token via a cookie, it's also fine in a response header or body, but setting it in the session means the browser can't read it.

One thing to keep in mind is that the CSRF risk here is a different origin initiating a destructive request. That different origin has to do that blindly, they don't have the opportunity to read a HTTP response body.

This issue arises due to Safari/WebKit's stricter handling of cross-domain cookies and access to document.cookie, even with SameSite=Lax and a .example.com domain.

You're correct that saving the CSRF token to sessionStorage can be a viable workaround, and does not add additional CSRF vulnerabilities, because:

  • The CSRF token is already exposed to JavaScript (HttpOnly = false) in Django’s default setup.

  • Storing it in sessionStorage (or localStorage) is not less secure than reading it from cookies, since both are exposed to the same XSS risks.

  • CSRF protection still works because Django validates the token server-side using the cookie and the X-CSRFToken header — the source of the header value (cookie vs. storage) doesn’t change that.

However, do note:

  • XSS is the primary risk here, not CSRF. If an attacker can run JS on your site, they can read from both document.cookie and sessionStorage.

  • Make sure your app has strong XSS protection (Content Security Policy, output escaping, etc.).

If you’re only serving your frontend from a different subdomain, moving both frontend and backend to the same root domain (example.com/app, example.com/api) is the most robust fix, since it avoids cross-subdomain issues entirely.

Вернуться на верх