Guía para Principiantes de Diseño de Seguridad Serio con FastAPI: Autenticación y Autorización — JWT/OAuth2, Cookies de Sesión, RBAC/Scopes, Protección CSRF y Trampas del Mundo Real
Resumen (visión general)
- Almacena contraseñas como hashes; mantén los tokens de acceso de corta duración y los tokens de refresco de larga duración + almacenados de forma segura como regla general.
- Para SPAs o móvil, usa JWT (Bearer); para aplicaciones web centradas en navegador, Cookies + protección CSRF son una buena combinación.
- Separa la autorización en roles (RBAC) y scopes, e incrusta decisiones usando
Security
/Depends
de FastAPI. - Implementa contramedidas concretas contra amenazas comunes: fijación de sesión, reutilización de tokens, XSS, CSRF, contraseñas débiles, etc.
- Incluye pruebas, rotación, gestión de claves y registros de auditoría para construir un sistema listo para operaciones.
Quién se beneficia
- Aprendiz A (tesis / proyecto en solitario): quiere una configuración mínima y fiable para formulario de autenticación y JWT.
- Equipo pequeño B (desarrollo por contrato de 3 personas): no está seguro sobre operaciones con Cookies para API y panel de administración; quiere finalizar diseño de CSRF y SameSite.
- Equipo SaaS C (startup): quiere límites claros con RBAC + scopes, y ejecutar rotación y revocación de tokens en producción.
Evaluación de Accesibilidad
- Puntos clave organizados con encabezados y listas; el código usa bloques monoespaciados con comentarios cortos para legibilidad.
- Los términos técnicos incluyen una breve explicación en su primera mención. Puntos de decisión al final de secciones ayudan a personas con lectores de pantalla a seguir el flujo.
- Nivel general: aproximadamente AA.
1. Política fundamental: descomponer Autenticación, Autorización y Sesión
- Autenticación: verificar quién es la persona usuaria (contraseñas, SAML/OIDC, claves de API, etc.)
- Autorización: determinar qué puede hacer (RBAC = roles, scopes = unidades funcionales)
- Sesión: mecanismo para persistir el estado autenticado (JWT, Cookies, sesiones de servidor)
Puntos de decisión
- Si el front-end es SPA/móvil, elige JWT (acceso de corta duración; guarda el refresh con seguridad).
- Si es principalmente web de mismo origen, Cookies de sesión son naturales. Aplica siempre HttpOnly/Secure/SameSite y protección CSRF.
- Usa roles para límites amplios; protege endpoints de API con scopes para controles finos.
2. Modelo de usuario y hashing de contraseñas
2.1 Ejemplo de modelo (SQLAlchemy 2.x)
# app/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Boolean
from typing import Optional
class Base(DeclarativeBase): pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(default=True)
role: Mapped[str] = mapped_column(String(50), default="user") # p. ej., admin/user
2.2 Hashing y verificación
# app/security/passwords.py
from passlib.context import CryptContext
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_ctx.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_ctx.verify(plain, hashed)
Puntos de decisión
- Nunca almacenes texto plano. Usa hashes intencionalmente lentos como
bcrypt
oargon2
. - Habilita política de contraseñas (longitud, clases de caracteres, comprobación de brechas) en la validación.
3. Implementación mínima de JWT (Bearer)
3.1 Ajustes y gestión de claves
# app/core/settings.py
from pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
jwt_secret: str = Field(..., description="Clave secreta HS256")
jwt_issuer: str = "example-api"
access_token_expires_minutes: int = 15
refresh_token_expires_days: int = 30
class Config:
env_file = ".env"
extra = "ignore"
def get_settings():
return Settings()
3.2 Emisión y verificación (usando PyJWT / libs tipo JOSE)
# app/security/jwt.py
from datetime import datetime, timedelta, timezone
import jwt # pyjwt
from typing import Any, Dict
from app.core.settings import get_settings
ALGO = "HS256"
def create_token(sub: str, scope: str, expires_delta: timedelta, token_type: str) -> str:
s = get_settings()
now = datetime.now(timezone.utc)
payload: Dict[str, Any] = {
"iss": s.jwt_issuer,
"sub": sub,
"scope": scope, # p. ej., "articles:read users:write" (separado por espacios)
"type": token_type, # "access" o "refresh"
"iat": int(now.timestamp()),
"exp": int((now + expires_delta).timestamp())
}
return jwt.encode(payload, s.jwt_secret, algorithm=ALGO)
def decode_token(token: str) -> Dict[str, Any]:
s = get_settings()
return jwt.decode(token, s.jwt_secret, algorithms=[ALGO], options={"require": ["exp","sub","type"]})
3.3 Router de autenticación (login / refresh)
# app/routers/auth.py
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from datetime import timedelta
from app.security.passwords import verify_password
from app.security.jwt import create_token, decode_token
from app.core.settings import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: str
password: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Función temporal de búsqueda en DB
def get_user_by_email(email: str):
# Implementación omitida. Devuelve un usuario que incluya password_hash
...
@router.post("/login", response_model=TokenPair)
def login(payload: LoginRequest):
user = get_user_by_email(payload.email)
if not user or not verify_password(payload.password, user.password_hash) or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
s = get_settings()
access = create_token(
sub=str(user.id),
scope="articles:read users:read", # ensamblar dinámicamente según necesidad
expires_delta=timedelta(minutes=s.access_token_expires_minutes),
token_type="access"
)
refresh = create_token(
sub=str(user.id),
scope="refresh",
expires_delta=timelta(days=s.refresh_token_expires_days),
token_type="refresh"
)
return TokenPair(access_token=access, refresh_token=refresh)
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenPair)
def refresh(req: RefreshRequest):
data = decode_token(req.refresh_token)
if data.get("type") != "refresh":
raise HTTPException(401, "invalid token type")
s = get_settings()
access = create_token(sub=data["sub"], scope="articles:read users:read",
expires_delta=timedelta(minutes=s.access_token_expires_minutes), token_type="access")
# En producción, rota los refresh tokens (reemitir + invalidar el anterior)
new_refresh = create_token(sub=data["sub"], scope="refresh",
expires_delta=timedelta(days=s.refresh_token_expires_days), token_type="refresh")
return TokenPair(access_token=access, refresh_token=new_refresh)
Puntos de decisión
- Los tokens de acceso son de corta duración; los de refresco deben soportar rotación y listas negras para invalidar fugas.
- Los scopes se separan por espacios. Mantén un esquema consistente como recurso:verbo (
articles:read
).
4. Autorización: combinando RBAC y Scopes
4.1 Decodificador y verificación de scopes
# app/security/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Sequence
from app.security.jwt import decode_token
from app.models import User
bearer = HTTPBearer(auto_error=False)
def get_current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> User:
if creds is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing token")
data = decode_token(creds.credentials)
# Recuperar usuario de DB (omitido) y verificar estado activo
...
return user
def require_scopes(required: Sequence[str]):
def checker(creds: HTTPAuthorizationCredentials = Depends(bearer)):
if creds is None:
raise HTTPException(401, "missing token")
data = decode_token(creds.credentials)
token_scopes = set(str(data.get("scope","")).split())
if not set(required).issubset(token_scopes):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="insufficient scope")
return data
return checker
4.2 Aplicarlo a rutas
# app/routers/articles.py
from fastapi import APIRouter, Depends
from app.security.deps import get_current_user, require_scopes
router = APIRouter(prefix="/articles", tags=["articles"])
@router.get("", dependencies=[Depends(require_scopes(["articles:read"]))])
def list_articles():
return [{"id": 1, "title": "Hello"}]
@router.post("", dependencies=[Depends(require_scopes(["articles:write"]))])
def create_article():
return {"ok": True}
Puntos de decisión
- Las rutas se mantienen legibles si declaras solo los scopes requeridos ahí.
- Mapea roles → scopes al emitir tokens, o traduce roles a scopes en el servidor.
5. Cookies de sesión + CSRF (token doble)
Para apps web centradas en navegador, una cookie de sesión HttpOnly
es conveniente. Puedes almacenar estado del lado servidor o poner un JWT en una cookie (pero siempre usa HttpOnly/Secure/SameSite).
5.1 Guardar en una cookie (p. ej., token de acceso)
# app/routers/web_auth.py
from fastapi import APIRouter, Response, HTTPException, Depends
from pydantic import BaseModel
from datetime import timedelta
from app.security.passwords import verify_password
from app.security.jwt import create_token, decode_token
from app.core.settings import get_settings
router = APIRouter(prefix="/web", tags=["web-auth"])
class WebLogin(BaseModel):
email: str
password: str
@router.post("/login")
def web_login(payload: WebLogin, response: Response):
user = ... # omitido
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(401, "invalid credentials")
s = get_settings()
access = create_token(str(user.id), "articles:read", timedelta(minutes=s.access_token_expires_minutes), "access")
# `HttpOnly` evita lecturas desde JS; `Secure` restringe a HTTPS; configura SameSite según el caso
response.set_cookie("access_token", access, httponly=True, secure=True, samesite="Lax", max_age=60*15, path="/")
# Distribuye un `XSRF-TOKEN` en una cookie separada (JS la lee y la envía en una cabecera)
response.set_cookie("XSRF-TOKEN", "random-csrf", httponly=False, secure=True, samesite="Lax", path="/")
return {"ok": True}
5.2 Verificación CSRF (cookie de doble envío)
# app/middlewares/csrf.py
from fastapi import Request, HTTPException, status
async def verify_csrf(request: Request):
if request.method in ("POST","PUT","PATCH","DELETE"):
cookie = request.cookies.get("XSRF-TOKEN")
header = request.headers.get("X-CSRF-TOKEN")
if not cookie or not header or cookie != header:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="csrf failed")
Aplicación de ejemplo:
# app/routers/protected_web.py
from fastapi import APIRouter, Depends
from app.middlewares.csrf import verify_csrf
router = APIRouter(prefix="/web-api", dependencies=[Depends(verify_csrf)])
@router.post("/profile")
def update_profile():
return {"ok": True}
Puntos de decisión
- HttpOnly ayuda a evitar la lectura del token pero no previene CSRF—protégelo por separado.
SameSite="Lax"
es un valor por defecto sensato. Para necesidades cross-site, usaNone
+ HTTPS.
6. Operaciones de refresh y revocación
- Para abordar refresh tokens robados, implementa rotación (reemitir) e invalida el token previamente emitido mediante una tabla de gestión.
- En logout explícito, añade el jti del access token (ID del token) y los refresh tokens a una lista negra.
Ejemplo (conceptual):
# app/security/revoke.py
# Mantén un almacén de revoked_tokens (por jti o hash de firma); tras decodificar, comprueba y rechaza si está presente
Puntos de decisión
- Usar un almacén con TTL como Redis simplifica la implementación.
- Detecta reutilización de refresh (refresh antiguo usado otra vez tras la rotación) y alerta.
7. Registro de auditoría y limitación de tasa
- Emite logs de auditoría estructurados para eventos clave (login éxito/fallo, cambio de contraseña, cambio de permisos, reemisión de tokens).
- Limita la tasa de intentos de login (IP × usuario) y añade demora para suprimir fuerza bruta.
Ejemplo: reutiliza una implementación de “token-bucket” de tu toolkit de caché.
8. Estrategia de pruebas (perspectiva de seguridad)
- Unit tests para verificación de contraseñas y rechazo de contraseñas débiles.
- E2E:
/auth/login
→ API protegida; prueba expiración, manipulación y casos de scope incorrecto. - CSRF: asegura que discrepancia cookie/cabecera devuelve 403.
- Revocación: los tokens en lista negra deben ser rechazados.
- Auditoría: verifica que los eventos se registren sin huecos.
9. Trampas comunes y contramedidas
Síntoma | Causa | Contramedida |
---|---|---|
Tokens robados por JS | Almacenamiento en localStorage + XSS | Cookies HttpOnly, CSP, sanitización, auditorías XSS |
CSRF exitoso | Autenticación solo por cookies, sin defensa CSRF | Cookie doble, SameSite, comprobaciones Origin/Referer |
Expansión de scopes | Parseo de strings laxo | Trata scopes como conjunto y compara estrictamente |
Abuso de refresh | Vida muy larga + sin rotación | Rotación + lista negra; IDs por dispositivo y revocación |
Fuga en rutas | Tokens de acceso en logs | Enmascara cabeceras de auth; omite PII |
10. Boilerplate: app mínima extremo a extremo
# app/main.py
from fastapi import FastAPI
from app.routers import auth, articles, web_auth, protected_web
app = FastAPI(title="Secure API")
# APIs para JWT (Bearer)
app.include_router(auth.router)
app.include_router(articles.router)
# APIs para Web (Cookie + CSRF)
app.include_router(web_auth.router)
app.include_router(protected_web.router)
@app.get("/health")
def health():
return {"ok": True}
11. Hoja de ruta de adopción
- Implementa hashing de contraseñas y el modelo de usuario; pon a funcionar el JWT mínimo en
/auth/login
. - Protege endpoints con scopes vía
require_scopes
. - Agrega rotación de refresh, lista negra, auditoría y rate limiting.
- Introduce Cookie + CSRF para web; verifica
HttpOnly/Secure/SameSite
. - Operacionaliza rotación de claves, separación de entornos y tableros de auditoría.
12. Referencias
- FastAPI
- JWT / OAuth2 / Bearer
- Librerías de Python
- Seguridad Web
Conclusión
- Con JWT, usa tokens de acceso de corta duración más tokens de refresco rotables. Aclara la autorización con roles y scopes; declarar solo los requisitos mínimos en cada ruta mejora la mantenibilidad.
- En la web, Cookies + CSRF son efectivos. Aplica el trío
HttpOnly/Secure/SameSite
, y superpone defensas con el token de doble envío. - Incorpora revocación, limitación de tasa, auditoría y rotación de claves/tokens en operaciones, y verifica continuamente la seguridad con pruebas. Empieza pequeño hoy y expande de forma constante tu zona segura.