Sesiones, JWT y OAuth: qué son y cuándo usar cada uno

Sesiones, JWT y OAuth: qué son y cuándo usar cada uno

Sesiones, JWT y OAuth: tres formas de saber quién entra en tu app web

Por:

Franco Brutti

·

Fecha de Actualización:

Una sesión guarda en el servidor quién eres y te da una cookie con un identificador aleatorio. Un JWT es un token firmado que lleva tus datos dentro y que el servidor comprueba por su firma. OAuth 2.0 es otra cosa: el protocolo con el que una app obtiene permiso para acceder a tus datos en otro servicio.

La confusión viene de que se comparan como si fueran tres opciones del mismo menú. No lo son. Sesión y JWT son dos maneras de recordar que ya te has identificado; OAuth es la manera de pedir permiso a otro servicio, y muchas veces sus tokens son JWT. Por eso la pregunta útil no es «¿cuál es mejor?», sino «¿qué problema tengo?».

Para que no se quede en teoría, cada concepto va con un ejemplo mínimo en Python que hemos ejecutado el 10 de noviembre de 2026 (Python 3.13, Flask 3.1 y PyJWT 2.15). Las salidas que verás son las reales. Las definiciones salen de los estándares: los RFC del IETF, las guías de OWASP y la documentación de MDN.

Qué es una sesión con cookie y cómo funciona

Una sesión con cookie funciona así: cuando inicias sesión, el servidor crea un registro con tus datos, genera un identificador aleatorio y te lo envía en una cookie. En cada petición, el navegador devuelve esa cookie y el servidor busca el registro. El estado vive en el servidor; tu navegador solo guarda una referencia sin significado.

Es el modelo más antiguo de la web y sigue siendo el más sencillo de hacer bien en una aplicación con su propio backend. La seguridad depende de tres cosas, todas recogidas en la guía de gestión de sesiones de OWASP:

  1. Que el identificador no se pueda adivinar. OWASP exige al menos 64 bits de entropía y un generador criptográficamente seguro.

  2. Que la cookie no quede al alcance de JavaScript. El atributo HttpOnly impide leerla desde document.cookie, que es lo que buscaría un ataque XSS. MDN lo resume así: las cookies que mantienen una sesión del lado del servidor no necesitan estar disponibles para JavaScript.

  3. Que el servidor pueda acabar con ella. Cerrar sesión es borrar el registro. En ese instante la cookie deja de valer, aunque alguien la hubiera copiado.

Este es el ejemplo mínimo con Flask. Guarda las sesiones en un diccionario para que se entienda la idea; en producción irían en Redis o en una base de datos.

# Sesión de servidor con cookie HttpOnly (ejemplo mínimo, no apto para producción tal cual)
import secrets
from flask import Flask, request, make_response, abort

app = Flask(__name__)
SESIONES = {}  # en producción: Redis o base de datos, nunca un dict en memoria

@app.post("/login")
def login():
    # Aquí iría la comprobación real de usuario y contraseña
    usuario = request.form["usuario"]
    sid = secrets.token_urlsafe(32)          # 256 bits aleatorios con CSPRNG
    SESIONES[sid] = {"usuario": usuario}
    resp = make_response("ok")
    resp.set_cookie("sid", sid, httponly=True, secure=True,
                    samesite="Lax", max_age=1800, path="/")
    return resp

@app.get("/perfil")
def perfil():
    datos = SESIONES.get(request.cookies.get("sid", ""))
    if datos is None:
        abort(401)
    return f"Hola, {datos['usuario']}"

@app.post("/logout")
def logout():
    SESIONES.pop(request.cookies.get("sid", ""), None)  # se invalida en el servidor
    resp = make_response("adiós")
    resp.delete_cookie("sid", path="/")
    return resp

if __name__ == "__main__":
    c = app.test_client()
    https = {"base_url": "https://localhost"}  # la cookie Secure solo viaja por HTTPS
    r = c.post("/login", data={"usuario": "ana"}, **https)
    nombre, resto = r.headers["Set-Cookie"].split("=", 1)
    print(nombre + "=<aleatorio>;" + resto.split(";", 1)[1])
    r = c.get("/perfil", **https)
    print("GET /perfil:", r.status_code, r.get_data(as_text=True))
    c.post("/logout", **https)
    print("Sesiones en el servidor tras logout:", len(SESIONES))
    print("GET /perfil sin sesión:", c.get("/perfil", **https).status_code)
# Sesión de servidor con cookie HttpOnly (ejemplo mínimo, no apto para producción tal cual)
import secrets
from flask import Flask, request, make_response, abort

app = Flask(__name__)
SESIONES = {}  # en producción: Redis o base de datos, nunca un dict en memoria

@app.post("/login")
def login():
    # Aquí iría la comprobación real de usuario y contraseña
    usuario = request.form["usuario"]
    sid = secrets.token_urlsafe(32)          # 256 bits aleatorios con CSPRNG
    SESIONES[sid] = {"usuario": usuario}
    resp = make_response("ok")
    resp.set_cookie("sid", sid, httponly=True, secure=True,
                    samesite="Lax", max_age=1800, path="/")
    return resp

@app.get("/perfil")
def perfil():
    datos = SESIONES.get(request.cookies.get("sid", ""))
    if datos is None:
        abort(401)
    return f"Hola, {datos['usuario']}"

@app.post("/logout")
def logout():
    SESIONES.pop(request.cookies.get("sid", ""), None)  # se invalida en el servidor
    resp = make_response("adiós")
    resp.delete_cookie("sid", path="/")
    return resp

if __name__ == "__main__":
    c = app.test_client()
    https = {"base_url": "https://localhost"}  # la cookie Secure solo viaja por HTTPS
    r = c.post("/login", data={"usuario": "ana"}, **https)
    nombre, resto = r.headers["Set-Cookie"].split("=", 1)
    print(nombre + "=<aleatorio>;" + resto.split(";", 1)[1])
    r = c.get("/perfil", **https)
    print("GET /perfil:", r.status_code, r.get_data(as_text=True))
    c.post("/logout", **https)
    print("Sesiones en el servidor tras logout:", len(SESIONES))
    print("GET /perfil sin sesión:", c.get("/perfil", **https).status_code)
# Sesión de servidor con cookie HttpOnly (ejemplo mínimo, no apto para producción tal cual)
import secrets
from flask import Flask, request, make_response, abort

app = Flask(__name__)
SESIONES = {}  # en producción: Redis o base de datos, nunca un dict en memoria

@app.post("/login")
def login():
    # Aquí iría la comprobación real de usuario y contraseña
    usuario = request.form["usuario"]
    sid = secrets.token_urlsafe(32)          # 256 bits aleatorios con CSPRNG
    SESIONES[sid] = {"usuario": usuario}
    resp = make_response("ok")
    resp.set_cookie("sid", sid, httponly=True, secure=True,
                    samesite="Lax", max_age=1800, path="/")
    return resp

@app.get("/perfil")
def perfil():
    datos = SESIONES.get(request.cookies.get("sid", ""))
    if datos is None:
        abort(401)
    return f"Hola, {datos['usuario']}"

@app.post("/logout")
def logout():
    SESIONES.pop(request.cookies.get("sid", ""), None)  # se invalida en el servidor
    resp = make_response("adiós")
    resp.delete_cookie("sid", path="/")
    return resp

if __name__ == "__main__":
    c = app.test_client()
    https = {"base_url": "https://localhost"}  # la cookie Secure solo viaja por HTTPS
    r = c.post("/login", data={"usuario": "ana"}, **https)
    nombre, resto = r.headers["Set-Cookie"].split("=", 1)
    print(nombre + "=<aleatorio>;" + resto.split(";", 1)[1])
    r = c.get("/perfil", **https)
    print("GET /perfil:", r.status_code, r.get_data(as_text=True))
    c.post("/logout", **https)
    print("Sesiones en el servidor tras logout:", len(SESIONES))
    print("GET /perfil sin sesión:", c.get("/perfil", **https).status_code)

Salida real:

sid=<aleatorio>

sid=<aleatorio>

sid=<aleatorio>

Fíjate en la última línea: tras el logout, la misma cookie ya no abre nada. Esa capacidad de invalidar al momento es lo que más se echa de menos cuando se pasa a JWT sin pensarlo.

Dos detalles que OWASP marca como obligatorios y que el ejemplo no cubre. Primero, hay que regenerar el identificador al iniciar sesión y en cada cambio de privilegios, para evitar la fijación de sesión. Segundo, SameSite debe declararse siempre de forma explícita: OWASP prefiere Strict y acepta Lax, y recomienda además el prefijo __Host- en el nombre de la cookie. Usamos Lax porque con Strict el usuario que llega desde un enlace externo aparece sin sesión, algo que en muchas apps confunde más de lo que protege. Si tu app maneja datos sensibles, ve a Strict.

Qué es un JWT y qué lleva dentro

Un JWT (JSON Web Token) es, según el RFC 7519, un medio compacto y seguro para URL de representar claims, es decir, afirmaciones que se transfieren entre dos partes. En su forma habitual va firmado: tiene tres partes separadas por puntos (cabecera, contenido y firma) y quien tenga la clave puede comprobar que nadie lo ha alterado.

Lo que suele sorprender es que firmado no significa cifrado. El contenido va codificado en base64url, no protegido: cualquiera que tenga el token puede leerlo. La firma solo garantiza que no se ha modificado. Por eso en un JWT nunca deben ir contraseñas ni datos que no enseñarías.

Dentro van los claims. El RFC 7519 define varios registrados, y la guía de JWT de OWASP destaca los que importan para la seguridad:

Claim

Qué indica

Por qué validarlo

exp

Momento de caducidad

Pasado ese momento, el RFC dice que el token no debe aceptarse

iss

Quién lo emitió

Evita aceptar tokens de un emisor que no es el tuyo

aud

Para quién es

Evita que un token emitido para otra API sirva en la tuya

sub

De quién habla (el usuario)

Identifica al sujeto

iat

Cuándo se emitió

Permite saber la antigüedad del token

El ejemplo firma un token con HS256, lo lee sin clave para demostrar que no está cifrado, lo verifica y prueba cuatro intentos de colarlo: contenido manipulado, token caducado, audiencia equivocada y el famoso alg: none.

# Firmar y verificar un JWT con PyJWT (HS256). Clave de EJEMPLO generada al vuelo:
# en una app real sale de un gestor de secretos, nunca del código.
import base64, json, secrets, time
import jwt

CLAVE_EJEMPLO = secrets.token_bytes(32)   # 256 bits, como pide OWASP para HS256

ahora = int(time.time())
claims = {"sub": "usuario-42", "iss": "https://auth.ejemplo.dev",
          "aud": "api-ejemplo", "iat": ahora, "exp": ahora + 900}  # 15 minutos
token = jwt.encode(claims, CLAVE_EJEMPLO, algorithm="HS256")
cabecera, carga, firma = token.split(".")
print("Partes del token:", len(token.split(".")))

# 1. El contenido NO está cifrado: cualquiera lo lee sin la clave
leido = json.loads(base64.urlsafe_b64decode(carga + "=" * (-len(carga) % 4)))
print("Carga legible sin clave:", leido["sub"], leido["aud"])

def verificar(t):
    return jwt.decode(t, CLAVE_EJEMPLO, algorithms=["HS256"],
                      audience="api-ejemplo", issuer="https://auth.ejemplo.dev")

# 2. Verificación correcta
print("Verificado:", verificar(token)["sub"])

# 3. Alguien cambia la carga (se hace admin) y conserva la firma
falsa = dict(leido, sub="admin")
carga_falsa = base64.urlsafe_b64encode(json.dumps(falsa).encode()).rstrip(b"=").decode()
for nombre, t in [
    ("carga manipulada", f"{cabecera}.{carga_falsa}.{firma}"),
    ("token caducado", jwt.encode(dict(claims, exp=ahora - 10), CLAVE_EJEMPLO, algorithm="HS256")),
    ("otra audiencia", jwt.encode(dict(claims, aud="otra-api"), CLAVE_EJEMPLO, algorithm="HS256")),
    ("alg none", jwt.encode(claims, None, algorithm="none")),
]:
    try:
        verificar(t)
        print(nombre, "-> ACEPTADO")
    except jwt.InvalidTokenError as e:
        print(nombre, "-> rechazado:", type(e).__name__)
# Firmar y verificar un JWT con PyJWT (HS256). Clave de EJEMPLO generada al vuelo:
# en una app real sale de un gestor de secretos, nunca del código.
import base64, json, secrets, time
import jwt

CLAVE_EJEMPLO = secrets.token_bytes(32)   # 256 bits, como pide OWASP para HS256

ahora = int(time.time())
claims = {"sub": "usuario-42", "iss": "https://auth.ejemplo.dev",
          "aud": "api-ejemplo", "iat": ahora, "exp": ahora + 900}  # 15 minutos
token = jwt.encode(claims, CLAVE_EJEMPLO, algorithm="HS256")
cabecera, carga, firma = token.split(".")
print("Partes del token:", len(token.split(".")))

# 1. El contenido NO está cifrado: cualquiera lo lee sin la clave
leido = json.loads(base64.urlsafe_b64decode(carga + "=" * (-len(carga) % 4)))
print("Carga legible sin clave:", leido["sub"], leido["aud"])

def verificar(t):
    return jwt.decode(t, CLAVE_EJEMPLO, algorithms=["HS256"],
                      audience="api-ejemplo", issuer="https://auth.ejemplo.dev")

# 2. Verificación correcta
print("Verificado:", verificar(token)["sub"])

# 3. Alguien cambia la carga (se hace admin) y conserva la firma
falsa = dict(leido, sub="admin")
carga_falsa = base64.urlsafe_b64encode(json.dumps(falsa).encode()).rstrip(b"=").decode()
for nombre, t in [
    ("carga manipulada", f"{cabecera}.{carga_falsa}.{firma}"),
    ("token caducado", jwt.encode(dict(claims, exp=ahora - 10), CLAVE_EJEMPLO, algorithm="HS256")),
    ("otra audiencia", jwt.encode(dict(claims, aud="otra-api"), CLAVE_EJEMPLO, algorithm="HS256")),
    ("alg none", jwt.encode(claims, None, algorithm="none")),
]:
    try:
        verificar(t)
        print(nombre, "-> ACEPTADO")
    except jwt.InvalidTokenError as e:
        print(nombre, "-> rechazado:", type(e).__name__)
# Firmar y verificar un JWT con PyJWT (HS256). Clave de EJEMPLO generada al vuelo:
# en una app real sale de un gestor de secretos, nunca del código.
import base64, json, secrets, time
import jwt

CLAVE_EJEMPLO = secrets.token_bytes(32)   # 256 bits, como pide OWASP para HS256

ahora = int(time.time())
claims = {"sub": "usuario-42", "iss": "https://auth.ejemplo.dev",
          "aud": "api-ejemplo", "iat": ahora, "exp": ahora + 900}  # 15 minutos
token = jwt.encode(claims, CLAVE_EJEMPLO, algorithm="HS256")
cabecera, carga, firma = token.split(".")
print("Partes del token:", len(token.split(".")))

# 1. El contenido NO está cifrado: cualquiera lo lee sin la clave
leido = json.loads(base64.urlsafe_b64decode(carga + "=" * (-len(carga) % 4)))
print("Carga legible sin clave:", leido["sub"], leido["aud"])

def verificar(t):
    return jwt.decode(t, CLAVE_EJEMPLO, algorithms=["HS256"],
                      audience="api-ejemplo", issuer="https://auth.ejemplo.dev")

# 2. Verificación correcta
print("Verificado:", verificar(token)["sub"])

# 3. Alguien cambia la carga (se hace admin) y conserva la firma
falsa = dict(leido, sub="admin")
carga_falsa = base64.urlsafe_b64encode(json.dumps(falsa).encode()).rstrip(b"=").decode()
for nombre, t in [
    ("carga manipulada", f"{cabecera}.{carga_falsa}.{firma}"),
    ("token caducado", jwt.encode(dict(claims, exp=ahora - 10), CLAVE_EJEMPLO, algorithm="HS256")),
    ("otra audiencia", jwt.encode(dict(claims, aud="otra-api"), CLAVE_EJEMPLO, algorithm="HS256")),
    ("alg none", jwt.encode(claims, None, algorithm="none")),
]:
    try:
        verificar(t)
        print(nombre, "-> ACEPTADO")
    except jwt.InvalidTokenError as e:
        print(nombre, "-> rechazado:", type(e).__name__)

Salida real:

Partes del token: 3
Carga legible sin clave: usuario-42 api-ejemplo
Verificado: usuario-42
carga manipulada -> rechazado: InvalidSignatureError
token caducado -> rechazado: ExpiredSignatureError
otra audiencia -> rechazado: InvalidAudienceError
alg none -> rechazado: InvalidAlgorithmError
Partes del token: 3
Carga legible sin clave: usuario-42 api-ejemplo
Verificado: usuario-42
carga manipulada -> rechazado: InvalidSignatureError
token caducado -> rechazado: ExpiredSignatureError
otra audiencia -> rechazado: InvalidAudienceError
alg none -> rechazado: InvalidAlgorithmError
Partes del token: 3
Carga legible sin clave: usuario-42 api-ejemplo
Verificado: usuario-42
carga manipulada -> rechazado: InvalidSignatureError
token caducado -> rechazado: ExpiredSignatureError
otra audiencia -> rechazado: InvalidAudienceError
alg none -> rechazado: InvalidAlgorithmError

La línea que más enseña es algorithms=["HS256"]. Decir explícitamente qué algoritmo aceptas es lo que bloquea el alg: none: OWASP recuerda que algunas librerías aceptaban antes tokens sin firma por defecto, y con eso cualquiera podía fabricarse el suyo. La clave del ejemplo se genera en cada ejecución con secrets y tiene 256 bits, el tamaño que OWASP pide para HS256. Una contraseña o una cadena escrita en el código no valen como clave.

Sesión o JWT: la diferencia está en dónde vive el estado

La diferencia de fondo entre una sesión y un JWT es dónde se guarda el estado. En la sesión vive en el servidor y la cookie solo lo señala; en el JWT viaja dentro del token y el servidor lo cree mientras la firma y la caducidad cuadren. De ahí salen sus ventajas y sus problemas, que son simétricos.

Sesión con cookie

JWT firmado

Dónde está el estado

En el servidor (Redis, base de datos)

Dentro del token

Qué hace el servidor en cada petición

Busca el identificador

Verifica firma, exp, iss y aud

Cerrar sesión al momento

Sí: se borra el registro

No, salvo que mantengas una lista de revocados

Quién puede leer el contenido

Nadie fuera del servidor

Cualquiera que tenga el token

Dónde encaja mejor

Una app web con su propio backend

APIs que verifican tokens emitidos por otro servicio, como en OAuth

El punto más delicado es la revocación. OWASP lo dice sin rodeos en su guía de JWT: si los usas para sesiones de usuario necesitas una lista de tokens revocados, y en cuanto la tienes la sesión ya no es «sin estado», que era justo la ventaja que buscabas. Su propia recomendación es considerar un sistema de sesiones normal.

Nuestro criterio, para quien empieza y se siente tentado por el JWT porque parece lo moderno: si tu frontend y tu backend son la misma aplicación, empieza con sesiones. Son más fáciles de hacer bien y cerrar sesión funciona de verdad. Reserva el JWT para cuando un token tiene que ser verificado por servicios que no comparten base de datos con quien lo emitió, que es lo que ocurre con los tokens de acceso y de identidad en OAuth y OpenID Connect.

Y una regla que no admite matices: OWASP pide no guardar tokens de sesión, JWT ni refresh tokens en localStorage ni en sessionStorage, porque cualquier script de la página puede leerlos y basta un fallo de XSS para exponerlos todos. Si un JWT tiene que vivir en el navegador, mejor en una cookie HttpOnly.

Del ejemplo de veinte líneas al login de un producto real

En Rock the AI Code aprendes a implementar autenticación y pagos: login de usuarios con JWT, OAuth y Clerk, y cobros con Stripe y webhooks. Descarga el dossier y descubre los 8 proyectos que construirás y el syllabus completo.

Descargar dossier →

Qué es OAuth 2.0 y por qué no es un sistema de login

OAuth 2.0 es, según el RFC 6749, un marco de autorización que permite a una aplicación de terceros obtener acceso limitado a un servicio HTTP en nombre de su propietario. Resuelve un problema concreto: que una app use tus datos de otro servicio sin que tengas que darle tu contraseña.

Antes de OAuth, si una aplicación quería leer tu calendario, le dabas tu usuario y tu contraseña del calendario. El RFC 6749 explica en su introducción por qué eso era un problema: la app guardaba tu contraseña, tenía acceso a todo y no podías retirarle el permiso a ella sola sin cambiar la contraseña. OAuth sustituye la contraseña por un token de acceso limitado a unos permisos (los scopes) y revocable.

El RFC define cuatro roles, y entenderlos ahorra media confusión:

  • Propietario del recurso: tú, la persona que concede el acceso.

  • Cliente: la aplicación que quiere acceder a tus datos.

  • Servidor de autorización: el que te autentica, te pregunta si das permiso y emite los tokens.

  • Servidor de recursos: la API que guarda tus datos y acepta los tokens.

Entonces, ¿por qué se usa OAuth para «Iniciar sesión con…»? Porque sobre sus flujos se construye OpenID Connect. El RFC 9700, que recoge las buenas prácticas actuales de seguridad de OAuth, describe OAuth 2.0 como el estándar para proteger APIs y la base del inicio de sesión federado con OpenID Connect. En OpenID Connect, como recuerda OWASP, la identidad del usuario llega en un ID token que es un JWT. Así se juntan las tres piezas del título.

El flujo de código de autorización con PKCE, paso a paso

El flujo de código de autorización con PKCE es la forma recomendada hoy de usar OAuth en apps web, SPA y móviles. El RFC 9700 obliga a los clientes públicos a usar PKCE y lo recomienda para los confidenciales. Su idea es simple: quien pide el código tiene que demostrar después que es el mismo que lo canjea.

Estos son los pasos, con los nombres de los parámetros que verás en cualquier proveedor:

  1. Tu app prepara el secreto de un solo uso. Genera un code_verifier aleatorio (el RFC 7636 pide al menos 43 caracteres) y calcula su huella: code_challenge = BASE64URL(SHA256(code_verifier)). Ese método se llama S256.

  2. Tu app redirige al usuario al servidor de autorización con response_type=code, su client_id, la redirect_uri, los scope que pide, el code_challenge y code_challenge_method=S256. El verificador no viaja: se queda en tu app.

  3. El usuario se identifica en el servidor de autorización y da su consentimiento. Tu app nunca ve su contraseña.

  4. El servidor redirige de vuelta a la redirect_uri con un code, un código de autorización de corta duración.

  5. Tu backend canjea el código en el endpoint de token enviando el code y el code_verifier original.

  6. El servidor recalcula la huella del verificador y la compara con el code_challenge del paso 2. Si coinciden, entrega el token de acceso (y, si procede, un refresh token). Si alguien interceptó el código en el paso 4, no puede canjearlo porque no tiene el verificador.

  7. Tu app llama a la API con el token de acceso en la cabecera Authorization.

No hemos ejecutado este flujo contra un proveedor real, porque exigiría registrar una aplicación con credenciales. Lo que sí hemos comprobado es el cálculo del paso 1, contrastándolo con el vector de prueba que publica el propio RFC 7636 en su apéndice B:

# PKCE (S256): el cliente crea un code_verifier secreto y envía solo su huella (code_challenge)
import base64, hashlib, secrets

def challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

# Vector de prueba del Apéndice B del RFC 7636
v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print("RFC 7636 coincide:", challenge(v) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")

# En tu app: uno nuevo por cada inicio de sesión
verifier = secrets.token_urlsafe(32)      # 43 caracteres
print("Longitud del verifier:", len(verifier))
print("code_challenge:", len(challenge(verifier)), "caracteres")
# PKCE (S256): el cliente crea un code_verifier secreto y envía solo su huella (code_challenge)
import base64, hashlib, secrets

def challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

# Vector de prueba del Apéndice B del RFC 7636
v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print("RFC 7636 coincide:", challenge(v) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")

# En tu app: uno nuevo por cada inicio de sesión
verifier = secrets.token_urlsafe(32)      # 43 caracteres
print("Longitud del verifier:", len(verifier))
print("code_challenge:", len(challenge(verifier)), "caracteres")
# PKCE (S256): el cliente crea un code_verifier secreto y envía solo su huella (code_challenge)
import base64, hashlib, secrets

def challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

# Vector de prueba del Apéndice B del RFC 7636
v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print("RFC 7636 coincide:", challenge(v) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")

# En tu app: uno nuevo por cada inicio de sesión
verifier = secrets.token_urlsafe(32)      # 43 caracteres
print("Longitud del verifier:", len(verifier))
print("code_challenge:", len(challenge(verifier)), "caracteres")

Salida real:

RFC 7636 coincide: True
Longitud del verifier: 43
code_challenge: 43 caracteres
RFC 7636 coincide: True
Longitud del verifier: 43
code_challenge: 43 caracteres
RFC 7636 coincide: True
Longitud del verifier: 43
code_challenge: 43 caracteres

En la práctica casi nadie escribe esto a mano: las librerías cliente de OAuth y los servicios de identidad lo hacen por ti. Pero saber qué ocurre en cada paso es lo que te permite leer un error de invalid_grant y entender en dos minutos que el verificador no coincide con el reto.

Errores frecuentes al implementar autenticación

Los errores que más se repiten al implementar autenticación no vienen de no saber qué es un JWT, sino de detalles de configuración que los estándares ya avisan. Esta es la lista que repasaría antes de poner en producción cualquier login, con la fuente de cada punto:

  • Aceptar cualquier algoritmo al verificar un JWT. Fija la lista (algorithms=["HS256"] o el que uses). Es la defensa contra alg: none y contra la confusión de claves que describe OWASP.

  • Usar una contraseña como clave de firma. OWASP pide para HS256 una clave aleatoria de al menos 256 bits, distinta para cada emisor y audiencia, y nunca publicada.

  • No validar exp, iss y aud. Un token bien firmado pero emitido para otra API no debería abrirte la tuya.

  • Guardar tokens en localStorage. Va en contra de la guía de sesiones de OWASP; la alternativa es una cookie HttpOnly, Secure y SameSite, o un backend que guarde los tokens (el patrón BFF).

  • No regenerar el identificador de sesión tras el login, lo que abre la puerta a la fijación de sesión.

  • Seguir usando el flujo implícito de OAuth. El RFC 9700 desaconseja el response_type=token porque expone el token de acceso en la redirección. Usa código de autorización con PKCE.

  • Pedir al usuario su contraseña dentro de tu app para obtener un token de otro servicio. Es el flujo de credenciales de contraseña del propietario, y el RFC 9700 dice que no debe usarse.

  • Comparar la redirect_uri con reglas flexibles. El RFC 9700 exige comparación exacta de cadenas en el servidor de autorización.

Si tu app expone una API, estos puntos encajan con lo que contamos en la guía para proteger tus APIs frente a las vulnerabilidades más comunes. Y si todavía estás asentando cómo se diseña una API, empieza por qué es RESTful y sus seis principios: la autenticación se apoya en ellos.

Siguiente paso: implementar el login de un SaaS real

Entender sesiones, JWT y OAuth en un ejemplo de veinte líneas es el primer paso; el trabajo real empieza cuando tienes que unirlos con una base de datos, un frontend y un sistema de pagos sin dejar huecos. Ahí es donde la teoría de este artículo se convierte en criterio propio.

Es justo lo que se trabaja en Rock the AI Code, el máster Full Stack Developer con IA de ThePower Tech School. Según su ficha, consultada el 10-11-2026, el Proyecto 05, «Suscripción de pago», consiste en un gestor de enlaces con plan gratuito y premium en el que «implementa autenticación, pagos recurrentes y persistencia NoSQL con MongoDB para un producto SaaS». En el bloque de herramientas, «Datos y autenticación» incluye «JWT · OAuth · WebSockets», y entre lo que serás capaz de hacer al terminar figura el «login de usuarios con JWT, OAuth y Clerk, y cobros con Stripe y webhooks».

Si después quieres llevar ese login a producción con despliegues automáticos, el siguiente tema natural es CI/CD explicado, del commit a producción. Y si aún estás situando qué parte del trabajo cae en el servidor, repasa frontend y backend explicado fácil.

El futuro es de quienes saben
construir tecnología

| Título + Especialización

El programa para desarrollar habilidades tech desde cero y entrar en el mundo digital.

Descubre Rock the AI Code →

El futuro es de quienes saben
construir tecnología

| Título + Especialización

El programa para desarrollar habilidades tech desde cero y entrar en el mundo digital.

Descubre Rock the AI Code →

Preguntas frecuentes sobre sesiones, JWT y OAuth

¿Qué es JWT y para qué sirve?

JWT (JSON Web Token) es un formato de token definido en el RFC 7519 que transporta afirmaciones, como quién es el usuario o cuándo caduca el token, firmadas para que nadie pueda alterarlas. Sirve para que un servicio verifique la identidad o los permisos de quien llama sin consultar una base de datos central, por ejemplo en APIs protegidas con OAuth.

¿JWT es más seguro que una sesión con cookie?

No por sí mismo. Las dos opciones son seguras si se configuran bien y peligrosas si no. La sesión tiene una ventaja práctica: se puede invalidar al momento borrándola en el servidor. Un JWT sigue siendo válido hasta que caduca, salvo que mantengas una lista de revocados. Para una app web con backend propio, OWASP sugiere considerar sesiones normales.

¿OAuth y JWT son lo mismo?

No. OAuth 2.0 es un protocolo de autorización: define cómo una aplicación obtiene permiso y un token para acceder a datos en tu nombre. JWT es un formato de token. Se combinan a menudo, porque muchos servidores emiten sus tokens de acceso como JWT y OpenID Connect usa un JWT como ID token, pero OAuth no obliga a usar JWT.

¿Dónde guardo un JWT en el navegador?

En una cookie con los atributos HttpOnly, Secure y SameSite, o directamente en el servidor mediante un backend intermedio que guarde los tokens. OWASP desaconseja localStorage y sessionStorage para cualquier credencial, porque cualquier script de la página puede leerlos y un solo fallo de XSS bastaría para robar todos los tokens.

¿Se puede invalidar un JWT antes de que caduque?

No de forma nativa: se acepta mientras la firma y el claim exp sean válidos. Para invalidarlo antes necesitas una lista de tokens revocados que se consulte en cada petición, y eso devuelve estado al servidor. Por eso conviene que los tokens de acceso caduquen pronto y se renueven con un refresh token, que el servidor de autorización sí puede revocar.

Comparte este artículo

THE POWER TECH SCHOOL

¿Qué máster de Tech encaja contigo?

Cuéntanos tu punto de partida y te orientamos hacia el programa adecuado.

Pedir información

RECOMENDADOS PARA TI

Full stack con IA desde cero, sin conocimientos previos

Conviértete en Data Analyst con IA generativa

IA en producción para developers: RAG, LangGraph y MLOps

También puede interesarte

Categorías

¿Buscas algo en concreto?

Usar IA para programar vs programar con IA: la diferencia real

Usar IA para programar vs programar con IA: la diferencia real

Hay una diferencia real entre usar IA para programar y programar con IA. Uno acelera tu flujo. El otro cambia completamente qué puedes construir.

Ver artículo

Mapas de Karnaugh: cómo simplificar funciones lógicas paso a paso

Mapas de Karnaugh: cómo simplificar funciones lógicas paso a paso

Qué es un mapa de Karnaugh, por qué el orden es 00, 01, 11, 10 y cómo agrupar bien, con un ejemplo resuelto de 4 variables y condiciones indiferentes.

Ver artículo

https://thepower.education/blog/tech/de-developer-backend-a-agentic-developer

De developer backend a Agentic Developer: hoja de ruta paso a paso

Si ya desarrollas en backend o fullstack, tienes más de la mitad del camino hecho para convertirte en Agentic Developer. Esta es la ruta concreta paso a paso.

Ver artículo

Programar con IA: el nuevo Full Stack Developer y cómo formarte

Programar con IA: el nuevo Full Stack Developer y cómo formarte

Programar con IA es más que autocompletar código. Descubre qué hace el Full Stack Developer con IA, cuánto gana en España y cómo aprender desde cero.

Ver artículo

Herramientas ciberseguridad: guía técnica

Herramientas de ciberseguridad: lo que usa un profesional para proteger su sistema

Las herramientas de ciberseguridad que usa un profesional para proteger su sistema, cómo configurarlas y cuándo usar cada una. Guía técnica para 2026.

Ver artículo

Perfil Desarrollador Agéntico: habilidades y salidas en 2026

Perfil del Desarrollador Agéntico: habilidades y salidas profesionales en 2026

Descubre qué habilidades define el perfil de un desarrollador agéntico, qué roles existen en el mercado y cuánto gana este perfil en España en 2026.

Ver artículo

En tech, quien no se forma cada año, se queda atrás
En tech, quien no se forma cada año, se queda atrás

VER MÁSTERS TECH

VER MÁSTERS TECH

Business & IA

Tech & Data

Pharma

FP Oficial

Oposiciones

Oficios

In Company