Autenticación JWT con Claude Code: claims, cookies, rotación y claves
Implementa JWT con Claude Code: claims, cookies, rotación de refresh tokens, revocación, claves y prompts seguros.
JWT parece una solución de autenticación muy sencilla: firmas un payload, devuelves un token y lo verificas en cada request. Ese demo funciona, pero producción exige más cuidado. Un aud sin validar, un refresh token demasiado largo, un token guardado en localStorage o una rotación de claves mal planificada pueden abrir una ruta real de compromiso de cuentas.
Esta guía explica JWT desde lo básico y luego lo convierte en una especificación práctica para Claude Code. Veremos diseño de claims, firma frente a cifrado, ubicación en Cookie o sesión, refresh-token rotation, revocación, rotación de claves, fallos frecuentes y prompts seguros. Para el flujo completo de login, revisa también implementación de autenticación, gestión de cookies y control de acceso RBAC.
Trabaja con fuentes primarias abiertas: RFC 7519 para JWT, RFC 8725 para prácticas seguras, RFC 9700 para refresh tokens y replay, OWASP JWT Cheat Sheet, MDN Set-Cookie, jose y Claude Code settings.
Conceptos básicos de JWT
Un JWT tiene tres partes separadas por puntos: header.payload.signature. El header indica el tipo y el algoritmo. El payload contiene claims, es decir, afirmaciones sobre el token. La firma permite detectar modificaciones. Claims comunes son sub para el usuario, iss para el emisor, aud para la audiencia, exp para expiración y jti para el identificador del token.
El punto que más se olvida es que un JWT normal está firmado, no cifrado. La firma no oculta el payload. Cualquiera que obtenga el token puede decodificarlo. No pongas API keys, direcciones, datos de pago, notas internas o datos personales innecesarios en el payload. Si algo debe permanecer secreto, guárdalo en el servidor y usa el JWT solo como referencia mínima.
Empieza el trabajo con Claude Code fijando estas reglas.
Implementa autenticación JWT.
Reglas:
- El payload de JWT está firmado, no cifrado. No guardar secretos.
- Access token: máximo 15 minutos.
- Refresh token: máximo 7 días.
- Validar iss, aud, sub, exp, iat y jti.
- Rechazar alg none y algoritmos no esperados.
- Implementar refresh-token rotation y detección de reutilización.
- Revisar Cookie, CSRF, XSS, revocación y rotación de claves.
Diseña claims mínimos
Un JWT no debe ser una copia del perfil de usuario. El access token solo necesita información mínima para que la API pueda iniciar la decisión: usuario estable, session id, tenant id, role amplio y token id. Datos que cambian con frecuencia, como plan, suspensión, permisos finos o límites de uso, deben consultarse en servidor.
| Claim | Uso | Precaución |
|---|---|---|
sub | Usuario estable | Mejor un ID interno que email |
iss | Emisor | Fijarlo al servidor de autenticación |
aud | Audiencia | Rechazar tokens de otra API |
exp | Expiración | Access tokens cortos |
jti | ID del token | Revocación y auditoría |
sid | ID de sesión | Logout por dispositivo y token family |
role | Rol amplio | La autorización fina se revisa en servidor |
El error típico es meter plan: "pro" odisabled: false en el token. Cuando el plan cambia o la cuenta se bloquea, el token puede seguir diciendo lo contrario hasta que expire. Autenticación responde “quién es”. Autorización responde “puede hacer esto ahora”. Mantén separadas esas dos preguntas.
Ejemplo TypeScript ejecutable con jose
Este demo firma y verifica access tokens, guarda solo hashes de refresh tokens, rota el refresh token y detecta reutilización. En producción cambia el Map por Redis o base de datos, añade rate limit, logs de auditoría, HTTPS y protección CSRF.
mkdir jwt-lab
cd jwt-lab
npm init -y
npm install jose
npm install -D tsx typescript @types/node
// auth-demo.ts
import { createHash, createSecretKey, randomUUID } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
const ISSUER = "https://auth.example.com";
const AUDIENCE = "claudecodelab-api";
const ACCESS_TTL = "15m";
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 7;
const accessKey = createSecretKey(
Buffer.from(
process.env.JWT_ACCESS_SECRET ??
"dev-only-secret-change-me-32-bytes-minimum"
)
);
const refreshKey = createSecretKey(
Buffer.from(
process.env.JWT_REFRESH_SECRET ??
"dev-only-refresh-secret-change-me-32-bytes"
)
);
type Role = "admin" | "user" | "viewer";
type User = { id: string; role: Role; tenantId: string };
type VerifiedAccess = {
userId: string;
role: Role;
tenantId: string;
sessionId: string;
tokenId: string;
};
type RefreshRecord = {
userId: string;
sessionId: string;
tokenHash: string;
expiresAt: number;
revokedAt?: number;
};
const refreshStore = new Map<string, RefreshRecord>();
const revokedAccessTokenIds = new Set<string>();
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function assertRole(value: unknown): asserts value is Role {
if (!["admin", "user", "viewer"].includes(String(value))) {
throw new Error("invalid role claim");
}
}
async function signAccessToken(user: User, sessionId: string) {
const tokenId = randomUUID();
return new SignJWT({ role: user.role, tid: user.tenantId, sid: sessionId })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(ACCESS_TTL)
.setJti(tokenId)
.sign(accessKey);
}
async function verifyAccessToken(token: string): Promise<VerifiedAccess> {
const { payload } = await jwtVerify(token, accessKey, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
assertRole(payload.role);
if (
typeof payload.sub !== "string" ||
typeof payload.tid !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.jti !== "string"
) {
throw new Error("missing required claim");
}
if (revokedAccessTokenIds.has(payload.jti)) {
throw new Error("access token revoked");
}
return {
userId: payload.sub,
role: payload.role,
tenantId: payload.tid,
sessionId: payload.sid,
tokenId: payload.jti,
};
}
async function signRefreshToken(user: User, sessionId: string) {
const tokenId = randomUUID();
const token = await new SignJWT({ sid: sessionId, kind: "refresh" })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience("claudecodelab-refresh")
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("7d")
.setJti(tokenId)
.sign(refreshKey);
refreshStore.set(tokenId, {
userId: user.id,
sessionId,
tokenHash: sha256(token),
expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000,
});
return token;
}
async function rotateRefreshToken(refreshToken: string, user: User) {
const { payload } = await jwtVerify(refreshToken, refreshKey, {
issuer: ISSUER,
audience: "claudecodelab-refresh",
algorithms: ["HS256"],
});
if (
typeof payload.jti !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.sub !== "string"
) {
throw new Error("invalid refresh token claims");
}
const record = refreshStore.get(payload.jti);
const presentedHash = sha256(refreshToken);
if (!record || record.revokedAt || record.tokenHash !== presentedHash) {
for (const item of refreshStore.values()) {
if (item.sessionId === payload.sid) item.revokedAt = Date.now();
}
throw new Error("refresh token reuse detected");
}
if (record.expiresAt < Date.now()) {
throw new Error("refresh token expired");
}
record.revokedAt = Date.now();
return {
accessToken: await signAccessToken(user, payload.sid),
refreshToken: await signRefreshToken(user, payload.sid),
};
}
async function main() {
const user: User = {
id: "user_123",
role: "admin",
tenantId: "tenant_a",
};
const sessionId = randomUUID();
const accessToken = await signAccessToken(user, sessionId);
const refreshToken = await signRefreshToken(user, sessionId);
const verified = await verifyAccessToken(accessToken);
const rotated = await rotateRefreshToken(refreshToken, user);
console.log({ verified, rotatedRefreshLength: rotated.refreshToken.length });
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
npx tsx auth-demo.ts
El detalle importante es que el refresh token no se guarda en texto plano. Solo se almacena su hash. Cuando se usa, el registro se revoca y se emite un par nuevo. Si el token viejo vuelve a aparecer, se revoca toda la familia asociada a ese sid.
Cookies, sesión y ubicación del token
En aplicaciones web, el refresh token suele vivir en una Cookie HttpOnly, Secure y SameSite. HttpOnly impide que JavaScript lea directamente el valor, lo que reduce el robo del token si hay XSS. Aun así, las cookies se envían automáticamente, así que los endpoints de refresh, logout y cambios de estado necesitan defensa CSRF.
const refreshCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/api/auth/refresh",
maxAge: 60 * 60 * 24 * 7,
};
const clearRefreshCookieOptions = {
...refreshCookieOptions,
maxAge: 0,
};
El access token puede guardarse en memoria para una SPA, sabiendo que se perderá al recargar. En una arquitectura BFF o con Route Handlers de Next.js, el servidor puede hacer de proxy y evitar exponer el access token al navegador. localStorage es cómodo, pero cualquier XSS puede leer bearer tokens; no lo uses para tokens largos.
Revocación y rotación de claves
Un JWT seguirá siendo válido hasta exp si el servidor no añade controles. Por eso necesitas access tokens cortos, lista de jti revocados para eventos críticos, revocación por sid y refresh token rotation. Logout debe revocar el refresh record. Cambio de contraseña, suspensión de cuenta o sospecha de filtración deben invalidar todas las sesiones de ese usuario.
Para rotación de claves, usa una ventana de solapamiento. HS256 es simple, pero cada verificador necesita el secreto compartido. Cuando hay varios servicios, RS256 o ES256 con JWKS es más manejable: los verificadores solo reciben claves públicas.
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
export async function verifyWithRotatingKeys(token: string) {
return jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "claudecodelab-api",
algorithms: ["RS256", "ES256"],
});
}
{
"rotationPlan": {
"step1": "Crear una clave nueva y publicarla en JWKS",
"step2": "Firmar nuevos tokens con el nuevo kid",
"step3": "Mantener la clave pública vieja hasta que expiren tokens",
"step4": "Revisar logs y retirar la clave vieja"
}
}
Casos de uso y prompt seguro
flowchart LR
Login["Login"] --> Access["Access token corto"]
Login --> Refresh["Refresh cookie HttpOnly"]
Access --> API["API valida iss/aud/exp/jti"]
Refresh --> Rotate["Rotación al refrescar"]
Rotate --> Store["DB/Redis guarda hash y sid"]
Store --> Reuse["Reutilización: revocar familia"]
Caso 1: panel SaaS. Puedes incluir tenantId para contexto, pero las consultas a base de datos también deben filtrar por tenant. Estados como plan, suspensión y permisos administrativos se vuelven a consultar en operaciones destructivas.
Caso 2: sitio de cursos o contenido pago. Mantén access tokens cortos y refresco silencioso para no expulsar al lector en medio de una lección. Si conviven anuncios, Analytics y CTA de compra, conecta esto con headers de seguridad web y consentimiento de cookies.
Caso 3: apps móviles o de escritorio. Usa almacenamiento seguro del sistema operativo en lugar de Cookie del navegador. Conserva sid para revocar un dispositivo perdido y registra la reutilización de refresh token como evento de seguridad.
Caso 4: microservicios. No distribuyas un secreto simétrico a todos los servicios. Considera verificación con clave pública, API gateway o token exchange. Cada servicio debe validar aud.
Diseña e implementa autenticación JWT en este repositorio.
Antes de editar, muestra una tabla con:
- framework, modelo de usuario, código de session/cookie, middleware auth
- checks de autorización, CSRF, CSP y rate limit existentes
- ubicación actual del token y riesgo XSS/CSRF
Reglas:
- Usar jose. No volver a jsonwebtoken.
- Access token de 15 minutos. Refresh token de 7 días.
- Validar iss, aud, sub, exp, iat, jti y sid.
- Guardar solo hash del refresh token en DB/Redis.
- Rotar refresh tokens y revocar la familia sid al detectar reuse.
- No imprimir secrets, .env ni tokens de producción.
- Terminar con evidencia de test o curl.
Fallos, verificación y CTA
Los fallos más comunes son no fijar algoritmos, meter datos sensibles en payload, omitir aud oiss, permitir reutilización de refresh tokens, hacer logout solo en el navegador, retirar claves viejas demasiado pronto y pegar secrets reales en Claude Code. Mitígalos con allowlist de algoritmos, claims mínimos, rotación, revocación por sid, ventana JWKS y prompts sin secretos.
curl -i -X POST https://example.com/api/auth/login \
-H "content-type: application/json" \
-d '{"email":"demo@example.com","password":"correct horse"}'
npm test -- --runInBand auth
Prueba tokens expirados, firmas alteradas, audience incorrecta, jti revocado, refresh token reutilizado, logout, cambio de contraseña y suspensión de cuenta. Confirma que la Cookie de refresh tenga HttpOnly, Secure, SameSite correcto y un path lo más limitado posible.
Si quieres estandarizar este trabajo, empieza con la cheatsheet gratuita de Claude Code para fijar hábitos de verificación. Para prompts y plantillas reutilizables, revisa ClaudeCodeLab products. Para equipos que necesitan JWT, RBAC, cookies, auditoría y CI gates en un repositorio real, usa training y consultoría de Claude Code.
Al probar el ejemplo de este artículo, lo que más redujo retrabajo fue escribir la tabla de claims antes del código de firma. Así aparecieron pronto tres problemas: falta de aud, un ejemplo con top-level await que no corría en la ruta tsx por defecto y el riesgo de guardar refresh tokens en texto plano. JWT seguro no es solo firmar: es diseñar claims, validación, almacenamiento, rotación, revocación y claves como un flujo revisable.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.