← Proyectos/api-rest-jwt
Live💻 Full Stack

API REST con Autenticación JWT

API Node.js + TypeScript + Express con JWT, RBAC, rate limiting, OpenAPI docs y deploy en EC2 + Docker.

Node.jsTypeScriptExpressJWTPostgreSQLEC2DockerRedis

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 anterior

Implementación

1

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.

2

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.

3

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.

4

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.

5

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).

6

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

Next.js 16 (App Router)React 19, shadcn/ui, Radix UITypeScript 5Tailwind CSSZustand v5TanStack Query v5Axiosreact-hook-form + Zod v3Sonner

Runtime & Framework

Node.js 24.16.0 LTSExpress 5TypeScript 6

Autenticación & Seguridad

jsonwebtoken (RS256)bcrypthelmetcorsexpress-rate-limit

Validación & Documentación

Zodswagger-jsdocswagger-ui-express

Base de datos

PostgreSQL 17node-postgres (pg)pg-pool

Caché & Sessions

Redis 7ioredisrate-limit-redis

Infraestructura

Docker (multi-stage)EC2 + DockerTerraformGitHub Actions

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

TypeScript — JWT Middleware (authenticate + authorize)typescript
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();
  };
}
TypeScript — Validación con Zod + middleware genéricotypescript
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,
);