Mejores Prácticas de Seguridad en APIs 2026

Mejores Prácticas de Seguridad en APIs 2026

Guía completa de seguridad para APIs REST y GraphQL. Autenticación, autorización, rate limiting.

16 de marzo de 202613 min de lectura

Introducción: La Seguridad de APIs Es Crítica

En 2026, las APIs son la columna vertebral de prácticamente toda aplicación moderna. Desde aplicaciones móviles hasta microservicios, desde integraciones de terceros hasta sistemas de IoT, las APIs manejan datos sensibles y operaciones críticas de negocio. Sin embargo, también representan uno de los vectores de ataque más explotados: según el informe OWASP de 2025, las vulnerabilidades en APIs fueron responsables del 60% de las brechas de seguridad en aplicaciones web.

Esta guía completa cubre las mejores prácticas de seguridad para APIs REST y GraphQL que todo desarrollador y arquitecto de software debería implementar en 2026. Desde la autenticación robusta hasta la defensa en profundidad, cada sección incluye ejemplos de código prácticos y configuraciones que puedes aplicar directamente en tus proyectos.

OWASP API Security Top 10 - 2026

El OWASP API Security Top 10 es la referencia estándar para entender los riesgos más críticos en APIs. Estas son las vulnerabilidades principales:

  1. Broken Object Level Authorization (BOLA): Acceso no autorizado a objetos de otros usuarios
  2. Broken Authentication: Fallos en mecanismos de autenticación
  3. Broken Object Property Level Authorization: Exposición excesiva de datos
  4. Unrestricted Resource Consumption: Sin límites en consumo de recursos
  5. Broken Function Level Authorization: Acceso a funciones no autorizadas
  6. Unrestricted Access to Sensitive Business Flows: Abuso de flujos de negocio
  7. Server Side Request Forgery (SSRF): Peticiones desde el servidor a destinos arbitrarios
  8. Security Misconfiguration: Configuraciones inseguras por defecto
  9. Improper Inventory Management: APIs no documentadas o expuestas
  10. Unsafe Consumption of APIs: Confianza excesiva en APIs de terceros

Autenticación Robusta

JWT (JSON Web Tokens) con Mejores Prácticas

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

interface TokenPayload {
  userId: string;
  role: string;
  permissions: string[];
}

// Configuración segura de JWT
const JWT_CONFIG = {
  accessTokenSecret: process.env.JWT_ACCESS_SECRET!,
  refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
  accessTokenExpiry: '15m',      // Tokens de acceso de corta duración
  refreshTokenExpiry: '7d',      // Refresh tokens de mayor duración
  issuer: 'api.tudominio.com',
  audience: 'tudominio.com',
  algorithm: 'RS256' as const,   // Usar RS256 en lugar de HS256
};

// Generar access token
function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, JWT_CONFIG.accessTokenSecret, {
    expiresIn: JWT_CONFIG.accessTokenExpiry,
    issuer: JWT_CONFIG.issuer,
    audience: JWT_CONFIG.audience,
    algorithm: JWT_CONFIG.algorithm,
    jwtid: crypto.randomUUID(),  // ID único para cada token
  });
}

// Generar refresh token con rotación
function generateRefreshToken(userId: string): string {
  const tokenId = crypto.randomUUID();

  // Almacenar en base de datos para poder revocar
  storeRefreshToken(userId, tokenId);

  return jwt.sign(
    { userId, tokenId },
    JWT_CONFIG.refreshTokenSecret,
    {
      expiresIn: JWT_CONFIG.refreshTokenExpiry,
      issuer: JWT_CONFIG.issuer,
    }
  );
}

// Verificar token con validaciones completas
function verifyAccessToken(token: string): TokenPayload {
  try {
    const decoded = jwt.verify(token, JWT_CONFIG.accessTokenSecret, {
      issuer: JWT_CONFIG.issuer,
      audience: JWT_CONFIG.audience,
      algorithms: [JWT_CONFIG.algorithm],
    }) as TokenPayload;

    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new AuthError('Token expirado', 401);
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new AuthError('Token inválido', 401);
    }
    throw new AuthError('Error de autenticación', 500);
  }
}

Refresh Token Rotation

La rotación de refresh tokens previene el uso de tokens robados:

async function refreshAccessToken(refreshToken: string) {
  // 1. Verificar el refresh token
  const decoded = jwt.verify(refreshToken, JWT_CONFIG.refreshTokenSecret);

  // 2. Verificar que el token existe en la base de datos
  const storedToken = await db.refreshTokens.findUnique({
    where: { tokenId: decoded.tokenId },
  });

  if (!storedToken) {
    // Token no encontrado - posible reutilización de token robado
    // Revocar TODOS los tokens del usuario por seguridad
    await db.refreshTokens.deleteMany({
      where: { userId: decoded.userId },
    });
    throw new AuthError('Token revocado. Se requiere re-autenticación.', 401);
  }

  // 3. Eliminar el refresh token usado (rotación)
  await db.refreshTokens.delete({
    where: { tokenId: decoded.tokenId },
  });

  // 4. Generar nuevos tokens
  const user = await db.users.findUnique({ where: { id: decoded.userId } });
  const newAccessToken = generateAccessToken({
    userId: user.id,
    role: user.role,
    permissions: user.permissions,
  });
  const newRefreshToken = generateRefreshToken(user.id);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

OAuth 2.0 y OpenID Connect

// Configuración de OAuth 2.0 con PKCE (Proof Key for Code Exchange)
import { OAuth2Client } from 'oauth2-client';

const oauth2Config = {
  clientId: process.env.OAUTH_CLIENT_ID!,
  authorizationEndpoint: 'https://auth.ejemplo.com/authorize',
  tokenEndpoint: 'https://auth.ejemplo.com/token',
  redirectUri: 'https://tuapp.com/callback',
  scopes: ['openid', 'profile', 'email'],
};

// Generar PKCE challenge
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { verifier, challenge };
}

// Iniciar flujo de autenticación
function getAuthorizationUrl(): { url: string; state: string; verifier: string } {
  const state = crypto.randomBytes(16).toString('hex');
  const { verifier, challenge } = generatePKCE();

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: oauth2Config.clientId,
    redirect_uri: oauth2Config.redirectUri,
    scope: oauth2Config.scopes.join(' '),
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  return {
    url: `${oauth2Config.authorizationEndpoint}?${params}`,
    state,
    verifier,
  };
}

Autorización: Más Allá de la Autenticación

Role-Based Access Control (RBAC)

// Sistema de permisos basado en roles
const ROLES = {
  admin: ['users:read', 'users:write', 'users:delete', 'products:*', 'orders:*', 'analytics:read'],
  editor: ['products:read', 'products:write', 'orders:read', 'analytics:read'],
  viewer: ['products:read', 'orders:read'],
  customer: ['products:read', 'orders:read', 'orders:write:own'],
} as const;

type Role = keyof typeof ROLES;

// Middleware de autorización
function authorize(...requiredPermissions: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = req.user; // Obtenido del middleware de autenticación

    if (!user) {
      return res.status(401).json({ error: 'No autenticado' });
    }

    const userPermissions = ROLES[user.role as Role] || [];

    const hasPermission = requiredPermissions.every((required) => {
      return userPermissions.some((permission) => {
        // Soportar wildcards: 'products:*' coincide con 'products:read'
        if (permission.endsWith(':*')) {
          return required.startsWith(permission.replace(':*', ':'));
        }

        // Soportar permisos propios: 'orders:write:own'
        if (permission.endsWith(':own')) {
          const basePermission = permission.replace(':own', '');
          return required === basePermission && isOwnResource(req, user.id);
        }

        return permission === required;
      });
    });

    if (!hasPermission) {
      return res.status(403).json({
        error: 'Permisos insuficientes',
        required: requiredPermissions,
      });
    }

    next();
  };
}

// Uso en rutas
app.get('/api/users', authorize('users:read'), listUsers);
app.delete('/api/users/:id', authorize('users:delete'), deleteUser);
app.post('/api/orders', authorize('orders:write'), createOrder);

Object-Level Authorization (Prevenir BOLA)

// Middleware para verificar propiedad del recurso
async function verifyResourceOwnership(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const resourceId = req.params.id;
  const userId = req.user.id;
  const userRole = req.user.role;

  // Los administradores pueden acceder a todo
  if (userRole === 'admin') {
    return next();
  }

  // Verificar que el recurso pertenece al usuario
  const order = await db.orders.findUnique({
    where: { id: resourceId },
    select: { userId: true },
  });

  if (!order) {
    return res.status(404).json({ error: 'Recurso no encontrado' });
  }

  if (order.userId !== userId) {
    // Log del intento de acceso no autorizado
    logger.warn('BOLA attempt', {
      userId,
      resourceId,
      resourceType: 'order',
      ip: req.ip,
    });
    return res.status(403).json({ error: 'Acceso denegado' });
  }

  next();
}

Rate Limiting y Protección contra Abuso

Implementación de Rate Limiting

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

// Rate limiter general
const generalLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000,   // 15 minutos
  max: 100,                     // 100 peticiones por ventana
  standardHeaders: true,        // Incluir headers RateLimit-*
  legacyHeaders: false,
  message: {
    error: 'Demasiadas peticiones. Intenta de nuevo más tarde.',
    retryAfter: 900,
  },
  keyGenerator: (req) => {
    // Usar ID de usuario si está autenticado, IP si no
    return req.user?.id || req.ip;
  },
});

// Rate limiter estricto para autenticación
const authLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args: string[]) => redis.call(...args),
  }),
  windowMs: 15 * 60 * 1000,
  max: 5,                      // Solo 5 intentos de login por 15 minutos
  skipSuccessfulRequests: true, // No contar intentos exitosos
  message: {
    error: 'Demasiados intentos de autenticación.',
  },
});

// Rate limiter por endpoint costoso
const searchLimiter = rateLimit({
  windowMs: 60 * 1000,         // 1 minuto
  max: 30,                      // 30 búsquedas por minuto
});

// Aplicar limiters
app.use('/api/', generalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/search', searchLimiter);

Throttling y Circuit Breaker

// Circuit Breaker para llamadas a servicios externos
class CircuitBreaker {
  private failures = 0;
  private lastFailure = 0;
  private state: 'closed' | 'open' | 'half-open' = 'closed';

  constructor(
    private readonly threshold: number = 5,
    private readonly timeout: number = 60000,
  ) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > this.timeout) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuito abierto: servicio no disponible');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  private onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.threshold) {
      this.state = 'open';
    }
  }
}

const paymentCircuit = new CircuitBreaker(3, 30000);

// Uso
async function processPayment(order: Order) {
  return paymentCircuit.execute(() =>
    paymentGateway.charge(order.total, order.paymentMethod)
  );
}

Validación de Entrada

Validación Exhaustiva con Zod

import { z } from 'zod';

// Esquemas de validación estrictos
const CreateUserSchema = z.object({
  name: z
    .string()
    .min(2, 'Nombre muy corto')
    .max(100, 'Nombre muy largo')
    .regex(/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/, 'Caracteres no válidos en el nombre'),
  email: z
    .string()
    .email('Email no válido')
    .max(254, 'Email muy largo')
    .toLowerCase()
    .trim(),
  password: z
    .string()
    .min(12, 'La contraseña debe tener al menos 12 caracteres')
    .regex(/[A-Z]/, 'Debe incluir al menos una mayúscula')
    .regex(/[a-z]/, 'Debe incluir al menos una minúscula')
    .regex(/[0-9]/, 'Debe incluir al menos un número')
    .regex(/[^A-Za-z0-9]/, 'Debe incluir al menos un carácter especial'),
  role: z.enum(['user', 'editor']).default('user'),
});

// Middleware de validación genérico
function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(400).json({
        error: 'Datos de entrada no válidos',
        details: result.error.issues.map((issue) => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }

    req.body = result.data; // Usar datos validados y sanitizados
    next();
  };
}

// Prevención de inyección SQL con parámetros preparados
async function getUserByEmail(email: string) {
  // ❌ NUNCA concatenar strings en queries
  // const result = await db.query(`SELECT * FROM users WHERE email = '${email}'`);

  // ✅ Siempre usar parámetros preparados
  const result = await db.query(
    'SELECT id, name, email, role FROM users WHERE email = $1',
    [email]
  );
  return result.rows[0];
}

Prevención de Mass Assignment

// ❌ Malo: Pasar todo el body directamente
app.put('/api/users/:id', async (req, res) => {
  await db.users.update({
    where: { id: req.params.id },
    data: req.body, // Un atacante podría enviar { role: 'admin' }
  });
});

// ✅ Bueno: Usar allowlist explícita
const UpdateProfileSchema = z.object({
  name: z.string().min(2).max(100).optional(),
  email: z.string().email().optional(),
  avatar: z.string().url().optional(),
  // No incluir: role, permissions, isAdmin, etc.
});

app.put('/api/users/:id', validateBody(UpdateProfileSchema), async (req, res) => {
  await db.users.update({
    where: { id: req.params.id },
    data: req.body, // Solo contiene campos permitidos por el schema
  });
});

Seguridad en APIs GraphQL

Protecciones Específicas de GraphQL

import { createYoga, createSchema } from 'graphql-yoga';
import { useDepthLimiting } from '@envelop/depth-limiting';
import { useCostAnalysis } from '@graphql-cost-analysis/envelop';

const yoga = createYoga({
  schema: createSchema({ /* ... */ }),
  plugins: [
    // Limitar profundidad de queries
    useDepthLimiting({
      maxDepth: 10,
    }),

    // Análisis de costo de queries
    useCostAnalysis({
      maximumCost: 1000,
      defaultCost: 1,
      costMap: {
        Query: {
          users: { complexity: 10 },
          searchProducts: { complexity: 50 },
        },
        User: {
          orders: { complexity: 5 },
          followers: { complexity: 20 },
        },
      },
    }),
  ],
});

// Deshabilitar introspección en producción
const schema = createSchema({
  typeDefs: /* ... */,
  resolvers: /* ... */,
});

if (process.env.NODE_ENV === 'production') {
  // Deshabilitar introspección
  schema.getQueryType()?.getFields().__schema = undefined;
}

Cabeceras de Seguridad HTTP

import helmet from 'helmet';

// Configuración completa de cabeceras de seguridad
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://api.tudominio.com'],
      fontSrc: ["'self'", 'https://fonts.googleapis.com'],
      objectSrc: ["'none'"],
      frameSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
    },
  },
  strictTransportSecurity: {
    maxAge: 63072000,
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: { policy: 'same-origin' },
  crossOriginResourcePolicy: { policy: 'same-origin' },
}));

// Configuración de CORS restrictiva
app.use(cors({
  origin: ['https://tudominio.com', 'https://app.tudominio.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}));

Logging y Monitorización de Seguridad

import winston from 'winston';

const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'security.log' }),
  ],
});

// Middleware de logging de seguridad
function securityAuditLog(req: Request, res: Response, next: NextFunction) {
  const startTime = Date.now();

  res.on('finish', () => {
    const logEntry = {
      timestamp: new Date().toISOString(),
      method: req.method,
      path: req.path,
      statusCode: res.statusCode,
      responseTime: Date.now() - startTime,
      userId: req.user?.id || 'anonymous',
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    };

    // Log eventos sospechosos
    if (res.statusCode === 401 || res.statusCode === 403) {
      securityLogger.warn('Access denied', logEntry);
    }

    if (res.statusCode >= 500) {
      securityLogger.error('Server error', logEntry);
    }

    // Detectar patrones de ataque
    if (req.path.includes('../') || req.path.includes('..\\')) {
      securityLogger.error('Path traversal attempt', logEntry);
    }
  });

  next();
}

Herramientas Complementarias

La seguridad de APIs requiere herramientas adecuadas para depuración y pruebas. Usa nuestro formateador de JSON para analizar respuestas de API y detectar datos sensibles que no deberían estar expuestos. La herramienta de codificación Base64 es perfecta para decodificar y analizar tokens JWT y datos codificados. Y el generador de hashes te permite verificar la integridad de datos y entender los diferentes algoritmos de hashing.

Para generar contraseñas seguras para tus servicios y APIs, usa nuestro generador de contraseñas.

Lista de Verificación de Seguridad para APIs

Antes de desplegar cualquier API a producción, verifica que hayas implementado estas medidas:

Autenticación

  • Tokens JWT con expiración corta (15 minutos o menos)
  • Refresh token rotation implementada
  • Passwords hasheados con bcrypt/argon2 (nunca MD5/SHA1)
  • Autenticación multifactor (MFA) disponible

Autorización

  • RBAC o ABAC implementado correctamente
  • Verificación de propiedad de recursos (prevención de BOLA)
  • Principio de mínimo privilegio aplicado

Validación

  • Validación de entrada en todos los endpoints
  • Parámetros preparados para queries de base de datos
  • Protección contra mass assignment

Infraestructura

  • HTTPS obligatorio con HSTS
  • Rate limiting configurado por endpoint
  • Cabeceras de seguridad HTTP configuradas
  • CORS restrictivo configurado
  • Logging de seguridad activo

Monitorización

  • Alertas para patrones de acceso sospechosos
  • Dashboards de métricas de seguridad
  • Plan de respuesta a incidentes documentado

Conclusión

La seguridad de APIs no es una característica que se añade al final del desarrollo; es un principio fundamental que debe guiar cada decisión de diseño y implementación desde el primer día. Las técnicas presentadas en esta guía, desde autenticación robusta con JWT y OAuth 2.0 hasta rate limiting, validación exhaustiva y monitorización continua, forman un sistema de defensa en profundidad que protege tu aplicación contra las amenazas más comunes y sofisticadas.

Recuerda que la seguridad es un proceso continuo: las amenazas evolucionan constantemente, y tus defensas deben evolucionar con ellas. Realiza auditorías de seguridad periódicas, mantén tus dependencias actualizadas, y fomenta una cultura de seguridad en tu equipo de desarrollo. La inversión en seguridad siempre es menor que el costo de una brecha de datos.

Publicaciones relacionadas