Las cookies firmadas son el primer nivel de session management. Para apps chicas funcionan perfecto. Para una SaaS con miles de usuarios activos, llega un momento donde necesitas más: revocación instantánea de sesiones, rate limiting por usuario, throttling por IP, o simplemente datos de sesión que no quieres que viajen en cada request.
Este artículo cubre cómo migré un proyecto Next.js desplegado en Lambda de cookies stateless a sesiones con Redis en ElastiCache, por qué lo hice, y qué problemas raros me encontré en el camino. El objetivo no es convencerte de usar Redis, es mostrarte cuándo tiene sentido y cómo implementarlo bien.
El problema con las cookies firmadas
Las cookies firmadas con JWT o similar son stateless: el servidor no necesita saber nada, solo verifica la firma. Eso escala perfecto. El problema viene cuando necesitas:
- Invalidar una sesión específica. Un usuario reporta que le robaron el teléfono. Quieres cerrar su sesión en todos los dispositivos sin esperar a que expire el JWT.
- Guardar datos de sesión que no deben estar en cookie. Preferencias temporales, progreso de un wizard multi-step, estado de carrito.
- Rate limiting por usuario. Contar requests por segundo de cada user ID.
- Presencia y estado online. Saber quién está conectado ahora mismo.
Con JWT stateless, la opción 1 requiere una "blacklist" externa, que es justamente una base de datos de sesiones. Una vez que necesitas eso, mejor diseñas sesiones stateful desde el principio.
flowchart LR
subgraph "Stateless JWT"
A1[Cliente] -->|Cookie JWT| B1[Lambda Next.js]
B1 -->|Verifica firma| C1[Procede]
B1 -.->|Revocar?| X1[No posible sin extra]
end
subgraph "Stateful Redis"
A2[Cliente] -->|Cookie: sid| B2[Lambda Next.js]
B2 -->|GET session:sid| R[Redis ElastiCache]
R -->|Session data| B2
B2 -->|Procede si existe| C2[Procede]
B2 -.->|DEL session:sid| R
end
Arquitectura en AWS
ElastiCache Redis corre en VPC privada, no es accesible desde internet. Tu Lambda Next.js necesita estar en la misma VPC (o una peered) para conectarse. Eso implica que tu Lambda toma un ENI (elastic network interface), lo que antes causaba cold starts horribles. Desde 2019 con Hyperplane ENIs eso está resuelto, pero aún hay que configurar bien.
flowchart TB
U[Usuario] --> CF[CloudFront]
CF --> LURL[Lambda URL via Amplify]
LURL --> L[Lambda Next.js en VPC]
L --> R[ElastiCache Redis Cluster]
R --> R1[Primary]
R --> R2[Replica 1]
R --> R3[Replica 2]
L --> DDB[DynamoDB - datos persistentes]
ElastiCache Redis en modo cluster con 1 primary y 2 replicas te da alta disponibilidad. Para apps en una sola región, cache.t4g.small alcanza de sobra. Cuesta unos $25/mes por nodo.
CDK para la infra
// cdk/lib/redis-stack.ts
import { Stack, StackProps, CfnOutput } from "aws-cdk-lib";
import { Vpc, SecurityGroup, Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
import {
CfnSubnetGroup,
CfnReplicationGroup,
} from "aws-cdk-lib/aws-elasticache";
import { Construct } from "constructs";
export class RedisStack extends Stack {
public readonly vpc: Vpc;
public readonly redisEndpoint: string;
public readonly redisSecurityGroup: SecurityGroup;
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
this.vpc = new Vpc(this, "AppVpc", {
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{ name: "public", subnetType: SubnetType.PUBLIC, cidrMask: 24 },
{
name: "private",
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
],
});
this.redisSecurityGroup = new SecurityGroup(this, "RedisSG", {
vpc: this.vpc,
description: "Allow Lambda to access Redis",
});
this.redisSecurityGroup.addIngressRule(
Peer.ipv4(this.vpc.vpcCidrBlock),
Port.tcp(6379),
"Redis from VPC"
);
const subnetGroup = new CfnSubnetGroup(this, "RedisSubnetGroup", {
description: "Subnets for Redis",
subnetIds: this.vpc.privateSubnets.map((s) => s.subnetId),
cacheSubnetGroupName: "app-redis-subnets",
});
const redis = new CfnReplicationGroup(this, "RedisCluster", {
replicationGroupDescription: "App session store",
engine: "redis",
engineVersion: "7.1",
cacheNodeType: "cache.t4g.small",
numNodeGroups: 1,
replicasPerNodeGroup: 2,
automaticFailoverEnabled: true,
multiAzEnabled: true,
cacheSubnetGroupName: subnetGroup.cacheSubnetGroupName,
securityGroupIds: [this.redisSecurityGroup.securityGroupId],
atRestEncryptionEnabled: true,
transitEncryptionEnabled: true,
authToken: process.env.REDIS_AUTH_TOKEN!,
});
redis.addDependency(subnetGroup);
this.redisEndpoint = redis.attrPrimaryEndPointAddress;
new CfnOutput(this, "RedisEndpoint", { value: this.redisEndpoint });
}
}
Tres cosas importantes acá. Primera: automaticFailoverEnabled con multiAzEnabled hace failover automático si el primary muere. Segunda: transitEncryptionEnabled fuerza TLS al conectar. Tercera: authToken fuerza AUTH con password. Esto es mínimo indispensable para producción.
Conectando el Lambda al VPC
En Amplify Gen 2, el Lambda que corre Next.js no es VPC por default. Hay que configurarlo:
// amplify/backend.ts
import { defineBackend } from "@aws-amplify/backend";
import { Vpc, SubnetType, SecurityGroup } from "aws-cdk-lib/aws-ec2";
const backend = defineBackend({});
const nextLambda = backend.createStack("NextLambdaStack");
// Referenciamos VPC existente
const vpc = Vpc.fromLookup(nextLambda, "AppVpc", {
vpcName: "RedisStack/AppVpc",
});
const lambdaSg = SecurityGroup.fromSecurityGroupId(
nextLambda,
"LambdaSG",
process.env.LAMBDA_SG_ID!
);
// El Lambda de Next.js lo genera Amplify, aquí lo configuramos vía escape hatch
const nextjsLambda = backend.createStack("amplify-nextjs");
// (Amplify Gen 2 todavía no expone VPC config directa; workaround con CfnLambdaFunction override)
Amplify Gen 2 al día de este artículo no expone config VPC para el Lambda de Next.js fácilmente. Una alternativa es usar CDK puro con el construct de Next.js Lambda de SST o Open Next, que permiten VPC config explícita:
// sst.config.ts
import { SSTConfig } from "sst";
import { NextjsSite, Vpc } from "sst/constructs";
export default {
config() {
return { name: "my-app", region: "us-east-1" };
},
stacks(app) {
app.stack(function Site({ stack }) {
const vpc = Vpc.fromLookup(stack, "ExistingVpc", {
vpcName: "AppVpc",
});
new NextjsSite(stack, "site", {
path: ".",
environment: {
REDIS_URL: process.env.REDIS_URL!,
},
cdk: {
server: {
vpc,
vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [lambdaSg],
},
},
});
});
},
} satisfies SSTConfig;
El cliente Redis para Next.js
Dos cosas importantes en Lambda. Primera, reutilizar la conexión entre invocaciones (Lambda mantiene el módulo cargado). Segunda, manejar timeouts agresivos porque Lambda se muere a los 30s.
// lib/redis.ts
import { Redis } from "ioredis";
let client: Redis | null = null;
export function getRedis(): Redis {
if (client && client.status === "ready") return client;
client = new Redis({
host: process.env.REDIS_HOST!,
port: 6379,
password: process.env.REDIS_AUTH_TOKEN,
tls: {},
connectTimeout: 5000,
commandTimeout: 3000,
maxRetriesPerRequest: 2,
lazyConnect: false,
enableOfflineQueue: false,
});
client.on("error", (err) => {
console.error("Redis error:", err.message);
});
return client;
}
El enableOfflineQueue: false es crítico. Si Redis se cae, quiero fallar rápido, no encolar comandos que expiran con el Lambda.
El middleware de sesión
La idea: cada request con cookie sid va a Redis, valida, inyecta datos de sesión en request context. Si no hay cookie o sesión expiró, redirige a login.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getRedis } from "@/lib/redis";
const PUBLIC_PATHS = ["/login", "/register", "/api/auth/login"];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const sid = req.cookies.get("sid")?.value;
if (!sid) {
return NextResponse.redirect(new URL("/login", req.url));
}
// Nota: middleware NO puede conectar a Redis porque corre en Edge Runtime
// En este caso validamos solo formato, y Redis se checa en Server Component
if (!/^[a-f0-9]{64}$/.test(sid)) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
Importante: middleware de Next.js corre en Edge Runtime, que no tiene acceso a Redis ni al VPC. Por eso la validación real de sesión la hago en Server Components, donde sí corre Node completo con VPC access.
Server action para login
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { randomBytes } from "crypto";
import { compare } from "bcryptjs";
import { getRedis } from "@/lib/redis";
import { db } from "@/lib/db";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 días
export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await db.user.findUnique({ where: { email } });
if (!user) return { error: "Credenciales inválidas" };
const valid = await compare(password, user.passwordHash);
if (!valid) return { error: "Credenciales inválidas" };
const sid = randomBytes(32).toString("hex");
const redis = getRedis();
const sessionData = {
userId: user.id,
email: user.email,
orgId: user.orgId,
role: user.role,
createdAt: Date.now(),
userAgent: "", // se setea al primer request
};
await redis.setex(
`session:${sid}`,
SESSION_TTL_SECONDS,
JSON.stringify(sessionData)
);
// También mantenemos un set de sesiones por usuario para revocación masiva
await redis.sadd(`user:${user.id}:sessions`, sid);
await redis.expire(`user:${user.id}:sessions`, SESSION_TTL_SECONDS);
(await cookies()).set("sid", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: SESSION_TTL_SECONDS,
path: "/",
});
redirect("/dashboard");
}
Lo notable: mantengo dos estructuras en Redis. session:{sid} con los datos, y user:{userId}:sessions con el set de todos los SIDs del usuario. Eso me permite "cerrar todas las sesiones de este usuario" iterando el set.
Lectura de sesión en Server Components
// lib/session.ts
import { cookies } from "next/headers";
import { cache } from "react";
import { getRedis } from "./redis";
export const getSession = cache(async () => {
const sid = (await cookies()).get("sid")?.value;
if (!sid) return null;
const redis = getRedis();
const data = await redis.get(`session:${sid}`);
if (!data) return null;
const parsed = JSON.parse(data);
// Sliding expiration: cada uso renueva TTL
await redis.expire(`session:${sid}`, 60 * 60 * 24 * 7);
return parsed;
});
cache() de React deduplica dentro del render. Si 10 Server Components llaman getSession(), solo una query a Redis sale por render.
Revocación de sesiones
Una acción del admin: cerrar todas las sesiones de un usuario.
// app/admin/actions.ts
"use server";
import { getRedis } from "@/lib/redis";
import { requireAdmin } from "@/lib/auth";
export async function revokeAllSessions(userId: string) {
await requireAdmin();
const redis = getRedis();
const sids = await redis.smembers(`user:${userId}:sessions`);
if (sids.length === 0) return { revoked: 0 };
const pipeline = redis.pipeline();
for (const sid of sids) {
pipeline.del(`session:${sid}`);
}
pipeline.del(`user:${userId}:sessions`);
await pipeline.exec();
return { revoked: sids.length };
}
Pipeline para hacer las N deletes en una sola round-trip a Redis. Para un usuario con 15 dispositivos conectados, esto es 20ms en vez de 300ms.
Rate limiting bonus
Con Redis ya disponible, implementar rate limiting es trivial:
// lib/rate-limit.ts
import { getRedis } from "./redis";
export async function checkRateLimit(
key: string,
limit: number,
windowSec: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const redis = getRedis();
const now = Date.now();
const windowKey = `ratelimit:${key}:${Math.floor(now / (windowSec * 1000))}`;
const pipeline = redis.pipeline();
pipeline.incr(windowKey);
pipeline.expire(windowKey, windowSec);
const results = await pipeline.exec();
const count = Number(results?.[0]?.[1] || 0);
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: (Math.floor(now / (windowSec * 1000)) + 1) * windowSec * 1000,
};
}
Uso en un Server Action:
const rate = await checkRateLimit(`login:${ip}`, 10, 60);
if (!rate.allowed) {
return { error: "Demasiados intentos. Intenta en un minuto." };
}
10 intentos por minuto por IP. Simple y efectivo.
Lo que aprendí en producción
1. El VPC + Lambda tiene costos ocultos.
Cuando configuras Lambda con VPC, pagas por ENI y por NAT Gateway si el Lambda necesita internet. Si tu Next.js solo necesita Redis y no internet, quita NAT Gateway. Si necesita ambos, separa Lambdas: uno VPC para Redis, otro sin VPC para external APIs.
2. ioredis con TLS a veces cuelga en cold start.
Tuve un bug donde el TLS handshake tardaba 8s en cold start esporádicamente. Resulta que tls: {} hace TLS con validación default, y en ElastiCache a veces el cert chain toma tiempo. Solución: preconectar con await redis.ping() en init del módulo.
3. El costo de Redis en AWS es más alto de lo que la docs sugieren.
Un cluster con replicación en 3 AZs empieza en $70/mes para el nodo más chico. Para apps pequeñas no vale la pena. Consideré Upstash Redis serverless para proyectos con tráfico bajo: paga por request, no por hora, y no requiere VPC.
4. La invalidación con patterns es peligrosa.
Traté de hacer redis.keys("session:*") para migración. KEYS bloquea Redis entero en producción. Usa SCAN con cursor, siempre. Mejor aún, mantén índices como el user:*:sessions que usé arriba.
5. Sliding expiration puede hacer que una sesión nunca expire.
Si el usuario deja una pestaña abierta que hace polling cada 30 segundos, la sesión renueva TTL infinitamente. Agregué un absoluteExpiry en los datos de sesión: aunque el TTL se renueve, si absoluteExpiry < now, invalido la sesión. Máximo 30 días desde el login original.
6. Redis down no debe tumbar la app.
Inicialmente, cualquier error de Redis devolvía 500. Ahora tengo fallback: si Redis no responde en 1s, leo la sesión de un backup simple en DynamoDB (synceado asincrónicamente desde Redis). La UX se degrada a "no puedo cerrar tu sesión remotamente ahora", pero la app sigue funcionando.
Cuándo NO agregar Redis
Si tu app tiene menos de 1000 usuarios activos, cookies firmadas con JWT alcanzan. La complejidad de VPC + ElastiCache + failover no se paga.
Si ya tienes DynamoDB y tu patrón de acceso a sesiones es "get por ID", DynamoDB con TTL hace el mismo trabajo. DynamoDB es más caro por request pero infinitamente más simple operacionalmente.
Si tu equipo no tiene experiencia con Redis, el troubleshooting en producción es duro. Los problemas típicos (memoria llena, eviction inesperada, slowlog) requieren debugging específico.
Si tu región AWS no tiene ElastiCache con alta disponibilidad (algunas edge regions no la tienen), considera Upstash o MemoryDB.
En el próximo artículo vamos a observabilidad real de frontend. CloudWatch RUM correlacionado con X-Ray, cómo hacer que un bug reportado por usuario te lleve directo al trace del Lambda correspondiente.
Top comments (0)