Seguridad de APIs: Patrones de Autenticación y Autorización
Guía práctica para proteger APIs con autenticación por tokens, control de acceso por roles, permisos por scopes y gestión de claves, y sus fallos comunes.

La seguridad de las APIs es donde ocurren la mayoría de las brechas. La autenticación rota aparece constantemente en el OWASP Top 10. La diferencia entre una API segura y una vulnerable no está en elegir el framework correcto — está en entender los patrones, implementarlos correctamente y saber dónde están los errores comunes.
La autenticación responde "¿quién eres?". La autorización responde "¿qué puedes hacer?". Son preocupaciones separadas que deben implementarse por separado. Mezclarlas crea sistemas frágiles donde añadir un nuevo rol significa cambiar la lógica de autenticación.
Autenticación Basada en Tokens
Los JWT son el estándar para la autenticación de APIs sin estado. El servidor emite un token firmado, el cliente lo envía con cada petición y el servidor verifica la firma sin tocar una base de datos.
import jwt from 'jsonwebtoken';
import { z } from 'zod';
interface TokenPayload {
sub: string; // User ID
roles: string[]; // User roles
scopes: string[]; // Granted permissions
iat: number; // Issued at
exp: number; // Expiration
}
function generateTokens(
user: User,
secret: string,
refreshSecret: string
) {
const payload: Omit<TokenPayload, 'iat' | 'exp'> = {
sub: user.id,
roles: user.roles,
scopes: user.scopes,
};
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m', // Short-lived access token
algorithm: 'HS256',
});
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d', algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
function verifyAccessToken(
token: string,
secret: string
): TokenPayload {
return jwt.verify(token, secret, {
algorithms: ['HS256'], // Explicitly allow only expected algorithms
}) as TokenPayload;
}// ❌ Common JWT mistakes
const badToken = jwt.sign(payload, secret, {
expiresIn: '30d', // Access tokens should be short-lived (15min)
});
// No algorithm restriction on verify — vulnerable to algorithm confusion
const decoded = jwt.verify(token, secret); // Missing algorithms option
// ✅ Secure JWT practices
const goodToken = jwt.sign(payload, secret, {
expiresIn: '15m', // Short-lived access token
algorithm: 'HS256', // Explicit algorithm
});
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // Only accept expected algorithm
maxAge: '15m', // Reject expired tokens
});Middleware de Autenticación
El middleware de autenticación extrae el token, lo verifica y adjunta el contexto del usuario a la petición. Esto se ejecuta antes de cualquier manejador de ruta.
import type { Request, Response, NextFunction } from 'express';
interface AuthenticatedRequest extends Request {
user: TokenPayload;
}
function authenticate(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token, secret);
(req as AuthenticatedRequest).user = payload;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
}
// Usage
app.use('/api', authenticate(config.jwtSecret));Control de Acceso Basado en Roles
RBAC mapea usuarios a roles y roles a permisos. Es el patrón de autorización más común porque es simple de entender e implementar.
// Define roles and their permissions
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [
'users:read', 'users:write', 'users:delete',
'orders:read', 'orders:write', 'orders:delete',
'reports:read', 'settings:write',
],
manager: [
'users:read',
'orders:read', 'orders:write',
'reports:read',
],
viewer: [
'orders:read',
'reports:read',
],
};
function authorize(...requiredPermissions: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as AuthenticatedRequest).user;
// Collect all permissions from the user's roles
const userPermissions = new Set(
user.roles.flatMap((role) => ROLE_PERMISSIONS[role] ?? [])
);
// Check if user has ALL required permissions
const hasPermission = requiredPermissions.every((perm) =>
userPermissions.has(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions,
});
}
next();
};
}
// Usage — route-level authorization
app.get('/api/orders', authorize('orders:read'), getOrders);
app.post('/api/orders', authorize('orders:write'), createOrder);
app.delete('/api/orders/:id', authorize('orders:delete'), deleteOrder);
app.get('/api/reports', authorize('reports:read'), getReports);// ❌ Authorization in route handlers — scattered and error-prone
app.delete('/api/orders/:id', (req, res) => {
if (req.user.role !== 'admin') { // Hardcoded role check
return res.status(403).json({ error: 'Forbidden' });
}
// Every route has its own auth logic
// Easy to forget, easy to get wrong
});
// ✅ Authorization as middleware — centralized and consistent
app.delete(
'/api/orders/:id',
authorize('orders:delete'), // Declarative, reusable
deleteOrder
);
// Permission checks are separate from business logic
// Adding a new role doesn't require modifying route handlersAutorización a Nivel de Recurso
RBAC maneja "¿puede este usuario acceder a los pedidos?" pero no "¿puede este usuario acceder a este pedido específico?". La autorización a nivel de recurso verifica la propiedad y las relaciones.
async function authorizeResource(
userId: string,
resourceType: string,
resourceId: string,
action: string,
db: Database
): Promise<boolean> {
switch (resourceType) {
case 'order': {
const order = await db.query(
'SELECT customer_id FROM orders WHERE id = $1',
[resourceId]
);
if (order.rows.length === 0) return false;
// Owner can read and update their own orders
if (order.rows[0].customer_id === userId) {
return ['read', 'update'].includes(action);
}
return false;
}
case 'document': {
const access = await db.query(
`SELECT permission FROM document_access
WHERE document_id = $1 AND user_id = $2`,
[resourceId, userId]
);
if (access.rows.length === 0) return false;
const permission = access.rows[0].permission;
if (action === 'read') return true;
if (action === 'write') return permission === 'editor';
return false;
}
default:
return false;
}
}
// Middleware for resource-level auth
function authorizeOwnership(resourceType: string, action: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = (req as AuthenticatedRequest).user;
const resourceId = req.params.id;
const allowed = await authorizeResource(
user.sub,
resourceType,
resourceId,
action,
req.app.locals.db
);
if (!allowed) {
return res.status(403).json({ error: 'Access denied' });
}
next();
};
}
app.get(
'/api/orders/:id',
authorize('orders:read'), // Role check
authorizeOwnership('order', 'read'), // Ownership check
getOrder
);Gestión de Claves de API
Para la comunicación máquina a máquina, las claves de API son más simples que los tokens OAuth. Pero requieren una gestión cuidadosa — rotación, scoping y limitación de tasa.
import crypto from 'crypto';
// Generate a secure API key
function generateApiKey(): { key: string; hash: string } {
// Generate a cryptographically secure random key
const key = `sk_live_${crypto.randomBytes(32).toString('hex')}`;
// Store only the hash — never store the raw key
const hash = crypto
.createHash('sha256')
.update(key)
.digest('hex');
return { key, hash };
}
// Verify an API key
async function verifyApiKey(
key: string,
db: Database
): Promise<ApiKeyRecord | null> {
const hash = crypto
.createHash('sha256')
.update(key)
.digest('hex');
const result = await db.query(
`SELECT id, owner_id, scopes, expires_at, is_active
FROM api_keys
WHERE key_hash = $1 AND is_active = TRUE`,
[hash]
);
if (result.rows.length === 0) return null;
const record = result.rows[0];
// Check expiration
if (record.expires_at && new Date(record.expires_at) < new Date()) {
return null;
}
// Update last used timestamp
await db.query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
[record.id]
);
return record;
}# ❌ API key anti-patterns
# Storing raw API keys in the database
# Using the same key for all environments
# No expiration date on keys
# No way to revoke a compromised key
# ✅ API key best practices
# Store only SHA-256 hash of the key
# Prefix keys by environment (sk_live_, sk_test_)
# Set expiration dates and rotation schedules
# Track last_used_at for auditing stale keys
# Scope keys to specific permissionsRotación de Refresh Tokens
Los tokens de acceso expiran rápido por seguridad. Los refresh tokens permiten a los clientes obtener nuevos tokens de acceso sin volver a autenticarse. Rota los refresh tokens en cada uso para detectar robos.
async function refreshAccessToken(
refreshToken: string,
db: Database,
config: AuthConfig
): Promise<{ accessToken: string; refreshToken: string }> {
// Verify the refresh token
const payload = jwt.verify(refreshToken, config.refreshSecret, {
algorithms: ['HS256'],
}) as { sub: string; type: string; jti: string };
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Check if this refresh token has been used before (reuse detection)
const tokenRecord = await db.query(
'SELECT id, used FROM refresh_tokens WHERE jti = $1',
[payload.jti]
);
if (tokenRecord.rows.length === 0) {
throw new Error('Refresh token not found');
}
if (tokenRecord.rows[0].used) {
// Token reuse detected — possible theft
// Invalidate ALL refresh tokens for this user
await db.query(
'DELETE FROM refresh_tokens WHERE user_id = $1',
[payload.sub]
);
throw new Error('Refresh token reuse detected — all sessions revoked');
}
// Mark the current refresh token as used
await db.query(
'UPDATE refresh_tokens SET used = TRUE WHERE jti = $1',
[payload.jti]
);
// Issue new token pair
const user = await db.query(
'SELECT id, roles, scopes FROM users WHERE id = $1',
[payload.sub]
);
return generateTokens(user.rows[0], config.jwtSecret, config.refreshSecret);
}Puntos Clave
- Mantén los tokens de acceso de corta duración (15 minutos) — usa refresh tokens para la longevidad; los tokens de corta duración limitan la ventana de compromiso
- Separa la autenticación de la autorización — autentica una vez en el middleware, luego verifica permisos por ruta con middleware declarativo
- Almacena hashes de las claves de API, no las claves en crudo — si la base de datos es comprometida, los atacantes obtienen hashes inútiles en lugar de claves funcionales
- Implementa autorización a nivel de recurso — RBAC por sí solo no es suficiente; verifica que los usuarios puedan acceder a los recursos específicos que están solicitando
- Rota los refresh tokens en cada uso — si un refresh token robado es usado, el siguiente intento de refresh del usuario legítimo revela el robo
- Restringe los algoritmos de verificación de JWT — pasa siempre un array explícito de
algorithmspara prevenir ataques de confusión de algoritmos


