API REST con Autenticación JWT
API Node.js + TypeScript + Express con JWT, RBAC, rate limiting, OpenAPI docs y deploy en EC2 + Docker.
Arquitecturas AWS utilizadas
Problema & Solución
Problema
Construir una API REST production-ready con autenticación stateless (JWT), control de acceso basado en roles (RBAC), protección contra abuso (rate limiting), validación exhaustiva de inputs, y documentación automática. El deploy debe ser reproducible con Docker y la infraestructura gestionada con Terraform.
Solución
API construida con Express + TypeScript usando una arquitectura en capas (routes → middleware → controllers → services → repositories). JWT para autenticación stateless con refresh token rotation. RBAC con roles definidos en base de datos. Rate limiting por IP y por usuario con Redis. Validación con Zod en cada endpoint. Documentación OpenAPI generada automáticamente. Containerizada con Docker multi-stage y desplegada en EC2 + Docker.
Diagrama de Arquitectura
Cliente (Next.js / Postman)
│ HTTPS
▼
┌──────────────────────────────────────────────────────┐
│ Express App (TypeScript) │
│ │
│ Request Pipeline: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. Helmet (security headers) │ │
│ │ 2. CORS (allow-list de orígenes) │ │
│ │ 3. Rate Limiter (100 req/15min por IP, Redis) │ │
│ │ 4. Body Parser (JSON, max 1MB) │ │
│ │ 5. Morgan (request logging) │ │
│ │ 6. authenticate() middleware (JWT verify) │ │
│ │ 7. authorize() middleware (RBAC check) │ │
│ │ 8. validate() middleware (Zod schema) │ │
│ │ 9. Route Handler (Controller) │ │
│ │ 10. Error Handler global │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────┘
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌────────────────┐
│ PostgreSQL │ │ Redis │ │ Secrets Mgr │
│ (usuarios, │ │ (rate limit │ │ (JWT secret, │
│ roles, │ │ + sessions)│ │ DB password) │
│ recursos) │ └─────────────┘ └────────────────┘
└─────────────┘
Auth Flow (JWT + Refresh Token):
POST /auth/login ──▶ valida credenciales ──▶ genera:
• accessToken (JWT, 15 min, firmado con RS256)
• refreshToken (opaque, 7 días, almacenado en Redis)
POST /auth/refresh ──▶ valida refreshToken en Redis ──▶ rota:
• nuevo accessToken (15 min)
• nuevo refreshToken (7 días) + invalida el anteriorImplementación
Arquitectura en capas (Layered Architecture)
La aplicación se divide en: Routes (define endpoints y aplica middleware), Controllers (maneja el request/response, delega lógica), Services (lógica de negocio pura, testeable), Repositories (acceso a datos, abstrae la DB). Esta separación permite testear cada capa de forma independiente con mocks.
Autenticación JWT con RS256 y Refresh Token Rotation
Se usa RS256 (asimétrico) en lugar de HS256: la clave privada firma los tokens (solo el servidor), la clave pública verifica (puede distribuirse a otros servicios). Access token: 15 minutos, contiene userId y roles. Refresh token: opaque UUID aleatorio, 7 días, almacenado en Redis con el userId asociado. Refresh token rotation: cada vez que se usa, se invalida y genera uno nuevo.
Control de acceso basado en roles (RBAC)
Los roles (admin, user, viewer) se almacenan en PostgreSQL y se incluyen en el JWT payload al login. El middleware authorize('admin', 'user') verifica que el rol del token esté en la lista permitida. Para permisos más granulares (ej: solo el dueño del recurso puede editar), se hace una verificación adicional en el Service layer comparando req.user.id con el ownerId del recurso.
Validación con Zod en cada endpoint
Cada endpoint define un schema Zod para body, params y query. El middleware validate(schema) ejecuta schema.parse() y retorna 400 con los errores de validación si falla. Zod garantiza type safety en runtime, complementando TypeScript que solo opera en compile time.
Rate Limiting con Redis
express-rate-limit con Redis store (rate-limit-redis). Límites: 100 requests/15min por IP (global), 5 intentos de login/15min por IP (anti-brute-force), 1000 requests/hora por usuario autenticado. Redis persiste los contadores entre restarts del servidor y entre múltiples instancias (crítico en ECS con múltiples tasks).
Documentación OpenAPI con Swagger UI
swagger-jsdoc genera la especificación OpenAPI 3.0 desde JSDoc comments en las routes. swagger-ui-express sirve el UI interactivo en /api/docs. Cada endpoint documenta: descripción, parámetros, request body schema, response schemas (200, 400, 401, 403, 404, 500), y ejemplos.
Tech Stack
Frontend
Runtime & Framework
Autenticación & Seguridad
Validación & Documentación
Base de datos
Caché & Sessions
Infraestructura
Decisiones Técnicas
Express vs Fastify vs NestJS
Elegido
Express + TypeScript
Alternativas
- —Fastify — mayor throughput, decoradores nativos, pero ecosistema más pequeño
- —NestJS — estructura empresarial, inyección de dependencias, más complejo
Razón
Express es el estándar de facto con el ecosistema más amplio. Fastify tiene mejor performance (~2x) pero el overhead de Express es irrelevante en la mayoría de casos reales. NestJS añade demasiado opinionamiento y curva de aprendizaje para una API REST estándar.
JWT RS256 vs HS256
Elegido
RS256 (RSA)
Alternativas
- —HS256 — más simple, suficiente para monolitos o cuando solo hay un servicio
- —PASETO — más moderno, evita errores comunes de JWT, menos soporte de librerías
Razón
RS256 usa par de claves asimétricas: la clave privada firma (solo el auth service), la clave pública verifica (cualquier microservicio). Permite que otros servicios validen tokens sin acceder a la clave privada. HS256 requiere compartir el secreto con todos los servicios que validan tokens, lo que aumenta la superficie de ataque.
Snippets de Código
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { getPublicKey } from "../config/jwt";
import { AppError } from "../utils/AppError";
import type { JwtPayload, AuthUser } from "../types";
export function authenticate(
req: Request,
_res: Response,
next: NextFunction,
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
throw AppError.unauthorized("Missing or invalid Authorization header");
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, getPublicKey(), {
algorithms: ["RS256"],
}) as JwtPayload;
req.user = { id: payload.sub, roles: payload.roles } satisfies AuthUser;
next();
} catch {
throw AppError.unauthorized("Invalid or expired token");
}
}
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/AppError";
import type { UserRole } from "../types";
export function authorize(...roles: UserRole[]) {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.user) throw AppError.unauthorized();
const hasRole = req.user.roles.some((r) => roles.includes(r));
if (!hasRole) throw AppError.forbidden();
next();
};
}import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
interface RequestSchemas {
body?: ZodSchema;
params?: ZodSchema;
query?: ZodSchema;
}
export function validate(schemas: RequestSchemas) {
return (req: Request, _res: Response, next: NextFunction): void => {
if (schemas.body) req.body = schemas.body.parse(req.body);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
if (schemas.params) req.params = schemas.params.parse(req.params) as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
if (schemas.query) req.query = schemas.query.parse(req.query) as any;
next();
};
}
// --- Uso en routes ---
import { Router, IRouter } from "express";
import {
createUser,
getProfile,
listUsers,
updateUser,
deleteUser,
} from "../controllers/users.controller";
import { authenticate } from "../middleware/authenticate";
import { authorize } from "../middleware/authorize";
import { validate } from "../middleware/validate";
import {
adminUpdateUserSchema,
createUserByAdminSchema,
userIdParamSchema,
} from "../models/users.schemas";
export const router: IRouter = Router();
router.use(authenticate);
/**
* @openapi
* /api/users/me:
* get:
* tags: [Users]
* summary: Get current user profile
* responses:
* 200:
* description: User profile
* 401:
* description: Unauthorized
*/
router.get("/me", getProfile);
/**
* @openapi
* /api/users:
* get:
* tags: [Users]
* summary: List all users (admin only)
* responses:
* 200:
* description: List of users
* 403:
* description: Forbidden
*/
router.get("/", authorize("admin"), listUsers);
/**
* @openapi
* /api/users:
* post:
* tags: [Users]
* summary: Create a new user (admin only)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, email, password]
* properties:
* name: { type: string }
* email: { type: string, format: email }
* password: { type: string }
* role: { type: string, enum: [admin, user, viewer] }
* responses:
* 201:
* description: User created
* 409:
* description: Email already in use
*/
router.post(
"/",
authorize("admin"),
validate({ body: createUserByAdminSchema }),
createUser,
);
/**
* @openapi
* /api/users/{id}:
* patch:
* tags: [Users]
* summary: Update user (admin or own profile)
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: string, format: uuid }
* responses:
* 200:
* description: User updated
* 403:
* description: Forbidden
* 404:
* description: User not found
*/
router.patch(
"/:id",
validate({ params: userIdParamSchema, body: adminUpdateUserSchema }),
updateUser,
);
/**
* @openapi
* /api/users/{id}:
* delete:
* tags: [Users]
* summary: Delete user (admin only)
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: string, format: uuid }
* responses:
* 204:
* description: User deleted
* 403:
* description: Forbidden
*/
router.delete(
"/:id",
authorize("admin"),
validate({ params: userIdParamSchema }),
deleteUser,
);