Tutorial

Cómo proteger formularios web con cifrado extremo a extremo sin JavaScript externo

19 de marzo de 2026 12 min de lectura PWR.es

HTTPS cifra el tráfico entre el navegador y tu servidor. Pero no cifra los datos dentro de tu servidor. Una vez que el formulario llega a PHP, los datos están en texto claro en $_POST, en los logs de Apache, en la memoria del proceso, y posiblemente en los logs de un WAF o un proxy inverso. Si alguien compromete tu servidor — o simplemente tiene acceso de lectura a los logs — los datos del formulario están expuestos.

El cifrado extremo a extremo (E2E) resuelve esto: los datos se cifran en el navegador del usuario, antes de salir de su dispositivo, y solo se descifran cuando tú los necesitas con tu clave privada. El servidor de tránsito nunca ve los datos en claro. Y lo mejor: el navegador ya trae todo lo necesario para hacerlo, sin librerías externas.

Web Crypto API: criptografía nativa del navegador

La Web Crypto API (window.crypto.subtle) está disponible en todos los navegadores modernos desde 2017. Proporciona funciones criptográficas de bajo nivel implementadas en C/C++ por el motor del navegador, no en JavaScript. Es rápida, segura, y no requiere importar ninguna librería.

Las funciones que necesitamos son: generateKey() para generar claves AES efímeras, encrypt()/decrypt() para cifrado simétrico, importKey() para importar la clave pública RSA del servidor, y wrapKey() para cifrar la clave AES con RSA-OAEP. Veamos la arquitectura completa.

Arquitectura del cifrado E2E

El flujo sigue el patrón estándar de cifrado híbrido: los datos se cifran con una clave simétrica AES efímera (rápida, apta para datos grandes), y la clave AES se cifra con la clave pública RSA del servidor (lenta, pero solo cifra 32 bytes). El servidor descifra primero la clave AES con su clave privada RSA, y luego usa esa clave AES para descifrar los datos.

  • 1. El servidor genera un par de claves RSA-4096 (una vez, al configurar el sistema)
  • 2. La clave pública RSA se incluye en la página HTML o se sirve vía endpoint
  • 3. El navegador genera una clave AES-256-GCM efímera para cada envío
  • 4. Los datos del formulario se cifran con AES-256-GCM en el navegador
  • 5. La clave AES se cifra con la clave pública RSA-OAEP del servidor
  • 6. Se envían al servidor: datos cifrados (AES) + clave cifrada (RSA) + IV
  • 7. El servidor descifra la clave AES con su clave privada RSA
  • 8. El servidor descifra los datos con la clave AES recuperada

Paso 1: Generar el par de claves RSA en el servidor

Primero, generamos el par de claves RSA que se usará para el intercambio de claves. La clave privada se almacena de forma segura en el servidor; la clave pública se distribuye al navegador. Esto se hace una vez, no en cada petición:

PHP
<?php
// generar_claves.php — Ejecutar una sola vez

// Generar par RSA-4096 para intercambio de claves E2E
$config = [
    'private_key_bits' => 4096,
    'private_key_type' => OPENSSL_KEYTYPE_RSA,
];

$par = openssl_pkey_new($config);

// Exportar clave privada (guardar en lugar seguro, NUNCA en directorio web)
openssl_pkey_export($par, $clave_privada);
file_put_contents('/claves/e2e_privada.pem', $clave_privada);
chmod('/claves/e2e_privada.pem', 0600);

// Exportar clave pública (esta se sirve al navegador)
$detalles = openssl_pkey_get_details($par);
$clave_publica = $detalles['key'];
file_put_contents(__DIR__ . '/datos/e2e_publica.pem', $clave_publica);

echo 'Claves generadas. Pública: ' . strlen($clave_publica) . ' bytes';

Paso 2: Cifrar en el navegador con Web Crypto API

Este es el núcleo del sistema. El JavaScript del navegador toma los datos del formulario, genera una clave AES efímera, cifra los datos, y luego envuelve (wrap) la clave AES con la clave pública RSA del servidor. Todo sin salir del navegador:

JAVASCRIPT
// e2e-cifrar.js — Sin dependencias externas, solo Web Crypto API

async function cifrarFormulario(formulario, clavePublicaPEM) {
    // 1. Importar la clave pública RSA del servidor
    const clavePublica = await importarClavePublicaRSA(clavePublicaPEM);

    // 2. Generar clave AES-256-GCM efímera
    const claveAES = await crypto.subtle.generateKey(
        { name: 'AES-GCM', length: 256 },
        true,        // extractable: necesitamos exportarla
        ['encrypt']
    );

    // 3. Serializar los datos del formulario
    const formData = new FormData(formulario);
    const datos = JSON.stringify(Object.fromEntries(formData));
    const datosBytes = new TextEncoder().encode(datos);

    // 4. Cifrar datos con AES-256-GCM
    const iv = crypto.getRandomValues(new Uint8Array(12)); // 96 bits
    const datosCifrados = await crypto.subtle.encrypt(
        { name: 'AES-GCM', iv: iv, tagLength: 128 },
        claveAES,
        datosBytes
    );

    // 5. Envolver (wrap) la clave AES con RSA-OAEP
    const claveCifrada = await crypto.subtle.wrapKey(
        'raw', claveAES, clavePublica,
        { name: 'RSA-OAEP' }
    );

    // 6. Devolver todo en base64 para enviar por POST
    return {
        datos: arrayBufferToBase64(datosCifrados),
        clave: arrayBufferToBase64(claveCifrada),
        iv: arrayBufferToBase64(iv)
    };
}

Necesitamos dos funciones auxiliares: una para importar la clave pública PEM en formato Web Crypto, y otra para convertir ArrayBuffer a Base64:

JAVASCRIPT
// Importar clave pública PEM como CryptoKey RSA-OAEP
async function importarClavePublicaRSA(pem) {
    // Eliminar cabeceras PEM y decodificar base64
    const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s/g, '');
    const der = Uint8Array.from(atob(b64), c => c.charCodeAt(0));

    return crypto.subtle.importKey(
        'spki', der.buffer,
        { name: 'RSA-OAEP', hash: 'SHA-256' },
        false,     // no extractable
        ['wrapKey'] // solo para envolver claves AES
    );
}

// ArrayBuffer → Base64
function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary);
}

Paso 3: Enviar el formulario cifrado

El formulario se intercepta en el submit, se cifra en el navegador, y se envía como JSON. El servidor nunca recibe los campos del formulario en claro:

JAVASCRIPT
// Interceptar envío del formulario
document.getElementById('formulario-contacto')
    .addEventListener('submit', async (e) => {
    e.preventDefault();

    // Obtener clave pública del servidor (embebida en la página)
    const clavePublica = document.getElementById('e2e-pubkey').textContent;

    // Cifrar todos los campos
    const cifrado = await cifrarFormulario(e.target, clavePublica);

    // Enviar datos cifrados por POST
    const respuesta = await fetch('/procesar-formulario.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(cifrado)
    });

    if (respuesta.ok) {
        e.target.innerHTML = '<p>Mensaje enviado de forma segura.</p>';
    }
});

Paso 4: Descifrar en el servidor PHP

El servidor recibe tres campos en Base64: los datos cifrados con AES-GCM, la clave AES cifrada con RSA-OAEP, y el IV. Primero descifra la clave AES con su clave privada RSA, y luego usa esa clave para descifrar los datos:

PHP
<?php
// procesar-formulario.php — Descifrar datos E2E

// 1. Leer datos cifrados del body JSON
$entrada = json_decode(file_get_contents('php://input'), true);
$datos_cifrados = base64_decode($entrada['datos']);
$clave_cifrada = base64_decode($entrada['clave']);
$iv = base64_decode($entrada['iv']);

// 2. Cargar clave privada RSA del servidor
$clave_privada = openssl_pkey_get_private(
    file_get_contents('/claves/e2e_privada.pem')
);

// 3. Descifrar la clave AES con RSA-OAEP
$clave_aes = '';
$ok = openssl_private_decrypt(
    $clave_cifrada, $clave_aes, $clave_privada,
    OPENSSL_PKCS1_OAEP_PADDING
);

if (!$ok) {
    http_response_code(400);
    die(json_encode(['error' => 'Clave no válida']));
}

// 4. Descifrar datos con AES-256-GCM
// Web Crypto API concatena ciphertext + tag (últimos 16 bytes)
$tag_length = 16;
$tag = substr($datos_cifrados, -$tag_length);
$ciphertext = substr($datos_cifrados, 0, -$tag_length);

$datos_json = openssl_decrypt(
    $ciphertext, 'aes-256-gcm', $clave_aes,
    OPENSSL_RAW_DATA, $iv, $tag
);

// 5. Procesar datos descifrados
$datos = json_decode($datos_json, true);
// $datos['nombre'], $datos['email'], $datos['mensaje'] — en claro

// 6. Almacenar cifrados en reposo (segunda capa de protección)
require_once 'cuantica.php';
$almacenado = cuantica_cifrar($datos_json, $clave_maestra);
file_put_contents('datos/contactos/' . uniqid() . '.enc', $almacenado);

echo json_encode(['ok' => true]);

Qué protege el cifrado E2E y qué no

Es importante ser honesto sobre los límites de este enfoque. El cifrado E2E en formularios web protege contra:

  • Interceptación del tráfico en proxies intermedios (incluso con TLS terminado en un balanceador)
  • Lectura de datos en logs del servidor web (Apache, Nginx) — los logs solo ven el body cifrado
  • Acceso no autorizado al servidor — sin la clave privada RSA, los datos son ilegibles
  • Ataques de tipo 'man in the middle' en redes corporativas con inspección SSL
  • Cumplimiento de regulaciones que exigen cifrado de datos personales en tránsito y reposo

Pero no protege contra un atacante que modifique el JavaScript de la página (si compromete tu servidor, puede eliminar el cifrado E2E). Tampoco protege contra malware en el dispositivo del usuario, ni contra ataques de ingeniería social. El cifrado E2E es una capa de defensa, no una solución mágica.

Integración con Cápsula de PWR.es

Cápsula es el módulo de formularios seguros de PWR.es que implementa todo lo descrito en este artículo — y más. Además del cifrado E2E con Web Crypto API, incluye:

  • Protección anti-spam sin reCAPTCHA: honeypot + timestamp + proof-of-work en el navegador
  • Rate limiting por IP integrado, sin dependencias de servidor
  • Validación de campos en el navegador antes del cifrado (para UX, no para seguridad)
  • Almacenamiento cifrado con Cuántica (6 capas + PQC) en el servidor
  • Notificación al administrador con los datos cifrados — solo descifra quien tiene la clave
  • Registro de consentimiento RGPD con marca temporal y hash del texto legal
PHP
<?php
// Integración con Cápsula — una línea para formulario seguro E2E
require_once 'capsula.php';

// Renderizar formulario con cifrado E2E integrado
echo capsula_formulario([
    'campos' => [
        ['nombre' => 'nombre', 'tipo' => 'text', 'requerido' => true],
        ['nombre' => 'email', 'tipo' => 'email', 'requerido' => true],
        ['nombre' => 'mensaje', 'tipo' => 'textarea', 'requerido' => true],
    ],
    'cifrado_e2e' => true,
    'clave_publica' => file_get_contents('datos/e2e_publica.pem'),
    'destino' => '/procesar-formulario.php',
    'texto_rgpd' => 'Acepto el tratamiento de mis datos según la política...',
]);
// Genera HTML + JS con Web Crypto API, sin dependencias externas

Consideraciones de seguridad adicionales

Para que el cifrado E2E sea efectivo, hay varios detalles que no puedes ignorar:

  • Content Security Policy (CSP): tu cabecera CSP debe prohibir scripts inline y solo permitir scripts de tu dominio. Si un atacante puede inyectar JavaScript, puede eludir el cifrado E2E
  • Subresource Integrity (SRI): si cargas tu JS desde un CDN (no recomendado), usa hashes SRI para verificar que no ha sido modificado
  • Clave privada RSA: nunca en el directorio web, nunca en el repositorio, permisos 600, accesible solo por el usuario PHP
  • Rotación de claves: cambia el par RSA periódicamente y re-cifra los datos almacenados con la nueva clave
  • HSTS y HPKP: fuerza HTTPS siempre y considera certificate pinning para evitar ataques MitM con certificados fraudulentos

El cifrado E2E en formularios web es una medida de defensa en profundidad. No sustituye a HTTPS, no sustituye a los prepared statements, no sustituye al control de acceso. Pero añade una capa que protege los datos incluso cuando otras capas fallan. Y con la Web Crypto API nativa, no cuesta nada implementarlo.