Autenticación vs. autorización: OAuth 2.0, OpenID Connect y SAML explicados con ejemplos

Una aplicación de fotografías parece un buen lugar para separar conceptos que a menudo se mezclan. Imaginemos que Ana inicia sesión, consulta una galería y modifica el título de una foto que subió ella. Puede ver otra foto de Bruno, pero no cambiarla. ¿Qué ha demostrado cada paso?
Al iniciar sesión, el sistema ha establecido la identidad de Ana. Al decidir si puede editar cada foto, ha aplicado reglas sobre una acción y un recurso concretos. OAuth 2.0, OpenID Connect (OIDC) y SAML resuelven partes distintas de ese problema. Entender sus límites evita dos errores frecuentes: tratar un token como una autorización ilimitada y usar un dato de identidad como si fuera una credencial para cualquier API.
Autenticación y autorización no son sinónimos
Autenticación responde a «¿quién eres?». Una contraseña, una llave de seguridad, una sesión corporativa o una aplicación de autenticación pueden servir para que el proveedor concluya que quien está delante es Ana.
Autorización responde a «¿qué puedes hacer, sobre qué recurso y en qué condiciones?». Puede conceder a Ana photos:read y photos:write, pero la API todavía debe decidir si la fotografía p-42 es suya, si está archivada o si una regla de equipo le da acceso. Estar autenticada no convierte a Ana en propietaria de todas las fotos.
La distinción también aparece en HTTP:
- 401 Unauthorized significa, pese a su nombre histórico, que faltan credenciales válidas o no se han podido validar. En un esquema Bearer suele acompañarse de
WWW-Authenticate. - 403 Forbidden significa que se entendió la petición y las credenciales eran válidas, pero la operación está prohibida por la lógica de autorización. Algunas APIs eligen devolver 404 para no revelar que el recurso existe.
La convención exacta de errores debe documentarse, pero usar 401 para un access token expirado o con audiencia incorrecta y 403 para un scope, rol o propiedad insuficiente da a clientes y operadores una señal útil. HTTP Semantics y la documentación de MDN distinguen ambos casos.
OAuth 2.0: acceso delegado, no un sistema de permisos completo
OAuth 2.0 permite que una aplicación obtenga acceso limitado a un recurso sin conocer la contraseña de la persona. En nuestro caso, la aplicación web de la galería no recibe la contraseña de Ana: la introduce, si es necesario, en el proveedor de identidad. La aplicación recibe un token que puede presentar ante la API de fotografías.
Sus cuatro papeles explican el recorrido:
- el resource owner es Ana, titular de las fotos o de la decisión de acceso;
- el client es la aplicación web que quiere llamar a la API;
- el authorization server autentica a Ana, recoge consentimiento cuando procede y emite credenciales;
- el resource server es la API que protege las fotos y valida la credencial recibida.
El consentimiento, los scopes y la autorización de la API son capas relacionadas, pero distintas. Un consentimiento puede permitir al cliente pedir photos:read; un scope limita el tipo de acceso delegado solicitado o concedido; y la API decide en cada petición si el token, la acción y el recurso satisfacen su política. En particular, photos:write no significa «editar cualquier fotografía»: la API debe comparar el sujeto del token con el propietario de p-42, o consultar una política que admita, por ejemplo, a un editor del equipo.
OAuth especifica cómo se obtiene y se usa un token de acceso, no el modelo universal de permisos de todas las aplicaciones. Un token puede ser opaco, un JWT, contener scopes, roles o atributos definidos por el proveedor. No diseñes una política de negocio suponiendo que todos los proveedores representarán esos datos de la misma forma. La especificación base de OAuth 2.0 y su BCP de seguridad son el punto de partida.
OpenID Connect añade la pregunta «¿quién inició sesión?»
OAuth por sí solo resuelve autorización delegada: una aplicación puede tener permiso para invocar un recurso sin que eso sea una afirmación de identidad suficiente para la propia aplicación. OIDC añade una capa de autenticación sobre OAuth 2.0 y normaliza descubrimiento, claims e ID token. No es el protocolo histórico OpenID 2.0: son tecnologías distintas con nombres parecidos; hoy, cuando un proveedor ofrece «Sign in with…» sobre OAuth, normalmente habla de OpenID Connect.
Después de un flujo OIDC se pueden recibir tres cosas, cada una con un destinatario y una finalidad:
| Artefacto | Destinatario principal | Para qué sirve |
|---|---|---|
| ID token | El cliente | Afirma que el proveedor autenticó a un usuario y comunica claims como iss, sub, aud y, si se pidieron, perfil o correo. |
| Access token | La API o resource server | Se presenta en Authorization: Bearer … para acceder a recursos protegidos. |
| Refresh token | El cliente autorizado, si el proveedor lo concede | Permite pedir nuevos access tokens sin repetir el login en ciertas condiciones. |
Un ID token tiene como audiencia al cliente que inició sesión; no lo envíes como credencial a la API. Aunque ambos artefactos puedan ser JWT visualmente parecidos, cambian el emisor esperado, la audiencia, las claims y el contrato de seguridad. La especificación OIDC Core exige, entre otras validaciones, comprobar emisor, audiencia, firma y expiración del ID token.
Un inicio de sesión moderno: Authorization Code con PKCE
Para una aplicación nueva en navegador, el flujo recomendado es Authorization Code con PKCE. El navegador recibe un código de corta vida a través de la redirección y el cliente lo canjea en el token endpoint. PKCE vincula ese canje a la instancia que inició el flujo mediante un secreto efímero llamado code_verifier; solo se envía al authorization server su desafío derivado, normalmente con S256.
Este diagrama usa texto preformateado, por lo que no depende de JavaScript ni de un renderizador especial del sitio:
Navegador / SPA Authorization server API de fotos
| | |
|-- /authorize ---------->| |
| response_type=code, state, nonce, |
| code_challenge=S256(verifier), scope |
|<-- login y consentimiento ----------------------------------|
|<-- redirect_uri?code=...&state=... --| |
| | |
|-- /token: code + code_verifier ---->| |
|<-- access_token, id_token (y quizá refresh_token) ---------|
| |
|-- GET /photos/p-42, Authorization: Bearer access_token --->|
|<-- 200, 403 o 401 ------------------------------------------|
Los tres valores aleatorios no son equivalentes:
statevincula la respuesta de autorización con la transacción y la sesión del navegador que la inició; mitiga CSRF y puede transportar estado de navegación protegido contra manipulación.noncevincula una petición OIDC con el ID token que vuelve y ayuda a detectar repetición de esa afirmación de autenticación. El cliente debe comprobarlo cuando lo usa.- PKCE vincula el código al
code_verifiersecreto de la instancia cliente. Evita que quien robe el código pueda canjearlo sin ese valor.
La BCP de OAuth exige PKCE para clientes públicos y recomienda S256; la guía actual para aplicaciones de navegador también favorece Authorization Code con PKCE. No presentamos Implicit ni el intercambio directo de contraseñas como opciones recomendadas para una aplicación nueva: el primero expone tokens en la navegación y el segundo hace que el cliente maneje credenciales que debería manejar el proveedor.
SAML: federación y SSO empresarial
SAML 2.0 es muy común en federación empresarial. Una empresa puede tener un Identity Provider (IdP) corporativo —por ejemplo, el directorio de empleados con MFA— y varias aplicaciones que actúan como Service Providers (SP): nóminas, intranet, soporte y la galería interna. Cuando Ana entra en la galería, el SP redirige al IdP; como Ana ya tiene una sesión SSO en el IdP, este entrega al SP una assertion firmada que afirma su identidad y, según la configuración, atributos o grupos.
Así, Ana no introduce su contraseña en cada aplicación. El SP valida emisor, destinatario, firma, audiencia, condiciones temporales y la correlación de la respuesta antes de crear su propia sesión de aplicación. Las siguientes peticiones de Ana pueden llevar la cookie de esa sesión: no hay que repetir un intercambio SAML en cada carga de página.
| SAML 2.0 | OpenID Connect | |
|---|---|---|
| Formato y entorno habitual | Assertions XML; muy extendido en SSO de aplicaciones empresariales web. | JSON/JWT y endpoints OAuth; cómodo para web, móvil y APIs. |
| Papel típico | Federación IdP–SP y sesión de la aplicación. | Autenticación del cliente y autorización delegada hacia APIs. |
| Decisión práctica | Encaja si la organización o el SP ya usa SAML. | Encaja si se integran clientes modernos y APIs OAuth. |
No es una carrera para «reemplazar» universalmente uno por otro. Las integraciones existentes, los productos SaaS, el modelo de sesión y los requisitos de API determinan la elección. SAML 2.0 Core define las assertions; OIDC cubre una familia distinta de intercambios y perfiles.
¿Hay que preguntar constantemente quién eres?
No necesariamente. Conviene separar el tráfico del login inicial —redirecciones, autenticación, consentimiento y canje de código— de las llamadas normales a la API. Estas últimas no deberían convertir al authorization server en una dependencia síncrona innecesaria, aunque cada diseño tiene su equilibrio.
Un access token puede ser un JWT firmado. La API descarga las claves públicas del emisor desde su JWKS, las guarda en caché y verifica localmente la firma, el algoritmo permitido, iss, aud y exp. Cuando llega un kid desconocido, una biblioteca correcta vuelve a consultar el JWKS para admitir rotación de claves. Tras calentar la caché, la latencia es local y la API puede seguir validando durante una caída breve del proveedor, pero una revocación no se refleja automáticamente en un JWT ya emitido.
También puede ser un token opaco: una referencia sin significado para la API. Esta llama al endpoint de introspection autenticándose como resource server y recibe, por ejemplo, active: true. La consulta permite saber con rapidez si fue revocado, a costa de latencia, disponibilidad y carga del authorization server. Es posible cachear una respuesta positiva o negativa, pero su vida útil define cuánto se retrasa la revocación.
Una tercera posibilidad es una sesión propia de la aplicación: cada petición consulta —o usa una caché de— su almacén de sesiones. Facilita invalidación centralizada, pero hace que ese almacén forme parte del camino crítico. No existe un número universal de llamadas a identidad: depende de si hay JWT u opacos, TTL de cachés, gateway, riesgo aceptable, consistencia requerida y topología.
JWT es un formato de claims, no OAuth. OAuth puede emitir access tokens JWT u opacos; y un JWT puede usarse fuera de OAuth. Un JWKS es simplemente un conjunto de JWKs públicos; la introspección de OAuth especifica la señal active para tokens opacos o centralmente gestionados.
Por qué expiran los tokens
Una expiración corta reduce la ventana de uso de una credencial robada. No evita el robo: un atacante puede usar un token válido hasta que expire. Para no obligar a iniciar sesión cada pocos minutos, el cliente puede renovar mediante un refresh token cuando el proveedor lo conceda. En esa renovación, el servidor puede reevaluar cuenta, cliente, consentimiento y permisos antes de emitir otro access token.
Un cambio de permisos tampoco invalida mágicamente todos los JWT ya emitidos. Hasta su exp, una API que solo hace validación local ve las claims anteriores. Según el riesgo, se combinan expiraciones breves, introspección o listas de revocación para operaciones sensibles, token versioning, cierre de sesiones y reevaluación de políticas en la API.
Los refresh tokens merecen más protección que un access token breve. Una práctica habitual es la rotación: al usar uno se entrega otro y se invalida el anterior; reutilizar uno viejo es una señal de posible robo. La revocación retira explícitamente la validez de una credencial o concesión. El cierre de sesión borra la sesión del cliente y puede notificar al proveedor; la expiración sucede al llegar exp; y la revocación es una decisión del servidor antes de ese momento. Son mecanismos complementarios, no sinónimos.
Ejemplo local reproducible: Keycloak, JavaScript y FastAPI
El ejemplo siguiente es código funcional, no pseudocódigo, si se aplica la configuración indicada. Está deliberadamente pequeño: usa datos en memoria y una política de propiedad sencilla para mostrar las tres comprobaciones que importan: token válido, permiso y recurso. No construye un proveedor OAuth ni implementa criptografía propia.
1. Arrancar y configurar Keycloak
Se puede ejecutar un proveedor local con una imagen fijada; es una configuración de desarrollo, no una receta de producción:
docker run --rm --name photos-keycloak -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0.7 start-dev
En http://localhost:8080/admin/, crea el realm photos y los usuarios alice, bob y reader. Crea el cliente OIDC público photos-spa con Client authentication desactivado, Standard flow activado, PKCE S256, URI de redirección http://localhost:5173/* y Web origin http://localhost:5173. Crea también el cliente photos-api y añade al token de photos-spa un Audience mapper con audiencia photos-api.
Para el ejercicio, crea los client scopes opcionales photos:read y photos:write, y solicita ambos desde la SPA. Asigna a alice y bob el rol de realm photo-editor; deja a reader sin él. En un sistema real, que se emita un scope o rol debe responder a una política del authorization server, no a que el navegador escriba un nombre de scope en una URL. El rol adicional deja visible esa distinción en esta demo.
Keycloak mantiene el adaptador keycloak-js: su documentación indica que un cliente del navegador debe ser público, que el adaptador usa Authorization Code por defecto y que conserva tokens en memoria.
2. Cliente JavaScript público
Instala la biblioteca y sirve el archivo desde http://localhost:5173 con tu herramienta habitual de desarrollo:
npm install keycloak-js
// main.js — código funcional para una SPA de demostración
import Keycloak from "keycloak-js";
const keycloak = new Keycloak({
url: "http://localhost:8080",
realm: "photos",
clientId: "photos-spa",
});
await keycloak.init({
onLoad: "login-required",
scope: "photos:read photos:write",
pkceMethod: "S256",
checkLoginIframe: false,
});
async function api(path, options = {}) {
// Renueva antes de la llamada; si falla, no enviamos una credencial caducada.
await keycloak.updateToken(30);
return fetch(`http://localhost:8000${path}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${keycloak.token}`,
"Content-Type": "application/json",
},
});
}
export async function readPhoto(id) {
return api(`/photos/${id}`);
}
export async function renamePhoto(id, title) {
return api(`/photos/${id}`, {
method: "PATCH",
body: JSON.stringify({ title }),
});
}
No hay ni debe haber client_secret en este código: cualquiera puede leer lo que llega al navegador. Mantener el access token en memoria reduce la persistencia ante un XSS posterior o una copia del perfil, pero no elimina XSS ni protege una página ya comprometida. Al recargar se pierde el estado y habrá que reanudar la sesión con el proveedor. Tampoco registres tokens en consola, analítica, URL o errores.
3. API FastAPI que valida el access token
Instala dependencias con soporte criptográfico de PyJWT:
python -m pip install "fastapi[standard]" "pyjwt[crypto]"
Guarda esto como app.py y ejecútalo con fastapi dev app.py. La audiencia coincide con el Audience mapper de Keycloak; aceptar solo RS256 evita que el encabezado no confiable del token elija el algoritmo.
# app.py — código funcional de demostración; sustituye PHOTOS por una base de datos
from uuid import uuid4
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError, PyJWKClientError
from pydantic import BaseModel
ISSUER = "http://localhost:8080/realms/photos"
AUDIENCE = "photos-api"
ALGORITHMS = ["RS256"]
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
# PyJWKClient almacena JWKS y claves por kid; si llega un kid nuevo vuelve a pedir JWKS.
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)
bearer = HTTPBearer(auto_error=False)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=False,
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
)
PHOTOS: dict[str, dict[str, str]] = {}
class PhotoPatch(BaseModel):
title: str
def unauthorized(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
def current_claims(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> dict:
if credentials is None or credentials.scheme.lower() != "bearer":
raise unauthorized("Missing bearer access token")
try:
signing_key = jwks_client.get_signing_key_from_jwt(credentials.credentials)
return jwt.decode(
credentials.credentials,
signing_key.key,
algorithms=ALGORITHMS,
issuer=ISSUER,
audience=AUDIENCE,
options={"require": ["exp", "iss", "sub", "aud"]},
)
except ExpiredSignatureError:
raise unauthorized("Access token expired")
except (InvalidTokenError, PyJWKClientError):
raise unauthorized("Invalid access token")
def require_scope(required: str):
def dependency(claims: dict = Depends(current_claims)) -> dict:
scopes = set(claims.get("scope", "").split())
if required not in scopes:
raise HTTPException(status_code=403, detail=f"Missing scope: {required}")
return claims
return dependency
def require_editor(claims: dict) -> None:
roles = set(claims.get("realm_access", {}).get("roles", []))
if "photo-editor" not in roles:
raise HTTPException(status_code=403, detail="Editor role required")
@app.post("/photos", status_code=201)
def create_photo(claims: dict = Depends(require_scope("photos:write"))):
require_editor(claims)
photo_id = str(uuid4())
PHOTOS[photo_id] = {"owner_sub": claims["sub"], "title": "Nueva foto"}
return {"id": photo_id, "owner_sub": claims["sub"]}
@app.get("/photos/{photo_id}")
def read_photo(photo_id: str, claims: dict = Depends(require_scope("photos:read"))):
photo = PHOTOS.get(photo_id)
if photo is None:
raise HTTPException(status_code=404, detail="Photo not found")
return {"id": photo_id, "title": photo["title"]}
@app.patch("/photos/{photo_id}")
def update_photo(
photo_id: str,
patch: PhotoPatch,
claims: dict = Depends(require_scope("photos:write")),
):
require_editor(claims)
photo = PHOTOS.get(photo_id)
if photo is None:
raise HTTPException(status_code=404, detail="Photo not found")
if photo["owner_sub"] != claims["sub"]:
raise HTTPException(status_code=403, detail="You do not own this photo")
photo["title"] = patch.title
return {"id": photo_id, "title": photo["title"]}
PyJWKClient resuelve la clave por kid, almacena claves y vuelve a obtener JWKS si no encuentra una; PyJWT documenta ambos comportamientos. La API no confía en un sub decodificado sin verificar la firma, ni acepta algoritmos distintos de los configurados. En producción añade HTTPS, límites, registros sin secretos, una base de datos transaccional y una política explícita para leer fotos privadas; el ejemplo deja la lectura visible para concentrarse en la escritura.
4. Matriz de comprobación manual
Tras iniciar sesión como Alice, crea una foto mediante POST /photos desde la SPA y guarda su id. Estos son los resultados que debe producir la configuración anterior:
| Caso | Petición | Resultado esperado |
|---|---|---|
| Lectura permitida | Alice: GET /photos/{id} con photos:read | 200. |
| Escritura propia | Alice: PATCH /photos/{id} con photos:write y photo-editor | 200. |
| Scope/rol insuficiente | Reader: PATCH /photos/{id} | 403 antes de modificar nada. |
| Foto ajena | Bob: PATCH /photos/{id} con write y editor | 403: el sub no coincide con owner_sub. |
| Audiencia incorrecta | Un access token sin aud: photos-api | 401 durante jwt.decode. |
| Token expirado | Reduce temporalmente el Access Token Lifespan del realm, captura un token y úsalo después de exp, sin llamar a updateToken | 401 Access token expired. |
Para el quinto caso crea un segundo cliente público idéntico pero sin el Audience mapper, inicia sesión en él y llama a la API: el validador no debe aceptar un token emitido para otra audiencia. No confundas este rechazo con 403: la credencial no cumple el contrato de esa API.
Una alternativa especialmente atractiva para aplicaciones web es un BFF (Backend for Frontend): el servidor confidencial realiza el canje de código y guarda tokens del lado servidor; el navegador solo recibe una cookie de sesión HttpOnly, Secure y con SameSite apropiado. Esa cookie reduce la exposición directa de tokens a JavaScript, pero introduce defensas CSRF: validar Origin/Referer cuando corresponda, usar un token antiforgery en operaciones que cambian estado y no abrir CORS ni cookies a cualquier origen.
Facebook: consentimiento no equivale a acceso ilimitado
Facebook es un buen recordatorio de que un scope no opera en el vacío. Sus permisos, el modo de la aplicación, las cuentas que pueden probarla y la revisión de la aplicación condicionan qué datos puede recibir un cliente, incluso tras el consentimiento de la persona. La documentación oficial de user_friends limita el resultado a amistades que también usan la aplicación y han concedido ese permiso; no permite recorrer libremente amigos de amigos. Los permisos que requieren acceso avanzado están sujetos a las políticas y al proceso de App Review.
Por tanto, «el usuario aceptó» no prueba que una API vaya a devolver un dato: el proveedor puede limitarlo, la aplicación puede no estar aprobada y la API del recurso conserva su propia política. Es el mismo principio de nuestra galería: el consentimiento y el token son entradas de la decisión, no sustitutos de ella.
Fuentes y lecturas para contrastar
- OAuth 2.0 (RFC 6749), OAuth 2.0 Security BCP (RFC 9700) y OAuth para aplicaciones de navegador (RFC 10017).
- OpenID Connect Core 1.0 y OpenID Connect Discovery.
- SAML 2.0 Core.
- JWT (RFC 7519), JWK/JWKS (RFC 7517) e introspección de tokens (RFC 7662).
- Keycloak JavaScript adapter, PyJWT: uso de PyJWKClient y FastAPI: seguridad.
- Permiso
user_friendsde Facebook y Facebook App Review.
