DEV Community

Cover image for LLPY-10: Autenticación JWT con RSA - Seguridad Stateless
Jesus Oviedo Riquelme
Jesus Oviedo Riquelme

Posted on

LLPY-10: Autenticación JWT con RSA - Seguridad Stateless

🎯 El Desafío de la Autenticación en APIs

Imagina que tu API RAG está en producción:

  • ✅ Endpoints públicos: /api/health, /api/rag/ask
  • ⚠️ Endpoints sensibles: /api/vectorstore/load, /api/status (detalles completos)
  • ⚠️ Endpoints administrativos: /api/vectorstore/delete

El problema: ¿Cómo proteges los endpoints sensibles sin comprometer la performance o escalabilidad?

Requisitos de Autenticación

  1. 🔒 Seguro: Tokens no falsificables
  2. ⚡ Rápido: Validación en <5ms
  3. 📈 Escalable: Sin estado compartido entre instancias
  4. 🔄 Stateless: No requiere base de datos de sesiones
  5. 🌐 Estándar: Compatible con cualquier cliente HTTP
  6. 🔑 Rotable: Cambio de claves sin downtime
  7. ⏰ Temporal: Tokens con expiración automática

Opciones de Autenticación

Método Pros Contras Escalabilidad
Session cookies Familiar, fácil Requiere DB/Redis Limitada ⚠️
API Keys Simple No expira, difícil rotación Media ⚠️
OAuth 2.0 Estándar, robusto Complejo setup Alta ✅
JWT (HS256) Stateless, rápido Secret compartido Alta ✅
JWT (RS256) Stateless, secure Requiere key pair Muy Alta ✅✅

Nuestra elección: JWT con RS256 (RSA)

📊 La Magnitud del Problema

Desafíos Técnicos

  1. 🔐 Generación de Claves: ¿Cómo generar par RSA seguro?
  2. 📝 Claims Management: ¿Qué información incluir en el token?
  3. ✅ Validación: ¿Cómo validar firma sin secret compartido?
  4. 🔄 Distribución: ¿Cómo distribuir public key a la API?
  5. ⏰ Expiración: ¿Cómo manejar tokens expirados?
  6. 🔁 Renovación: ¿Cómo implementar refresh tokens?
  7. 🚫 Revocación: ¿Cómo invalidar tokens antes de expiración?

JWT vs Traditional Sessions

Traditional Sessions:

Client → API → Check session in Redis/DB (20-50ms)
         ↓
       Response

Scaling: N servers = N connections to Redis
Cost: Redis instance + network latency
SPOF: Redis down = all auth fails
Enter fullscreen mode Exit fullscreen mode

JWT (Stateless):

Client → API → Validate signature locally (<5ms)
         ↓
       Response

Scaling: N servers = 0 shared state
Cost: CPU for validation (negligible)
SPOF: None (cada server valida independientemente)
Enter fullscreen mode Exit fullscreen mode

💡 La Solución: JWT con RSA (RS256)

¿Qué es JWT?

JWT (JSON Web Token) es un estándar (RFC 7519) para tokens de acceso:

  • Header: Algoritmo y tipo
  • Payload: Claims (datos del usuario)
  • Signature: Firma criptográfica

¿Por Qué RS256 (RSA)?

HS256 (HMAC) vs RS256 (RSA):

Aspecto HS256 RS256
Algoritmo HMAC + SHA256 (symmetric) RSA + SHA256 (asymmetric)
Claves 1 secret compartido Private + Public key
Seguridad Secret debe estar en todos los servers Solo public key en API
Compromiso Si leak = regenerar tokens de todos Si leak public key = no importa
Uso Pequeña escala, single app Multi-service, microservices
Nuestra elección RS256

RS256 permite:

  • Private key en un solo lugar (token generation service)
  • Public key en múltiples APIs (stateless validation)
  • Zero trust: APIs solo validan, nunca generan tokens

🚀 Implementación Paso a Paso

1. Generación de Claves RSA

El script utils/generate_jwt_keys.sh genera el par de claves RSA necesarias para JWT:

¿Qué hace el script?

  1. Genera clave privada RSA de 2048 bits con openssl genrsa
  2. Extrae clave pública de la privada con openssl rsa -pubout
  3. Establece permisos seguros:
    • Private key: 600 (solo owner puede leer/escribir)
    • Public key: 644 (todos pueden leer)
  4. Guarda en directorio keys/

Ejecutar:

cd utils
chmod +x generate_jwt_keys.sh
./generate_jwt_keys.sh
Enter fullscreen mode Exit fullscreen mode

Output del script:

[INFO] Generando clave privada RSA de 2048 bits...
[SUCCESS] Clave privada generada: keys/private_key.pem
[INFO] Generando clave pública RSA...
[SUCCESS] Clave pública generada: keys/public_key.pem
[SUCCESS] Permisos de archivos configurados correctamente

Información de las claves generadas:
==================================
Clave Privada:
  Archivo: keys/private_key.pem
  Tamaño: 1.7K
  Permisos: -rw------- (600)

Clave Pública:
  Archivo: keys/public_key.pem
  Tamaño: 451B
  Permisos: -rw-r--r-- (644)

Notas importantes:
  - La clave privada debe mantenerse segura y no compartirse
  - La clave pública puede ser compartida para validación de tokens
  - NO versionar private_key.pem en Git
Enter fullscreen mode Exit fullscreen mode

Estructura generada:

keys/
├── private_key.pem    # 🔒 Para generar tokens (NUNCA compartir)
└── public_key.pem     # 🔓 Para validar tokens (OK compartir)
Enter fullscreen mode Exit fullscreen mode

Formato de las claves:

-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA2Z7X3Y+9QvH5xK...
-----END RSA PRIVATE KEY-----

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ...
-----END PUBLIC KEY-----
Enter fullscreen mode Exit fullscreen mode

2. Generación de Tokens JWT

El script utils/generate_jwt_token.py usa las claves RSA para generar tokens:

¿Qué hace el script?

  1. Carga la clave privada desde keys/private_key.pem
  2. Construye payload con claims estándar:
    • sub: username
    • iss: "lus-laboris-api" (issuer)
    • aud: "lus-laboris-client" (audience)
    • exp: timestamp de expiración
    • iat: timestamp de creación
  3. Firma con RS256 usando la clave privada
  4. Retorna token en formato JWT

Uso del script:

# Generar token (expira en 15 minutos por defecto)
python utils/generate_jwt_token.py --username admin@example.com

# Con expiración custom (1440 min = 24 horas)
python utils/generate_jwt_token.py --username admin@example.com --expiry 1440

# Con claims adicionales
python utils/generate_jwt_token.py --username admin@example.com \
  --claims '{"role": "admin", "permissions": ["read", "write"]}'

# Validar token
python utils/generate_jwt_token.py --validate --token "eyJhbGc..."
Enter fullscreen mode Exit fullscreen mode

Output del script:

ℹ️  Clave privada cargada desde: keys/private_key.pem
✅ Token generado exitosamente para usuario: admin@example.com
ℹ️  Token expira en: 2025-10-18 15:30:00 UTC (15 minutos)

🔑 Token JWT Generado Exitosamente!
==================================================
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWI...
==================================================

📖 Instrucciones de Uso:
1. Usa este token en el header Authorization:
   Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI...

2. Prueba el token con:
   python generate_jwt_token.py --validate --token "eyJhbGc..."
Enter fullscreen mode Exit fullscreen mode

Estructura de un Token JWT:

El token tiene 3 partes separadas por .:

[Header].[Payload].[Signature]

Header (base64):
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload (base64):
{
  "sub": "admin@example.com",      // Usuario
  "iss": "lus-laboris-api",        // Emisor
  "aud": "lus-laboris-client",     // Audiencia
  "exp": 1729271800,               // Expira (Unix timestamp)
  "iat": 1729268200                // Creado (Unix timestamp)
}

Signature:
RS256(header + "." + payload, private_key) // Firma RSA
Enter fullscreen mode Exit fullscreen mode

Claims importantes:

  • sub: Identifica al usuario
  • iss/aud: Previenen uso del token en otros sistemas
  • exp: Auto-expiración (no requiere revocación manual)
  • iat: Para auditoría

3. Validación en FastAPI (JWTValidator)

La clase JWTValidator en jwt_handler.py se encarga de validar tokens:

¿Qué hace JWTValidator?

  1. Carga la clave pública al inicializar

    • Lee desde settings.api_jwt_public_key_path
    • Típicamente: keys/public_key.pem
    • Falla temprano si el archivo no existe
  2. Método validate_token() - El corazón de la validación

    • Decodifica el token usando la public key
    • Valida signature con algoritmo RS256
    • Verifica claims: exp, iat, iss, aud
    • Retorna payload si todo es válido
    • Lanza ValueError si algo falla
  3. Método is_token_valid() - Helper no-exception

    • Retorna True si válido, False si inválido
    • No lanza excepciones (para checks silenciosos)

Validaciones automáticas:

  • Expiration (exp): Token no expirado
  • Issued At (iat): Token ya fue emitido (no futuro)
  • Issuer (iss): Coincide con settings.api_jwt_iss
  • Audience (aud): Coincide con settings.api_jwt_aud
  • Signature: Firmado con la private key correcta

Errores manejados:

  • jwt.ExpiredSignatureError → "Token has expired"
  • jwt.InvalidAudienceError → "Invalid audience"
  • jwt.InvalidIssuerError → "Invalid issuer"
  • jwt.InvalidTokenError → "Invalid token"

Performance:

  • Validación completa: <5ms (CPU-bound, muy rápido)
  • No requiere llamadas externas (DB, Redis, etc.)

4. Security Dependencies para FastAPI

El archivo src/lus_laboris_api/api/auth/security.py proporciona las funciones de autenticación:

¿Qué contiene security.py?

  1. SecurityManager class

    • Centraliza la lógica de autenticación
    • Valida tokens JWT
    • Maneja errores de autenticación
  2. get_current_user()

    • Dependency que requiere JWT válido
    • Extrae payload del token
    • Retorna información del usuario
  3. optional_auth()

    • Dependency con autenticación opcional
    • Retorna dict si hay token válido
    • Retorna None si no hay token (no bloquea acceso)
  4. require_admin()

    • Dependency para operaciones administrativas
    • Requiere token JWT válido
  5. require_vectorstore_write() y require_vectorstore_read()

    • Dependencies para operaciones del vectorstore
    • Requieren token JWT válido

Uso en endpoints:

from ..auth.security import get_current_user, optional_auth, require_admin

# Endpoint protegido (requiere JWT)
@router.get("/protected")
async def protected_endpoint(user: dict = Depends(get_current_user)):
    return {"message": f"Hello {user['sub']}"}

# Endpoint con auth opcional
@router.get("/health/detailed")
async def detailed_health(user: dict | None = Depends(optional_auth)):
    if user:
        return {"status": "healthy", "details": "...", "user": user['sub']}
    else:
        return {"status": "healthy"}

# Endpoint admin (requiere JWT)
@router.delete("/collections/{name}")
async def delete_collection(name: str, user: dict = Depends(require_admin)):
    return {"deleted_by": user['sub']}
Enter fullscreen mode Exit fullscreen mode

5. Swagger UI Integration

La autenticación JWT se integra automáticamente en Swagger UI gracias a HTTPBearer:

Flujo en Swagger:

  1. Acceder a Swagger UI: http://localhost:8000/docs
  2. Click en "Authorize" 🔓 (botón en la parte superior)
  3. Ingresar token: Pegar el token JWT en el campo "Value"
  4. Click "Authorize": El token se guarda en la sesión
  5. Probar endpoints: Ahora puedes llamar endpoints protegidos

Características:

  • Auto-detecta endpoints con Depends(get_current_user)
  • Candado visible 🔒 en endpoints protegidos
  • Botón unlock para ingresar token
  • Persistencia: Token se mantiene mientras Swagger esté abierto
  • Logout: Click en "Logout" para borrar token

Ejemplo visual en Swagger:

GET /api/rag/ask                   [Sin candado] ← Público
GET /api/health                    [Sin candado] ← Público
GET /api/status                    🔒 [Authorize] ← Protegido
DELETE /api/vectorstore/delete     🔒 [Authorize] ← Protegido
Enter fullscreen mode Exit fullscreen mode

🔐 Integración con GCP Secret Manager

1. Almacenar Public Key en Secret Manager

# Crear secret con la public key
gcloud secrets create jwt-public-key \
    --data-file=keys/public_key.pem \
    --project=tu-proyecto-gcp \
    --replication-policy="automatic"

# Verificar
gcloud secrets versions list jwt-public-key --project=tu-proyecto-gcp
Enter fullscreen mode Exit fullscreen mode

2. Montar Secret en Cloud Run

En el deployment de Cloud Run:

gcloud run deploy lus-laboris-api \
  --image=tu-imagen \
  --update-secrets="/app/secrets/jwt/public_key.pem=jwt-public-key:latest" \
  --set-env-vars="API_JWT_PUBLIC_KEY_PATH=/app/secrets/jwt/public_key.pem"
Enter fullscreen mode Exit fullscreen mode

3. GitHub Actions Workflow

- name: Update JWT Public Key Secret
  run: |
    echo "${JWT_PUBLIC_KEY}" | \
      gcloud secrets versions add jwt-public-key \
      --data-file=- \
      --project=${GCP_PROJECT_ID}

    echo "✅ JWT public key secret updated"
Enter fullscreen mode Exit fullscreen mode

🎯 Casos de Uso Reales

Para APIs Públicas con Endpoints Protegidos:

"Quiero que /health sea público pero /status sea protegido"

Solución:

@router.get("/health")
async def health():
    """Public - no auth"""
    return {"status": "healthy"}

@router.get("/status")
async def status(user: dict = Depends(get_current_user)):
    """Protected - JWT required"""
    return {
        "status": "healthy",
        "details": {...},  # Info sensible
        "requested_by": user["sub"]
    }
Enter fullscreen mode Exit fullscreen mode

Para Integración con Aplicaciones Externas:

"Otra aplicación necesita cargar datos al vectorstore"

Solución:

# Generar token para la aplicación externa
python generate_jwt_token.py generate \
  --username external-app@company.com \
  --expiry 10080  # 7 días

# Compartir token (de forma segura)
# La app externa puede hacer requests:
curl -X POST http://api/vectorstore/load \
  -H "Authorization: Bearer eyJhbGc..." \
  -H "Content-Type: application/json" \
  -d '{"filename": "data.json"}'
Enter fullscreen mode Exit fullscreen mode

Para Diferentes Niveles de Acceso:

"Admin puede delete, user puede read"

Solución:

def require_admin(user: dict = Depends(get_current_user)) -> dict:
    """Dependency que requiere rol admin"""
    if user.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    return user

@router.delete("/collections/{name}")
async def delete_collection(
    name: str,
    admin: dict = Depends(require_admin)
):
    """Solo admins pueden eliminar"""
    ...
Enter fullscreen mode Exit fullscreen mode

Para Debugging y Testing:

"Quiero probar endpoints protegidos en desarrollo"

Solución:

# Generar token de desarrollo (expira en 24 horas)
python generate_jwt_token.py generate \
  --username dev@localhost \
  --expiry 1440 > dev_token.txt

# Usar en requests
export TOKEN=$(cat dev_token.txt)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/status
Enter fullscreen mode Exit fullscreen mode

🚀 El Impacto Transformador

Antes de JWT:

  • 🔑 Session management: Redis/DB requerido para sesiones
  • 📊 Scaling issues: N servers = N connections to session store
  • ⏱️ Latency: 20-50ms para check session
  • 💰 Cost: Redis instance ($50-100/mes)
  • 🚨 SPOF: Redis down = all auth fails

Después de JWT (RS256):

  • 🔑 Stateless: Cero dependencias externas
  • 📊 Infinite scaling: N servers = 0 shared state
  • ⏱️ Ultra-fast: <5ms validation (CPU-bound)
  • 💰 Zero cost: CPU cost negligible
  • 🚨 No SPOF: Cada server valida independientemente

Métricas de Mejora:

Aspecto Sin JWT Con JWT (RS256) Mejora
Latency de validación 20-50ms <5ms -90%
External dependencies Redis/DB None -100%
Cost (auth infra) $50-100/mes $0 -100%
Scaling complexity High Zero N/A
SPOF risk Yes No N/A

💡 Lecciones Aprendidas

1. RS256 > HS256 para Multi-Service

Si tu API se comunica con otros servicios, RS256 es mejor porque solo necesitas compartir la public key (no sensitive).

2. Expiry Time es Crítico

  • Corto (15-60 min): Más seguro, pero requiere refresh frecuente
  • Largo (24 horas+): Más convenient, pero mayor ventana de compromiso
  • Nuestro sweet spot: 60 minutos para API calls, 7 días para batch jobs

3. Claims Mínimos son Mejores

Solo incluir en el payload lo que realmente necesitas validar. No uses JWT como session storage.

4. Public Key en Secret Manager

Aunque la public key no es sensible, almacenarla en Secret Manager facilita rotación sin re-deploy.

5. optional_auth es Poderoso

Permite endpoints que dan más info si estás autenticado, pero no bloquean acceso público.

6. Swagger UI con JWT es UX++

El botón "Authorize" en Swagger UI hace testing de endpoints protegidos trivial.

🔐 Seguridad Best Practices

1. Never Version Private Key

# .gitignore
keys/private_key.pem
*.pem
!public_key.pem  # Public key is safe
Enter fullscreen mode Exit fullscreen mode

2. Secure Key Permissions

chmod 600 keys/private_key.pem  # Owner read/write only
chmod 644 keys/public_key.pem   # Read for everyone (safe)
Enter fullscreen mode Exit fullscreen mode

3. Rotate Keys Periodically

# Generate new key pair
./generate_jwt_keys.sh --force

# Deploy new public key to API
gcloud secrets versions add jwt-public-key --data-file=keys/public_key.pem

# Gradually phase out old tokens (based on expiry)
Enter fullscreen mode Exit fullscreen mode

4. Use Short-Lived Tokens

# For interactive users: 15-60 minutes
token = generator.generate_token(username, expiry_minutes=60)

# For service accounts: 7 days max
token = generator.generate_token(username, expiry_minutes=10080)
Enter fullscreen mode Exit fullscreen mode

5. Validate All Standard Claims

# Always verify: exp, iat, iss, aud
payload = jwt.decode(
    token,
    public_key,
    options={
        "verify_exp": True,
        "verify_iat": True,
        "verify_aud": True,
        "verify_iss": True,
    }
)
Enter fullscreen mode Exit fullscreen mode

🎯 El Propósito Más Grande

JWT con RS256 no es solo autenticación - es libertad arquitectural. Al eliminar la necesidad de estado compartido:

  • 🚀 Scaling: Cada instancia de la API es independiente
  • 🌐 Multi-region: Tokens válidos en cualquier región
  • 🔄 Microservices: Diferentes servicios validan con la misma public key
  • 💰 Cost: Zero infrastructure para auth
  • ⚡ Performance: Validación local ultra-rápida
  • 🔒 Security: Public key leak = no problem

Estamos construyendo una API que escala infinitamente sin necesidad de coordinación entre instancias, sin single points of failure, y sin costo adicional de infraestructura.


🔗 Recursos y Enlaces

Repositorio del Proyecto

Documentación Técnica

Recursos Externos


Próximo Post: LLPY-11 - Terraform: Infraestructura como Código

En el siguiente post exploraremos cómo gestionar toda la infraestructura de GCP con Terraform, desde VMs para Qdrant hasta Cloud Run para la API, con módulos reutilizables y CI/CD automatizado.

Top comments (0)