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.

Security Engineersecurityxsscsrf

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.

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

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

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

Close a SQL Injection in a Search EndpointSecurity projectNeutralize a Stored XSS in CommentsSecurity projectRemove Hardcoded Credentials From Source CodeSecurity projectSecurity roadmapStep by step to hiredSecurity interview questionsSTAR answersAll Security projectsProjects hub

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 →