La seguridad en APIs REST se basa en tres pilares: almacenamiento seguro de contraseñas, autenticación, y control de acceso.

Almacenamiento seguro de contraseñas

Nunca guardes contraseñas en texto plano. Usa funciones hash unidireccionales que transformen la contraseña en un valor que no puede revertirse.

pwdlib con Argon2

pwdlib es una biblioteca moderna para hash de contraseñas. Usa Argon2id, el algoritmo más seguro actualmente.

uv add "pwdlib[argon2]"

Uso básico:

from pwdlib import PasswordHash
 
pwd_context = PasswordHash.recommended()
 
# Hashear
hashed = pwd_context.hash("mi_contraseña")
 
# Verificar
is_valid = pwd_context.verify("mi_contraseña", hashed)  # True

Integración con modelos

# models.py
from advanced_alchemy.base import BigIntAuditBase
from pwdlib import PasswordHash
 
pwd_context = PasswordHash.recommended()
 
 
class User(BigIntAuditBase):
    __tablename__ = "users"
 
    username: Mapped[str] = mapped_column(unique=True)
    email: Mapped[str] = mapped_column(unique=True)
    hashed_password: Mapped[str]
 
    def verify_password(self, password: str) -> bool:
        return pwd_context.verify(password, self.hashed_password)

Al crear usuarios, hashea la contraseña directamente:

@post("/users/")
async def create_user(data: UserCreate, user_repo: UserRepository) -> dict:
    user = User(
        username=data.username,
        email=data.email,
        hashed_password=pwd_context.hash(data.password),
    )
    created_user = user_repo.add(user)
    return {"id": created_user.id, "username": created_user.username}

Autenticación con JWT

JSON Web Tokens (JWT) permiten autenticar usuarios sin mantener sesiones en el servidor. El cliente envía el token en cada request.

JWT no es encriptación

Los datos en un JWT están codificados en Base64, no encriptados. Nunca incluyas información sensible.

Configuración

uv add "litestar[jwt]"

Define el handler que recupera el usuario desde el token:

# auth.py
import os
from datetime import timedelta
from litestar.security.jwt import JWTAuth, Token
from litestar.connection import ASGIConnection
 
 
async def retrieve_user_handler(token: Token, connection: ASGIConnection) -> User | None:
    from app.repositories import provide_user_repo
    from app.db import get_session
 
    db_session = await get_session(connection)
    user_repo = await provide_user_repo(db_session)
 
    try:
        return user_repo.get(int(token.sub))
    except Exception:
        return None
 
 
jwt_auth = JWTAuth[User](
    retrieve_user_handler=retrieve_user_handler,
    token_secret=os.environ.get("JWT_SECRET", "change-in-production"),
    default_token_expiration=timedelta(hours=1),
    exclude=["/login", "/register", "/schema", "/docs"],
)

¿Qué es JWT_SECRET?

El JWT_SECRET es una clave secreta que se usa para firmar los tokens JWT. Esta firma garantiza que el token no ha sido modificado. Si alguien intenta alterar el contenido del token, la firma no coincidirá y será rechazado. Nunca compartas este secret y usa uno diferente para cada entorno (desarrollo, producción).

Generar secrets seguros

Usa este comando para generar una clave criptográficamente segura:

python -c "import secrets; print(secrets.token_urlsafe(32))"

Guárdala en una variable de entorno, nunca en el código fuente.

Registra en la app:

app = Litestar(
    route_handlers=[...],
    on_app_init=[jwt_auth.on_app_init],
)

Endpoint de login

from litestar import Controller, Response, post
from litestar.exceptions import NotAuthorizedException
 
 
class AuthController(Controller):
    path = "/auth"
 
    @post("/login")
    async def login(self, data: LoginData, user_repo: UserRepository) -> Response[User]:
        try:
            user = user_repo.get_one(username=data.username)
        except Exception:
            raise NotAuthorizedException("Credenciales inválidas")
 
        if not user.verify_password(data.password):
            raise NotAuthorizedException("Credenciales inválidas")
 
        return jwt_auth.login(identifier=str(user.id), response_body=user)

El token se retorna en el header Authorization: Bearer <token>.

Rutas protegidas

Accede al usuario autenticado con request.user:

@get("/profile")
async def get_profile(request: Request[User, Token, None]) -> dict:
    return {"id": request.user.id, "username": request.user.username}

Las rutas no incluidas en exclude requieren autenticación automáticamente.

JWTCookieAuth (opcional)

Para usar cookies en lugar de headers Authorization:

from litestar.security.jwt import JWTCookieAuth
 
jwt_cookie_auth = JWTCookieAuth[User](
    retrieve_user_handler=retrieve_user_handler,
    token_secret=os.environ.get("JWT_SECRET", "secret"),
    default_token_expiration=timedelta(hours=1),
    exclude=["/login", "/register", "/schema", "/docs"],
    auth_cookie_httponly=True,  # Protección contra XSS
    auth_cookie_secure=True,  # Solo HTTPS
    auth_cookie_samesite="lax",  # Protección CSRF
)

Las cookies son más seguras (HttpOnly previene XSS) pero requieren configuración CSRF adicional.

Control de acceso con Guards

Los guards son funciones que verifican permisos antes de ejecutar un handler. Si lanzan una excepción, la request es rechazada.

Guard básico

from litestar.connection import ASGIConnection
from litestar.handlers.base import BaseRouteHandler
from litestar.exceptions import NotAuthorizedException
 
 
async def admin_guard(connection: ASGIConnection, handler: BaseRouteHandler) -> None:
    if connection.user.role != "admin":
        raise NotAuthorizedException("Se requiere rol de administrador")

Uso

Aplica guards a endpoints, controladores o globalmente:

# En un endpoint
@get("/admin/users", guards=[admin_guard])
async def list_users() -> list[User]: ...
 
 
# En un controlador (aplica a todos los endpoints)
class AdminController(Controller):
    path = "/admin"
    guards = [admin_guard]
    ...
 
 
# Globalmente (aplica a toda la app)
app = Litestar(route_handlers=[...], guards=[admin_guard])

Guards con parámetros dinámicos

Usa handler.opt para pasar datos al guard:

async def permission_guard(connection: ASGIConnection, handler: BaseRouteHandler) -> None:
    required = handler.opt.get("permission")
    if required and not connection.user.has_permission(required):
        raise NotAuthorizedException(f"Se requiere permiso: {required}")
 
 
@get("/reports", guards=[permission_guard], opt={"permission": "view_reports"})
async def get_reports() -> dict: ...

CORS (Cross-Origin Resource Sharing)

CORS permite que tu API acepte requests desde dominios diferentes. Necesario cuando el frontend y backend están en dominios separados.

from litestar import Litestar
from litestar.config.cors import CORSConfig
 
cors_config = CORSConfig(
    allow_origins=["https://mi-frontend.com"],  # Producción: dominios específicos
    # allow_origins=["http://localhost:3000"],  # Desarrollo
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    allow_credentials=True,  # Permite cookies
)
 
app = Litestar(route_handlers=[...], cors_config=cors_config)

Uso de allow_origins=["*"]

Es posible usar allow_origins=["*"] para permitir peticiones desde cualquier origen. Esta configuración puede ser útil durante el desarrollo, pero nunca debe usarse en producción ya que permite que cualquier sitio web consuma tu API sin restricciones, facilitando ataques de phishing y uso no autorizado.

Configuración centralizada

Para gestionar todas las configuraciones de tu aplicación (JWT secrets, URLs de base de datos, configuraciones de CORS, etc.) en un solo lugar, usa pydantic-settings. Esta biblioteca permite cargar configuraciones desde variables de entorno o archivos .env.

uv add pydantic-settings

Definir configuraciones

# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
 
 
class Settings(BaseSettings):
    """Configuración centralizada de la aplicación."""
 
    # Seguridad
    jwt_secret: str
    jwt_expiration_hours: int = 24
 
    # Base de datos
    database_url: str
 
    # CORS
    cors_allowed_origins: list[str] = ["http://localhost:3000"]
 
    # Configuración del modelo
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )
 
 
# Instancia única de configuración
settings = Settings()

Archivo .env

Crea un archivo .env en la raíz del proyecto:

JWT_SECRET=tu-secret-super-seguro-generado-con-secrets
DATABASE_URL=postgresql://user:password@localhost/dbname
CORS_ALLOWED_ORIGINS=["https://mi-app.com","https://www.mi-app.com"]

Uso en la aplicación

Importa y usa settings en cualquier parte de tu código:

# auth.py
from app.config import settings
 
jwt_auth = JWTAuth[User](
    retrieve_user_handler=retrieve_user_handler,
    token_secret=settings.jwt_secret,  # Desde configuración centralizada
    default_token_expiration=timedelta(hours=settings.jwt_expiration_hours),
    exclude=["/login", "/register", "/schema", "/docs"],
)
# __init__.py
from app.config import settings
 
cors_config = CORSConfig(
    allow_origins=settings.cors_allowed_origins,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    allow_credentials=True,
)
 
app = Litestar(route_handlers=[...], cors_config=cors_config)

Ventajas de configuración centralizada

  • Un solo lugar para todas las configuraciones
  • Validación automática de tipos con Pydantic
  • Valores por defecto para desarrollo
  • Fácil cambio entre entornos (desarrollo, staging, producción)