Arquitectura E2EE en Next.js: guía para cumplir GDPR y estar preparado para Chat Control
Hay una diferencia crítica que la mayoría de los tutoriales de seguridad para Next.js no explican con suficiente claridad.
TLS — el HTTPS que tiene tu aplicación — protege los datos en tránsito entre el browser del usuario y tu servidor. Una vez que el mensaje llega a tu servidor, lo tienes en texto plano. Puedes leerlo. Tu proveedor de hosting puede leerlo. Un atacante que compromete tu servidor puede leerlo. Y si mañana llega una orden judicial, tienes algo que entregar.
E2EE — cifrado de extremo a extremo — es arquitectónicamente diferente. El mensaje se cifra en el dispositivo del usuario antes de salir. Tu servidor recibe ciphertext. Nunca ve el plaintext. No tienes nada que leer, nada que entregar, y nada que filtrar si te comprometen.
Esa diferencia arquitectónica es la que hace que E2EE cumpla el principio de "privacy by design" del Artículo 25 del GDPR. Y es la misma diferencia que hace que Chat Control 2.0, en su versión más ambiciosa, sea técnicamente imposible de implementar sin destruir lo que E2EE garantiza.
Esta guía muestra cómo implementar esa arquitectura en una aplicación Next.js con Supabase, de forma que sea compatible con GDPR hoy y esté posicionada correctamente independientemente de cómo evolucione Chat Control.
Los estándares de 2026 que debes conocer antes de escribir una línea
No implementes criptografía desde cero. Los estándares actuales están bien documentados y la comunidad de criptógrafos los ha auditado extensivamente.
Para cifrado simétrico (el contenido del mensaje): AES-256-GCM es el estándar de 2026. El modo GCM (Galois/Counter Mode) proporciona tanto confidencialidad como autenticación del mensaje en una sola operación. Usa claves de 256 bits. Requiere un nonce (número usado una sola vez) único por operación de cifrado — nunca reutilices el mismo nonce con la misma clave.
Para intercambio de claves (cómo dos usuarios acuerdan una clave secreta sin que el servidor la vea): X25519 (ECDH sobre Curve25519) es el estándar actual. Rápido, seguro, y soporte nativo en la Web Crypto API del browser. Para grupos, el protocolo MLS (Messaging Layer Security) — recomendado por el IETF en 2026 para mensajería grupal enterprise — resuelve el problema de distribución de claves a escala.
Para forward secrecy (que las claves pasadas no puedan descifrar mensajes futuros aunque sean comprometidas): El protocolo Signal — con su Double Ratchet algorithm — sigue siendo el gold standard. La variante post-cuántica SPQR (que integra X25519 y CRYSTALS-Kyber) es la dirección que el ecosistema está tomando para resistir ataques de computación cuántica futura.
Para integridad: SHA-256 para hashes de verificación. Las firmas digitales (Ed25519) para autenticar que un mensaje realmente vino del emisor que dice ser.
Herramienta de referencia:
La Web Crypto API (window.crypto.subtle) está disponible en todos los browsers modernos y es la forma correcta de hacer criptografía en el cliente en una aplicación Next.js. No uses librerías de terceros para las operaciones criptográficas core — la API nativa es más auditable y más confiable.
La arquitectura: tres modelos y cuál elegir
Antes del código, hay que tomar una decisión de arquitectura que define todo lo que viene después. Hay tres modelos posibles, con trade-offs diferentes.
Modelo 1: Zero-knowledge server
El servidor recibe y almacena únicamente ciphertext. Las claves privadas nunca salen del dispositivo del usuario. El servidor no puede leer el contenido bajo ninguna circunstancia.
Ventajas: Máxima privacidad. Cumple GDPR Article 32 con la máxima robustez. Si recibes una orden judicial, no tienes nada que entregar. Completamente inmune a Chat Control en cualquier forma que requiera escaneo del lado del servidor.
Desventajas: No puedes hacer búsqueda del lado del servidor. No puedes hacer moderación de contenido del lado del servidor. Si el usuario pierde su dispositivo y no tiene backup de su clave privada, pierde acceso a sus mensajes para siempre. Notificaciones push requieren arquitectura adicional porque el servidor no puede leer el contenido para generar el preview.
Cuándo elegir este modelo: Aplicaciones donde la privacidad es el producto principal. Plataformas para periodistas, abogados, médicos, o cualquier profesional con obligaciones de confidencialidad. Aplicaciones dirigidas a usuarios en jurisdicciones donde la privacidad es críticamente importante.
Modelo 2: Client-side encryption con key escrow opcional
El cifrado ocurre en el cliente. Opcionalmente, el usuario puede elegir hacer un backup cifrado de sus claves en el servidor, protegido por su contraseña o un segundo factor.
Ventajas: Balance entre privacidad y recuperabilidad. El usuario que pierde su dispositivo puede recuperar acceso si hizo backup de sus claves. El servidor sigue sin poder leer los mensajes — el backup de claves está cifrado con la contraseña del usuario, que el servidor nunca ve en texto plano.
Desventajas: El key escrow introduce un vector de ataque adicional. Si la contraseña del usuario es débil, el backup de claves es vulnerable. Requiere implementación cuidadosa del flujo de recuperación.
Cuándo elegir este modelo: La mayoría de las aplicaciones SaaS con componentes de mensajería. Buena opción para usuarios no técnicos que necesitan la posibilidad de recuperación sin sacrificar privacidad real.
Modelo 3: Hybrid — E2EE para mensajes, servidor para metadata
Los mensajes están cifrados de extremo a extremo. Metadata — quién habló con quién, cuándo, con qué frecuencia — se almacena en el servidor en texto plano o con cifrado del lado del servidor.
Ventajas: Permite funcionalidades de servidor como búsqueda por contacto, listas de conversaciones, notificaciones push con preview de remitente. La implementación es más simple que el modelo zero-knowledge completo.
Desventajas: La metadata es información valiosa. "Alice envió 47 mensajes a Bob entre las 11 PM y las 2 AM el jueves" dice mucho sobre la relación incluso sin el contenido. GDPR aplica a la metadata. Chat Control en versiones ambiciosas podría afectar la metadata.
Cuándo elegir este modelo: Aplicaciones donde las funcionalidades de servidor son importantes para la UX y donde la metadata no es especialmente sensible. La mayoría de las apps de mensajería comerciales usan alguna variante de este modelo.
Para esta guía implementaremos el Modelo 2 con opción de key escrow, que representa el mejor balance para la mayoría de las aplicaciones SaaS.
La implementación: paso a paso
Estructura del proyecto
/app
├── /api
│ ├── /messages ← solo recibe y almacena ciphertext
│ └── /keys ← almacena public keys y key escrow cifrado
├── /lib
│ ├── /crypto ← todas las operaciones criptográficas
│ │ ├── keyPair.ts ← generación de pares de claves
│ │ ├── encryption.ts ← cifrado/descifrado de mensajes
│ │ ├── keyExchange.ts ← intercambio de claves entre usuarios
│ │ └── keyStorage.ts ← gestión de claves en el cliente
│ └── /db
│ └── messages.ts ← queries a Supabase (solo ciphertext)
├── /components
│ └── /chat
│ ├── MessageInput.tsx ← cifra antes de enviar
│ └── MessageList.tsx ← descifra al mostrar
└── /hooks
└── useE2EE.ts ← hook para gestión de estado criptográfico
Paso 1: Generación del par de claves
Cuando un usuario se registra, genera un par de claves en su dispositivo. La clave pública va al servidor. La clave privada nunca sale del dispositivo.
// /lib/crypto/keyPair.ts
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return window.crypto.subtle.generateKey(
{
name: "ECDH",
namedCurve: "P-256", // o X25519 cuando esté disponible en todos los browsers
},
true, // extractable — necesario para exportar la clave pública
["deriveKey", "deriveBits"]
);
}
export async function exportPublicKey(keyPair: CryptoKeyPair): Promise<string> {
const exported = await window.crypto.subtle.exportKey(
"spki",
keyPair.publicKey
);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
export async function exportPrivateKey(keyPair: CryptoKeyPair): Promise<ArrayBuffer> {
// Exportamos la clave privada como PKCS8 para almacenamiento local
// NUNCA enviamos esto al servidor sin cifrar
return window.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
}
export async function importPublicKey(publicKeyB64: string): Promise<CryptoKey> {
const keyData = Uint8Array.from(atob(publicKeyB64), c => c.charCodeAt(0));
return window.crypto.subtle.importKey(
"spki",
keyData,
{ name: "ECDH", namedCurve: "P-256" },
true,
[] // las claves públicas no tienen usos en la Web Crypto API
);
}
Paso 2: Almacenamiento seguro de la clave privada en el cliente
La clave privada se almacena en IndexedDB — el único almacenamiento del browser que persiste entre sesiones y es accesible de forma asíncrona. Nunca en localStorage (síncrono y accesible para cualquier script en la página).
// /lib/crypto/keyStorage.ts
const DB_NAME = "e2ee-keys";
const STORE_NAME = "private-keys";
async function openKeyDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "userId" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function storePrivateKey(
userId: string,
privateKey: CryptoKey
): Promise<void> {
const db = await openKeyDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
// Almacenamos el objeto CryptoKey directamente
// IndexedDB puede almacenar CryptoKey objects de forma segura
const request = store.put({ userId, privateKey });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function retrievePrivateKey(
userId: string
): Promise<CryptoKey | null> {
const db = await openKeyDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.get(userId);
request.onsuccess = () => {
resolve(request.result?.privateKey ?? null);
};
request.onerror = () => reject(request.error);
});
}
Paso 3: Intercambio de claves (ECDH)
Cuando Alice quiere enviar un mensaje a Bob, necesita derivar una clave compartida usando su clave privada y la clave pública de Bob. El resultado es una clave simétrica que solo Alice y Bob pueden derivar.
// /lib/crypto/keyExchange.ts
export async function deriveSharedSecret(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<CryptoKey> {
return window.crypto.subtle.deriveKey(
{
name: "ECDH",
public: theirPublicKey,
},
myPrivateKey,
{
name: "AES-GCM",
length: 256,
},
false, // no extractable — la clave compartida nunca sale de la Web Crypto API
["encrypt", "decrypt"]
);
}
// Obtiene la clave pública de un usuario desde el servidor
export async function fetchPublicKey(userId: string): Promise<CryptoKey> {
const response = await fetch(`/api/keys/${userId}`);
const { publicKey } = await response.json();
return importPublicKey(publicKey);
}
Paso 4: Cifrado y descifrado de mensajes
// /lib/crypto/encryption.ts
export async function encryptMessage(
plaintext: string,
sharedKey: CryptoKey
): Promise<{ ciphertext: string; nonce: string }> {
// Genera un nonce único para cada mensaje
// CRÍTICO: nunca reutilizar el mismo nonce con la misma clave
const nonce = window.crypto.getRandomValues(new Uint8Array(12));
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
const ciphertextBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
// tagLength: 128 es el default y el recomendado
},
sharedKey,
data
);
return {
ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer))),
nonce: btoa(String.fromCharCode(...nonce)),
};
}
export async function decryptMessage(
ciphertext: string,
nonce: string,
sharedKey: CryptoKey
): Promise<string> {
const ciphertextBuffer = Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0));
const nonceBuffer = Uint8Array.from(atob(nonce), c => c.charCodeAt(0));
const plaintextBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonceBuffer,
},
sharedKey,
ciphertextBuffer
);
const decoder = new TextDecoder();
return decoder.decode(plaintextBuffer);
}
Paso 5: La API de Next.js — solo maneja ciphertext
// /app/api/messages/route.ts
import { createClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(request: NextRequest) {
const { senderId, recipientId, ciphertext, nonce } = await request.json();
// El servidor NUNCA ve el contenido del mensaje
// Solo almacena el ciphertext y el nonce
// Validación mínima: verificar que los campos existen y tienen el formato correcto
if (!ciphertext || !nonce || !senderId || !recipientId) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Verificación de que ciphertext es base64 válido (no su contenido)
try {
atob(ciphertext);
atob(nonce);
} catch {
return NextResponse.json(
{ error: "Invalid encoding" },
{ status: 400 }
);
}
const { data, error } = await supabase
.from("messages")
.insert({
sender_id: senderId,
recipient_id: recipientId,
ciphertext, // el servidor solo almacena esto
nonce, // y esto
created_at: new Date().toISOString(),
// NO almacenamos ningún plaintext, nunca
})
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ message: data });
}
Paso 6: El hook de React que gestiona todo
// /hooks/useE2EE.ts
import { useState, useEffect } from "react";
import { generateKeyPair, exportPublicKey } from "@/lib/crypto/keyPair";
import { storePrivateKey, retrievePrivateKey } from "@/lib/crypto/keyStorage";
import { deriveSharedSecret, fetchPublicKey } from "@/lib/crypto/keyExchange";
import { encryptMessage, decryptMessage } from "@/lib/crypto/encryption";
export function useE2EE(userId: string) {
const [privateKey, setPrivateKey] = useState<CryptoKey | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
initializeKeys();
}, [userId]);
async function initializeKeys() {
// Intenta recuperar la clave privada existente
const existing = await retrievePrivateKey(userId);
if (existing) {
setPrivateKey(existing);
setIsInitialized(true);
return;
}
// Primera vez: genera un par de claves nuevo
const keyPair = await generateKeyPair();
const publicKeyB64 = await exportPublicKey(keyPair);
// Almacena la clave privada localmente
await storePrivateKey(userId, keyPair.privateKey);
// Publica la clave pública en el servidor
await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, publicKey: publicKeyB64 }),
});
setPrivateKey(keyPair.privateKey);
setIsInitialized(true);
}
async function sendEncryptedMessage(
recipientId: string,
plaintext: string
): Promise<void> {
if (!privateKey) throw new Error("Keys not initialized");
// Obtiene la clave pública del destinatario
const recipientPublicKey = await fetchPublicKey(recipientId);
// Deriva la clave compartida
const sharedKey = await deriveSharedSecret(privateKey, recipientPublicKey);
// Cifra el mensaje
const { ciphertext, nonce } = await encryptMessage(plaintext, sharedKey);
// Envía el ciphertext al servidor
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
senderId: userId,
recipientId,
ciphertext,
nonce,
}),
});
}
async function decryptReceivedMessage(
senderId: string,
ciphertext: string,
nonce: string
): Promise<string> {
if (!privateKey) throw new Error("Keys not initialized");
const senderPublicKey = await fetchPublicKey(senderId);
const sharedKey = await deriveSharedSecret(privateKey, senderPublicKey);
return decryptMessage(ciphertext, nonce, sharedKey);
}
return {
isInitialized,
sendEncryptedMessage,
decryptReceivedMessage,
};
}
El esquema de Supabase
-- Tabla de claves públicas (el servidor solo almacena claves PÚBLICAS)
CREATE TABLE public_keys (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL, -- clave pública base64
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabla de mensajes (solo ciphertext, nunca plaintext)
CREATE TABLE messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
sender_id UUID NOT NULL REFERENCES auth.users(id),
recipient_id UUID NOT NULL REFERENCES auth.users(id),
ciphertext TEXT NOT NULL, -- AES-256-GCM ciphertext en base64
nonce TEXT NOT NULL, -- nonce único por mensaje en base64
created_at TIMESTAMPTZ DEFAULT NOW()
-- NO hay columna de contenido en texto plano
-- El servidor materialmente no puede leer estos mensajes
);
-- RLS: un usuario solo puede leer sus propios mensajes
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can only read their own messages"
ON messages FOR SELECT
USING (
auth.uid() = sender_id OR
auth.uid() = recipient_id
);
CREATE POLICY "Users can only insert messages they send"
ON messages FOR INSERT
WITH CHECK (auth.uid() = sender_id);
-- RLS para claves públicas: cualquiera puede leer (necesario para cifrar)
-- Solo el propietario puede actualizar su propia clave
ALTER TABLE public_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public keys are readable by anyone authenticated"
ON public_keys FOR SELECT
USING (auth.uid() IS NOT NULL);
CREATE POLICY "Users can only manage their own public key"
ON public_keys FOR ALL
USING (auth.uid() = user_id);
GDPR: cómo esta arquitectura cumple cada artículo relevante
Artículo 25 — Privacy by Design y Privacy by Default: La arquitectura no puede cumplir el requisito de privacidad "intentando más fuerte" — lo cumple porque materialmente no tiene acceso al contenido. El diseño garantiza privacidad estructuralmente, no como política.
Artículo 32 — Seguridad del tratamiento: AES-256-GCM es la medida técnica apropiada explícitamente mencionada en las guías de implementación del Artículo 32. La arquitectura E2EE va más allá del requisito mínimo — no solo cifra en tránsito, sino que garantiza que el procesador nunca tiene acceso al plaintext.
Artículo 17 — Derecho al olvido: En un sistema E2EE donde el servidor almacena únicamente ciphertext, la "cryptographic erasure" es una forma aceptada de cumplir el derecho al olvido: eliminar el ciphertext y destruir la clave compartida correspondiente hace que el contenido sea permanentemente inaccesible — equivalente funcional a la eliminación. Esta interpretación está aceptada en la mayoría de los marcos de protección de datos, pero requiere confirmación legal para tu jurisdicción específica antes de adoptarla como política.
Artículo 33 — Notificación de brechas: Si tu servidor es comprometido, los atacantes obtienen ciphertext que no pueden descifrar sin las claves privadas de los usuarios — que nunca llegaron al servidor. Técnicamente no hay una brecha de datos personales en el sentido del Artículo 33, porque los datos personales (el contenido de los mensajes) no estaban accesibles al servidor comprometido.
Documentación para tu DPA (Data Processing Agreement): Cuando documentes tu arquitectura para GDPR, incluye explícitamente: que las claves privadas nunca son transmitidas al servidor, que el servidor almacena únicamente ciphertext irreversiblemente, que el mecanismo de decifrado es exclusivamente client-side, y qué algoritmos y tamaños de clave específicos usas (AES-256-GCM, X25519).
Chat Control: por qué esta arquitectura es la respuesta correcta
La pregunta que muchos developers tienen sobre Chat Control es: "¿cómo implemento el escaneo requerido?"
La respuesta arquitectónica honesta es: no puedes, sin romper E2EE.
En un sistema con la arquitectura descrita en esta guía, el servidor recibe ciphertext. Escanear ciphertext para detectar contenido específico es criptográficamente equivalente a intentar adivinar el contenido sin la clave — imposible de hacer a escala con los recursos computacionales disponibles.
La única forma de escanear el contenido en este sistema es escanear antes del cifrado, en el dispositivo del usuario, antes de que el mensaje sea enviado. Eso es exactamente el client-side scanning que los 300+ criptógrafos firmantes de la carta abierta identificaron como incompatible con la privacidad real.
Si Chat Control 2.0 en su versión más ambiciosa llega a convertirse en ley con mandatos de client-side scanning, las opciones para un sistema como el descrito son dos: implementar el escaneo (y destruir la garantía de E2EE para todos los usuarios), o salir del mercado europeo (como Signal ya anunció que haría).
Construir sobre E2EE ahora te posiciona de la manera más honesta posible: no tienes que "implementar Chat Control" porque materialmente no tienes acceso al contenido que escanear. La decisión de qué hacer si la regulación llega seguirá siendo tuya — pero tomarla desde una arquitectura de privacidad real es diferente a tomarla desde una arquitectura de "cifrado cosmético" donde el servidor tiene las claves.
Las limitaciones que debes comunicar a tus usuarios
Una arquitectura E2EE honesta implica limitaciones reales que tus usuarios necesitan entender.
Sin recuperación sin backup: Si un usuario pierde su dispositivo y no hizo backup de sus claves (o no configuró key escrow), pierde acceso a sus mensajes anteriores permanentemente. Esto no es un bug — es una consecuencia directa de que las claves están solo en el dispositivo. Comunícalo claramente en el onboarding y ofrece un flujo de backup.
Sin búsqueda del lado del servidor: No puedes implementar "buscar en todos mis mensajes" en el servidor porque el servidor no puede leer los mensajes. La búsqueda requiere descargar y descifrar los mensajes en el cliente, lo que es más lento y costoso en términos de ancho de banda.
Sin moderación de contenido del lado del servidor: No puedes detectar y eliminar contenido que viola tus términos de servicio si no puedes leerlo. Esto tiene implicaciones legales y de producto que debes evaluar para tu caso de uso específico.
Funciona por dispositivo, no por cuenta: El par de claves está en el dispositivo. Un usuario que inicia sesión en un dispositivo nuevo no tendrá acceso a mensajes anteriores a menos que transfiera sus claves. El flujo de transferencia de claves entre dispositivos es uno de los problemas de UX más difíciles de E2EE.
Para cerrar: la inversión que vale la pena hacer ahora
Implementar E2EE correctamente toma entre una semana y un mes de trabajo de un developer con experiencia, dependiendo de la complejidad de tu aplicación. Es más tiempo que añadir cifrado del lado del servidor. Introduce limitaciones de funcionalidad reales. Y requiere comunicar a tus usuarios que sus claves son su responsabilidad.
A cambio, obtienes algo que ninguna cantidad de políticas de privacidad puede darte: la capacidad de decirle honestamente a tus usuarios que materialmente no puedes leer sus mensajes — no que "no quieres" ni que "tienes políticas que lo prohíben", sino que la arquitectura hace que sea técnicamente imposible.
En un momento en que Chat Control, el escándalo de Cambridge Analytica, y años de brechas de datos han erosionado la confianza en las plataformas digitales, esa garantía arquitectónica es una ventaja competitiva real — especialmente para cualquier aplicación que sirva a usuarios europeos que valoran la privacidad como feature, no como marketing.
El código de esta guía es un punto de partida. Para producción, necesitarás auditoría de seguridad externa, manejo de edge cases en el flujo de keys entre dispositivos, y una estrategia clara para el derecho al olvido bajo GDPR. Pero la arquitectura central — claves privadas en el cliente, ciphertext en el servidor, nunca plaintext en tránsito — es sólida y está lista para implementar.
¿Tienes preguntas sobre implementación específica para tu caso de uso — mensajería grupal, compartición de archivos, o integración con Supabase Realtime? Déjalo en los comentarios. Y si quieres la segunda parte de esta guía cubriendo key escrow, transferencia de claves entre dispositivos, y mensajería grupal con MLS, déjalo abajo.


Comentarios