JWT httpOnly Cookies - Stop XSS from Stealing Sessions
A JWT in localStorage is readable by any script on your page - one XSS and the attacker walks off with the session token. Moving the JWT into an httpOnly cookie blocks JavaScript from reading it entirely. Then a CSRF synchronizer token closes the remaining cross-site-request gap.
Why localStorage leaks your JWT
When a server returns a JWT in the response body and the client stores it with
localStorage.setItem("token", jwt), any JavaScript on that domain can read it -
including injected scripts from an XSS vulnerability. The attacker calls
localStorage.getItem("token"), exfiltrates the value, and replays it from
anywhere in the world.
Set the cookie on login instead
On a successful /login, mint the JWT and write it as a cookie with three flags:
from flask import make_response, jsonify, request
import secrets, jwt as pyjwt
SESSIONS = {} # session_id -> csrf_token (use Redis in production)
@app.route("/login", methods=["POST"])
def login():
user = (request.get_json(force=True) or {}).get("username")
if not user:
return jsonify({"error": "bad request"}), 400
session_id = secrets.token_urlsafe(16)
csrf = secrets.token_urlsafe(32)
SESSIONS[session_id] = csrf
token = pyjwt.encode({"sub": user, "sid": session_id}, JWT_SECRET, algorithm="HS256")
resp = make_response(jsonify({"csrf_token": csrf}))
resp.set_cookie(
"session", token,
httponly=True, # JS cannot read this cookie
secure=True, # HTTPS only
samesite="Strict", # browser won't attach on cross-site requests
path="/",
)
return resp
The JWT goes into the cookie - never in the response JSON body. The csrf_token
goes into the response body so the client can hold it in memory (a JavaScript
variable, not localStorage).
Verify both tokens on state-changing endpoints
The browser sends the session cookie automatically on same-site requests, but
the CSRF token must be sent explicitly by the client as X-CSRF-Token. The server
cross-checks them:
import hmac
@app.route("/transfer", methods=["POST"])
def transfer():
cookie = request.cookies.get("session")
if not cookie:
return jsonify({"error": "no session"}), 401
try:
payload = pyjwt.decode(cookie, JWT_SECRET, algorithms=["HS256"])
except pyjwt.InvalidTokenError:
return jsonify({"error": "invalid session"}), 401
presented = request.headers.get("X-CSRF-Token", "")
expected = SESSIONS.get(payload.get("sid"), "")
if not expected or not hmac.compare_digest(presented, expected):
return jsonify({"error": "csrf failed"}), 403
return jsonify({"ok": True, "user": payload["sub"]})
hmac.compare_digest is constant-time, preventing timing attacks on the comparison.
Why both flags + the CSRF token
- httpOnly - blocks
document.cookiereads. XSS can still make requests while the victim is browsing (the browser attaches the cookie automatically), but it cannot exfiltrate the session for offline replay. - SameSite=Strict - the browser omits the cookie on cross-site navigations, which stops most CSRF. Not universal - older browsers don't support it.
- Synchronizer token - a random per-session secret the server stores and the client must echo back as a header. A forged cross-site request won't have it. Defense-in-depth alongside SameSite.
In production, replace the in-memory SESSIONS dict with Redis keyed on
session_id with a TTL matching the JWT expiry.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Setting an httpOnly + Secure + SameSite=Strict cookie on login
- Implementing the synchronizer token pattern with hmac.compare_digest
- Verifying session cookie + X-CSRF-Token on state-changing endpoints
FAQ
Why is storing a JWT in localStorage dangerous?
Any JavaScript running on your domain can call localStorage.getItem() and read the token - including scripts injected by an XSS attack. The attacker can then exfiltrate it and replay the session from a different machine.
What does httpOnly on a cookie actually do?
httpOnly is a cookie flag that tells the browser to never expose the cookie to JavaScript. document.cookie returns nothing for httpOnly cookies, so an XSS script cannot read or steal the session token even if it executes on the page.
If SameSite=Strict already blocks CSRF, why add a CSRF token?
SameSite is not universally enforced - older browsers ignore it, and some edge cases (e.g. top-level navigation POST) still attach the cookie. The synchronizer token pattern is defense-in-depth: a forged request from another site won't know the per-session CSRF token stored server-side.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →