🔑 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.
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.
Atributos de cookie no negociables
| 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, norandom). - 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 ─┘
- Header: algoritmo de firma (
HS256,RS256) y tipo. - Payload: claims (
sub,exp,role, …). Base64URL, legible → nunca pongas secretos aquí. - 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 | Tú |
| 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
- El usuario entra por “Login con Google”.
- Authorization Code + PKCE contra el issuer de Google.
- El client recibe
access_token+id_token. - El client valida el ID token → sabe quién es el usuario.
- 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
- OWASP Authentication Cheat Sheet: la referencia de autenticación del OWASP.
- JWT.io: debugger visual para decodificar y firmar tokens.
- RFC 6749 — OAuth 2.0: el estándar original de OAuth.
- RFC 7636 — PKCE: la prueba para aplicaciones públicas.
- OAuth.net: guías, diagramas de flujo y vídeos en español.