Proyectos · JavaScript · Intermedio

Generador de contraseñas seguras

Contraseñas aleatorias de verdad con el generador criptográfico del navegador: longitud y tipos de caracteres a elegir, un carácter de cada tipo garantizado, copiar con un clic y su fuerza en bits.

  • 5 pasos
  • 1 hora aproximadamente
  • 174 líneas de código
  • Editor en vivo

Lo que vas a construir

Un generador de contraseñas parece fácil, pero hacerlo bien tiene trampas: Math.random() no sirve para seguridad, y repartir los caracteres con un simple % hace que unos salgan más que otros. Vas a usar el generador criptográfico del navegador, evitar ese sesgo y calcular cuánto de fuerte es cada contraseña.

Lo que vas a practicar

  • Aleatoriedad segura con crypto.getRandomValues
  • Evitar el sesgo del módulo
  • Mezclar con Fisher-Yates
  • Copiar al portapapeles
  • Medir la fuerza en bits de entropía
Resultado final

Antes de empezar, conviene haber hecho estas lecciones

Paso 1 de 5

La interfaz

Un <output> para la contraseña, un botón para copiarla, una barra de fuerza y un formulario con la longitud (un control deslizante de 8 a 64) y casillas para los tipos de caracteres.

La contraseña usa una fuente monoespaciada y overflow-wrap: anywhere, para que una contraseña larga salte de línea en lugar de salirse de la caja.

index.html
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Generador de contraseñas</title>
  <link rel="stylesheet" href="estilos.css">
</head>
<body>
  <main class="generador">
    <h1>Generador de contraseñas</h1>
    <div class="resultado">
      <output id="clave" class="clave" aria-live="polite">Pulsa «Generar»</output>
      <button type="button" id="copiar" class="copiar">Copiar</button>
    </div>
    <div class="fuerza" aria-hidden="true"><span id="barra"></span></div>
    <p id="nivel" class="nivel"></p>

    <form id="opciones" class="opciones">
      <label for="longitud">Longitud: <strong id="valor-longitud">16</strong></label>
      <input type="range" id="longitud" min="8" max="64" value="16">
      <label><input type="checkbox" id="mayusculas" checked> Mayúsculas (A-Z)</label>
      <label><input type="checkbox" id="numeros" checked> Números (0-9)</label>
      <label><input type="checkbox" id="simbolos" checked> Símbolos (!@#…)</label>
      <button type="submit">Generar</button>
    </form>
  </main>
  <script src="app.js"></script>
</body>
</html>
estilos.css
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 16px;
  background: #ecfeff;
  color: #083344;
  font-family: system-ui, sans-serif;
}

.generador {
  width: min(100%, 440px);
  padding: 24px;
  border-radius: 20px;
  background: #ffffff;
  box-shadow: 0 12px 32px rgb(8 51 68 / 0.12);
}

h1 { margin: 0 0 16px; font-size: 1.4rem; }

.resultado { display: flex; gap: 8px; }

.clave {
  flex: 1;
  min-width: 0;
  padding: 12px;
  border-radius: 10px;
  background: #083344;
  color: #a5f3fc;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 1.05rem;
  overflow-wrap: anywhere;
}

button {
  padding: 10px 16px;
  border: 0;
  border-radius: 10px;
  background: #0e7490;
  color: #ffffff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
button:focus-visible, input:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }

.fuerza { height: 8px; margin-top: 12px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
.fuerza span { display: block; height: 100%; width: 0; background: #dc2626; transition: width 300ms ease, background-color 300ms ease; }
.nivel { min-height: 1.5em; margin: 6px 0 16px; font-weight: 600; }

.opciones { display: grid; gap: 10px; }
.opciones label { display: flex; align-items: center; gap: 8px; }
.opciones input[type="range"] { width: 100%; accent-color: #0e7490; }
.opciones input[type="checkbox"] { width: 1.1rem; height: 1.1rem; accent-color: #0e7490; }
app.js
// El JavaScript llega en el paso 2

Paso 2 de 5

Aleatoriedad de verdad

Math.random() es rápido pero predecible: no está pensado para seguridad. crypto.getRandomValues usa el generador criptográfico del sistema y es el que hay que usar para contraseñas.

Para elegir una posición entre 0 y n − 1, el resto de dividir un número aleatorio entre n favorecería un poco a los primeros caracteres si 2³² no es múltiplo de n. La función azar descarta los números del final del rango y repite: así todos tienen exactamente la misma probabilidad.

app.js
const MINUSCULAS = "abcdefghijkmnopqrstuvwxyz"; // sin la l, que se confunde con el 1

const clave = document.querySelector("#clave");
const opciones = document.querySelector("#opciones");
const longitud = document.querySelector("#longitud");
const valorLongitud = document.querySelector("#valor-longitud");

// Un número al azar entre 0 y maximo - 1 con el generador criptográfico del navegador.
// Se descartan los valores del final del rango para que todos tengan la misma probabilidad.
function azar(maximo) {
  const limite = Math.floor(2 ** 32 / maximo) * maximo;
  const numero = new Uint32Array(1);
  do {
    crypto.getRandomValues(numero);
  } while (numero[0] >= limite);
  return numero[0] % maximo;
}

function generar(tamano, caracteres) {
  let resultado = "";
  for (let i = 0; i < tamano; i++) {
    resultado += caracteres[azar(caracteres.length)];
  }
  return resultado;
}

longitud.addEventListener("input", () => {
  valorLongitud.textContent = longitud.value;
});

opciones.addEventListener("submit", (event) => {
  event.preventDefault();
  clave.textContent = generar(Number(longitud.value), MINUSCULAS);
});

Paso 3 de 5

Elegir qué caracteres usar

Cada tipo de carácter es un grupo. Se quitan los que se confunden entre sí (l y 1, O y 0) para que la contraseña se pueda copiar a mano sin errores.

Para garantizar que aparece al menos un carácter de cada tipo elegido, se pone primero uno de cada grupo, se rellena con caracteres de todos y, al final, se mezcla todo con el algoritmo de Fisher-Yates para que esos primeros no estén siempre al principio.

app.jsLo nuevo, resaltado
const GRUPOS = {
  minusculas: "abcdefghijkmnopqrstuvwxyz",
  mayusculas: "ABCDEFGHJKLMNPQRSTUVWXYZ", // sin I ni O
  numeros: "23456789", // sin 0 ni 1
  simbolos: "!@#$%&*?-_=+",
};

const clave = document.querySelector("#clave");
const opciones = document.querySelector("#opciones");
const longitud = document.querySelector("#longitud");
const valorLongitud = document.querySelector("#valor-longitud");

function azar(maximo) {
  const limite = Math.floor(2 ** 32 / maximo) * maximo;
  const numero = new Uint32Array(1);
  do {
    crypto.getRandomValues(numero);
  } while (numero[0] >= limite);
  return numero[0] % maximo;
}

// Mezcla una lista al azar (algoritmo de Fisher-Yates)
function mezclar(lista) {
  for (let i = lista.length - 1; i > 0; i--) {
    const j = azar(i + 1);
    [lista[i], lista[j]] = [lista[j], lista[i]];
  }
  return lista;
}

function grupoElegidos() {
  const elegidos = [GRUPOS.minusculas];
  for (const nombre of ["mayusculas", "numeros", "simbolos"]) {
    if (document.querySelector(`#${nombre}`).checked) elegidos.push(GRUPOS[nombre]);
  }
  return elegidos;
}

function generar(tamano, grupos) {
  const todos = grupos.join("");
  // Al menos un carácter de cada grupo elegido; el resto, de cualquiera
  const caracteres = grupos.map((g) => g[azar(g.length)]);
  while (caracteres.length < tamano) caracteres.push(todos[azar(todos.length)]);
  return mezclar(caracteres).join("");
}

longitud.addEventListener("input", () => {
  valorLongitud.textContent = longitud.value;
});

opciones.addEventListener("submit", (event) => {
  event.preventDefault();
  clave.textContent = generar(Number(longitud.value), grupoElegidos());
});

Paso 4 de 5

Copiar con un clic

navigator.clipboard.writeText copia el texto al portapapeles. Devuelve una promesa, así que se usa con await dentro de una función async.

Puede fallar si el navegador no da permiso (por ejemplo, en la vista previa aislada del editor), así que va en un try…catch que avisa de copiarla a mano.

app.jsLo nuevo, resaltado
const GRUPOS = {
  minusculas: "abcdefghijkmnopqrstuvwxyz",
  mayusculas: "ABCDEFGHJKLMNPQRSTUVWXYZ", // sin I ni O
  numeros: "23456789", // sin 0 ni 1
  simbolos: "!@#$%&*?-_=+",
};

const clave = document.querySelector("#clave");
const opciones = document.querySelector("#opciones");
const longitud = document.querySelector("#longitud");
const valorLongitud = document.querySelector("#valor-longitud");
const botonCopiar = document.querySelector("#copiar");
let actual = "";

function azar(maximo) {
  const limite = Math.floor(2 ** 32 / maximo) * maximo;
  const numero = new Uint32Array(1);
  do {
    crypto.getRandomValues(numero);
  } while (numero[0] >= limite);
  return numero[0] % maximo;
}

// Mezcla una lista al azar (algoritmo de Fisher-Yates)
function mezclar(lista) {
  for (let i = lista.length - 1; i > 0; i--) {
    const j = azar(i + 1);
    [lista[i], lista[j]] = [lista[j], lista[i]];
  }
  return lista;
}

function grupoElegidos() {
  const elegidos = [GRUPOS.minusculas];
  for (const nombre of ["mayusculas", "numeros", "simbolos"]) {
    if (document.querySelector(`#${nombre}`).checked) elegidos.push(GRUPOS[nombre]);
  }
  return elegidos;
}

function generar(tamano, grupos) {
  const todos = grupos.join("");
  // Al menos un carácter de cada grupo elegido; el resto, de cualquiera
  const caracteres = grupos.map((g) => g[azar(g.length)]);
  while (caracteres.length < tamano) caracteres.push(todos[azar(todos.length)]);
  return mezclar(caracteres).join("");
}

longitud.addEventListener("input", () => {
  valorLongitud.textContent = longitud.value;
});

opciones.addEventListener("submit", (event) => {
  event.preventDefault();
  actual = generar(Number(longitud.value), grupoElegidos());
  clave.textContent = actual;
});

botonCopiar.addEventListener("click", async () => {
  if (!actual) return;
  try {
    await navigator.clipboard.writeText(actual);
    botonCopiar.textContent = "¡Copiada!";
  } catch {
    botonCopiar.textContent = "Cópiala a mano";
  }
  setTimeout(() => { botonCopiar.textContent = "Copiar"; }, 2000);
});

Paso 5 de 5

¿Cómo de fuerte es?

La fuerza se mide en bits de entropía: longitud × log₂(número de caracteres posibles). Cada bit duplica los intentos necesarios para adivinarla por fuerza bruta: 80 bits suponen 2⁵⁰ veces más intentos que 30, más de mil billones de veces.

Los tramos (débil por debajo de 60 bits, fuerte a partir de 80…) son orientativos. La barra cambia de ancho y de color con style.width y style.backgroundColor.

app.jsLo nuevo, resaltado
const GRUPOS = {
  minusculas: "abcdefghijkmnopqrstuvwxyz",
  mayusculas: "ABCDEFGHJKLMNPQRSTUVWXYZ", // sin I ni O
  numeros: "23456789", // sin 0 ni 1
  simbolos: "!@#$%&*?-_=+",
};

const clave = document.querySelector("#clave");
const opciones = document.querySelector("#opciones");
const longitud = document.querySelector("#longitud");
const valorLongitud = document.querySelector("#valor-longitud");
const botonCopiar = document.querySelector("#copiar");
const barra = document.querySelector("#barra");
const nivel = document.querySelector("#nivel");
let actual = "";

// Fuerza en bits de entropía: cuántas veces habría que duplicar los intentos para adivinarla
function mostrarFuerza(tamano, alfabeto) {
  const bits = Math.round(tamano * Math.log2(alfabeto));
  const tramos = [
    [60, "Débil", "#dc2626"],
    [80, "Aceptable", "#f59e0b"],
    [100, "Fuerte", "#16a34a"],
    [Infinity, "Muy fuerte", "#047857"],
  ];
  const [, texto, color] = tramos.find(([maximo]) => bits < maximo);
  barra.style.width = `${Math.min(100, bits / 1.28)}%`;
  barra.style.backgroundColor = color;
  nivel.textContent = `${texto} (unos ${bits} bits)`;
}

function azar(maximo) {
  const limite = Math.floor(2 ** 32 / maximo) * maximo;
  const numero = new Uint32Array(1);
  do {
    crypto.getRandomValues(numero);
  } while (numero[0] >= limite);
  return numero[0] % maximo;
}

// Mezcla una lista al azar (algoritmo de Fisher-Yates)
function mezclar(lista) {
  for (let i = lista.length - 1; i > 0; i--) {
    const j = azar(i + 1);
    [lista[i], lista[j]] = [lista[j], lista[i]];
  }
  return lista;
}

function grupoElegidos() {
  const elegidos = [GRUPOS.minusculas];
  for (const nombre of ["mayusculas", "numeros", "simbolos"]) {
    if (document.querySelector(`#${nombre}`).checked) elegidos.push(GRUPOS[nombre]);
  }
  return elegidos;
}

function generar(tamano, grupos) {
  const todos = grupos.join("");
  // Al menos un carácter de cada grupo elegido; el resto, de cualquiera
  const caracteres = grupos.map((g) => g[azar(g.length)]);
  while (caracteres.length < tamano) caracteres.push(todos[azar(todos.length)]);
  return mezclar(caracteres).join("");
}

longitud.addEventListener("input", () => {
  valorLongitud.textContent = longitud.value;
});

opciones.addEventListener("submit", (event) => {
  event.preventDefault();
  const grupos = grupoElegidos();
  actual = generar(Number(longitud.value), grupos);
  clave.textContent = actual;
  mostrarFuerza(actual.length, grupos.join("").length);
});

botonCopiar.addEventListener("click", async () => {
  if (!actual) return;
  try {
    await navigator.clipboard.writeText(actual);
    botonCopiar.textContent = "¡Copiada!";
  } catch {
    botonCopiar.textContent = "Cópiala a mano";
  }
  setTimeout(() => { botonCopiar.textContent = "Copiar"; }, 2000);
});

Retos para seguir

Has terminado el proyecto. Si quieres ir más allá, prueba a añadirle esto por tu cuenta:

  1. Añade un modo «frase de contraseña» que combine 5 palabras al azar de una lista.
  2. Genera una contraseña nueva automáticamente al cambiar cualquier opción.
  3. Guarda en localStorage las opciones elegidas (nunca la contraseña).

Conceptos clave

Repasa la ruta de JavaScript o elige otro proyecto.