🔎 Buscar

🔑 Autenticación y autorización

Hashing de contraseñas, sesiones, JWT con sus peligros, OAuth 2.0 con PKCE y OpenID Connect, con ejemplos de código reales.

Wiki / Apuntes📖 Contenido

Autenticación y autorización

Dos conceptos que se confunden constantemente y que la seguridad distingue con precisión:

  • Autenticación (authn): ¿quién eres? Verificar la identidad (contraseña, token, huella).
  • Autorización (authz): ¿qué puedes hacer? Decidir qué recursos puede tocar esa identidad.

La autenticación dice quién, la autorización dice cuánto. Un sistema puede autenticarte perfectamente y aun así negarte todo (403 vs 401, ver HTTP de punta a punta).

Este artículo cubre las tres tecnologías que resolverán el 99% de tus casos: hashing de contraseñas, sesiones y JWT, y el estándar industrial para delegar identidad: OAuth 2.0 y OpenID Connect.


Hashing de contraseñas

El hashing es una función unidireccional: a partir de la contraseña obtienes un digest, pero del digest no puedes recuperar la contraseña. Eso lo hace ideal para almacenar credenciales: ni siquiera tú, como administrador de la BD, puedes leerlas.

Por qué no usar MD5 ni SHA-1

Son funciones de hashing rápido, diseñadas para checksums y firmas, no para contraseñas. Un GPU moderno calcula miles de millones de md5() por segundo:

# probar 1 000 000 de contraseñas contra md5 lleva milisegundos
hashcat -m 0 hashes.txt /usr/share/wordlists/rockyou.txt
Algoritmo Velocidad (H/s GPU) ¿Válido para contraseñas?
MD5 ~20 000 M ❌ Roto al instante
SHA-1 ~10 000 M ❌ Roto al instante
SHA-256 ~5 000 M ❌ Rápido, mal
bcrypt ~20 K ✅ Lento por diseño
Argon2id ~3 K ✅ Lento y resistente a GPU

Los hashes de contraseña deben ser lentos y costosos en memoria, para que cada intento de fuerza bruta cueste tiempo y recursos. Además, cada contraseña debe tener su salt único (valor aleatorio que evita que dos usuarios con la misma contraseña tengan el mismo hash y frustra las rainbow tables).

bcrypt

import bcrypt

password = b"super-secreta-123"
salt = bcrypt.gensalt(rounds=12)          # cost factor: más alto = más lento
hash_ = bcrypt.hashpw(password, salt)

# verificación: bcrypt extrae el salt del propio hash
assert bcrypt.checkpw(password, hash_)    # True

El hash de bcrypt lleva el cost factor codificado, así que puedes subirlo con el tiempo y las contraseñas antiguas siguen verificándose:

$2b$12$K7g...  ← versión(2b), cost(12), salt+hash

Argon2id (el recomendado actual)

Ganador de la Password Hashing Competition (2015). Configurable en tiempo y memoria:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)

hash_ = ph.hash("mi-contraseña")
print(hash_)   # $argon2id$v=19$m=65536,t=3,p=4$...

try:
    ph.verify(hash_, "mi-contraseña")
except VerifyMismatchError:
    print("contraseña incorrecta")

💡 Regla de ingeniería: nunca escribas tu propio esquema de hashing. bcrypt y argon2id ya resuelven salt, cost factor y verificación con variable-time-safe comparación. Tu trabajo es elegir uno y configurarlo bien.

Almacenamiento y migración

CREATE TABLE usuarios (
  id         BIGSERIAL PRIMARY KEY,
  email      TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  password_algo TEXT NOT NULL DEFAULT 'argon2id'
);

Para migrar de MD5 a Argon2: en el login, si password_algo = 'md5', verifica con MD5 y si acierta, rehasshea al vuelo con Argon2 y actualiza la fila.


Sesiones basadas en servidor

En el modelo clásico, el servidor guarda el estado de sesión (en memoria, Redis o la BD) y le da al cliente solo un identificador opaco:

import secrets

session_id = secrets.token_urlsafe(32)   # 256 bits de entropía
redis.setex(f"session:{session_id}", 86400, {"user_id": 42, "role": "admin"})
Set-Cookie: session=<session_id>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400; Path=/

En cada petición, el servidor lee la cookie, busca el id en Redis y carga el usuario. Todo el estado está en el servidor: invalidar una sesión es borrar una clave.

Atributo Por qué es obligatorio
HttpOnly El JS del navegador no la lee → un XSS no puede robarla
Secure Solo se envía por HTTPS
SameSite=Lax No se envía en peticiones cross-site → mitiga CSRF
Max-Age/Expires Caducidad real, no infinita

Prácticas de sesión

  • Rotar el id tras el login (session fixation): si alguien ya te dio un id válido, al autenticarte cambias a uno nuevo.
  • Expiración por inactividad y absoluta; revocar en logout.
  • Entropía alta con generador criptográfico (secrets, no random).
  • Revalidar la sesión en acciones sensibles (cambio de contraseña → invalidar el resto).

💡 Las sesiones de servidor son la opción más fácil de invalidar (borrar en el server) y por eso siguen siendo la opción por defecto de los frameworks (Django, Express con express-session, Laravel). JWT las complementa, no las sustituye siempre.


JWT (JSON Web Tokens)

Un JWT es un token auto-contenido y firmado: el servidor no necesita estado, la firma garantiza que nadie lo alteró.

Estructura

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9.3WQv...
└──────────── header ────────────┘.└──────────── payload ────────────┘.└─ signature ─┘
  1. Header: algoritmo de firma (HS256, RS256) y tipo.
  2. Payload: claims (sub, exp, role, …). Base64URL, legible → nunca pongas secretos aquí.
  3. Signature: firma del header+payload.
// Ejemplo visual de cómo se firma con HS256:
const base64url = (obj) => btoa(JSON.stringify(obj)).replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_');

const header    = base64url({ alg: 'HS256', typ: 'JWT' });
const payload   = base64url({ sub: '42', role: 'admin', exp: Math.floor(Date.now()/1000) + 3600 });
const signature = /* HMAC-SHA256(header + "." + payload, secret) */;

const token = `${header}.${payload}.${signature}`;

HS256 vs RS256

HS256 (symmetric) RS256 (asymmetric)
Clave Una sola clave secreta firma y verifica Privada firma, pública verifica
Dónde vive En cada servicio que valida tokens La pública puede vivir en cualquier lado
Caso de uso Un solo servicio de confianza Microservicios / terceros validando
import jwt

# HS256
token = jwt.encode({"sub": "42", "role": "admin"}, "secreto-muy-largo", algorithm="HS256")
data  = jwt.decode(token, "secreto-muy-largo", algorithms=["HS256"])

# RS256
token = jwt.encode({"sub": "42"}, private_key, algorithm="RS256")
data  = jwt.decode(token, public_key, algorithms=["RS256"])

Los peligros reales de JWT

1. Algorithm confusion. Si el validador acepta alg del header sin forzarlo, un atacante envía {"alg":"none"} o un HS256 firmado con la clave pública del servidor (que es pública). Fix: fijar los algoritmos aceptados (algorithms=["RS256"]), nunca confiar en el header.

2. No puedes revocarlo. Si el token filtra, vale hasta exp. La revocación requiere un denylist en el servidor… que reintroduce el estado que querías evitar.

3. Dónde guardarlo. La cookie HttpOnly protege contra XSS pero es vulnerable a CSRF (mitigable con SameSite). localStorage es accesible desde JS (vulnerable a XSS) pero no se envía solo. La práctica más extendida hoy:

  • Access token de vida corta (5-15 min) en memoria del frontend.
  • Refresh token de vida larga en cookie HttpOnly con SameSite=Strict, Secure.

4. No confíes en claims para autorizar. El rol debe salir de la sesión/DB o verificarse, no darse por hecho porque venga en el payload.

Refresh tokens

POST /auth/refresh
Cookie: refresh_token=<refresh-jwt-httpOnly>

HTTP/1.1 200 OK
{ "access_token": "nuevo access token (15 min)" }

El refresh token se guarda en el servidor (o con rotación y reuse detection) y renueva el access token. Así limitas la ventana de un token robado a minutos, no a días.


OAuth 2.0

OAuth 2.0 es un marco de autorización delegada: permite que un usuario dé acceso a sus recursos en un servicio (el authorization server) a una aplicación de terceros (el client) sin entregar su contraseña.

Roles

Rol Qué es Ejemplo
Resource Owner El usuario dueño de los datos
Client La app que pide acceso Tu app web
Authorization Server Emite tokens, conoce credenciales Google, GitHub
Resource Server Sirve los datos protegidos La API de Google

El flujo principal: Authorization Code + PKCE

PKCE (Proof Key for Code Exchange) protege el flujo en aplicaciones públicas (SPAs, móviles) donde el client secret no puede guardarse en secreto:

[1] GET /oauth/authorize?response_type=code&client_id=X
        &redirect_uri=https://app.example/callback
        &code_challenge=sha256(verifier)&code_challenge_method=S256
[2] ← 302 redirect con ?code=AUTH_CODE (el usuario autorizó)
[3] POST /oauth/token
        { grant_type:"authorization_code", code:AUTH_CODE,
          client_id:X, code_verifier:VERIFIER }
[4] ← { access_token, refresh_token }
[5] GET /api/recursos   con Authorization: Bearer access_token

El code_verifier (secreto efímero) viaja en el paso 1 solo como hash y en el paso 3 en claro: el servidor lo usa para probar que quien canjea el code es quien inició el flujo, neutralizando códigos interceptados.

// Cliente (SPA) — ejemplo con verifier
const verifier = generateRandom();          // cadena aleatoria de alta entropía
const challenge = await sha256(verifier);   // code_challenge

location.href = `${AUTH_URL}?response_type=code&client_id=...&code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=...`;
# Servidor — canje del code
import httpx

r = httpx.post(
    "https://auth.example.com/oauth/token",
    data={
        "grant_type": "authorization_code",
        "code": auth_code,
        "code_verifier": verifier,
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
    },
)
tokens = r.json()

⚠️ El flujo Implicit (token en el redirect, sin code) está deprecado en favor de PKCE. Nunca empieces un proyecto nuevo con él.


OpenID Connect (OIDC)

OAuth 2.0 autoriza pero no autentica: el access token no dice quién es el usuario. OIDC extiende OAuth 2.0 añadiendo un ID token, un JWT que sí identifica al usuario.

Token Contiene Uso
Access token Permisos, opaque o JWT Llamar al resource server (API)
ID token (OIDC) Identidad: sub, email, name, exp El client conoce al usuario
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",
  "email": "ana@x.com",
  "email_verified": true,
  "exp": 1699999999,
  "aud": "mi-client-id"
}

El ID token se valida con la clave pública del issuer (jwks_uri), comprobando iss, aud y exp. El access token, en cambio, se envía a la API, que lo valida con sus propios medios.

Flujo completo con OIDC

  1. El usuario entra por “Login con Google”.
  2. Authorization Code + PKCE contra el issuer de Google.
  3. El client recibe access_token + id_token.
  4. El client valida el ID token → sabe quién es el usuario.
  5. El client usa el access token contra la API de Google si necesita sus recursos.

Guía de decisión rápida

Necesidad Solución
Login propio con usuarios de tu BD Sesión de servidor o JWT (acceso corto + refresh)
Hashing de contraseñas Argon2id (o bcrypt) — nunca MD5/SHA
Login social / terceros OAuth 2.0 + OIDC (Authorization Code + PKCE)
Identidad del usuario en el client ID token (OIDC)
Permisos de una API interna Access token firmado (RS256)

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.