Menu
CoddyTech

Caducidad de los tokens

Parte de la sección Autenticación y seguridad del Journey de Node.js de Coddy. Lección 17 de 24.

Un servidor no puede revocar un token: no se almacena nada, así que uno robado funciona hasta que caduca. Haz que los tokens tengan una vida corta, de minutos en lugar de días, y permite que el cliente solicite uno nuevo.

jwtVerify ya rechaza un token cuyo exp ha pasado. exp es un momento contado en segundos desde el 1 de enero de 1970. Lanza un errors.JWTExpired. err instanceof errors.JWTExpired es true para ese tipo de error, así que puedes distinguir un token caducado de uno falsificado y comunicarlo: un cliente que lee Token caducado sabe que debe iniciar sesión de nuevo.

import { errors, jwtVerify } from 'jose';

try {
    await jwtVerify(inviteToken, inviteKey);
} catch (err) {
    if (err instanceof errors.JWTExpired) {
        return res.status(410).json({ error: 'This invite has run out' });
    }
    return res.status(400).json({ error: 'Not a real invite' });
}
challenge icon

Desafío

Fácil

Un servidor no puede recuperar un token, así que un token robado sigue funcionando hasta que caduca. Haz que los tokens tengan una duración breve e informa claramente a la aplicación cuando su token haya caducado, para que sepa que debe iniciar sesión de nuevo.

  1. En server.js, en POST /token, haz que los tokens caduquen 15 minutos después de crearse, en lugar de una hora.
  2. En requireUser, examina el error que lanza jwtVerify. Para un token caducado, jose lanza un error errors.JWTExpired. Importa errors desde jose. Cuando el error sea de ese tipo, responde con el estado 401 y {"error": "Token expired"}.
  3. Cualquier otro problema sigue recibiendo el estado 401 y {"error": "Log in first"}: falta el encabezado, el token ha sido modificado o está firmado con otra clave.

Pruébalo: Pulsa «Ejecutar». Envía POST /signup y luego POST /token, ambos con {"username": "ada", "password": "correct horse"}. Envía GET /inspect?token= seguido del nuevo token: exp ahora está 900 segundos después de iat. Ambos cuentan los segundos desde el 1 de enero de 1970. A continuación, aquí tienes un token firmado con el secreto de este servidor, que caducó el 1 de enero de 2026: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE En el campo Headers, establece Authorization en Bearer, seguido de un espacio y ese token; luego envía GET /me: el estado será 401 y la respuesta {"error": "Token expired"}. Cambia una letra de su parte central y vuelve a enviarlo: el estado será 401 y la respuesta {"error": "Log in first"}. Un token modificado no supera la comprobación de firma antes de que se lea su fecha.

Pruébalo tú mismo

import express from 'express';
import { SignJWT, decodeJwt, jwtVerify } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!username || password.length < 8) {
        return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
    }
    if (await users.findOne({ username })) {
        return res.status(409).json({ error: 'That username is taken' });
    }
    await users.insertOne({ username, passwordHash: await hashPassword(password) });
    res.status(201).json({ username });
});

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    res.json({ username: user.username });
});

app.post('/token', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    const token = await new SignJWT({ username: user.username })
        .setProtectedHeader({ alg: 'HS256' })
        .setSubject(user._id.toHexString())
        .setIssuedAt()
        .setExpirationTime('1h')
        .sign(secret);
    res.json({ token });
});

// Comprueba el token en la cabecera Authorization o responde con 401.
async function requireUser(req, res, next) {
    const token = (req.get('authorization') ?? '').replace(/^Bearer /, '');
    let payload;
    try {
        ({ payload } = await jwtVerify(token, secret));
    } catch {
        return res.status(401).json({ error: 'Log in first' });
    }
    req.user = { id: payload.sub, username: payload.username };
    next();
}

app.get('/me', requireUser, (req, res) => {
    res.json(req.user);
});

// Muestra el contenido de un token: GET /inspect?token=...
app.get('/inspect', (req, res) => {
    try {
        res.json(decodeJwt(String(req.query.token ?? '')));
    } catch {
        res.status(400).json({ error: 'Not a JWT' });
    }
});

// La página de administración. No se puede confiar en ninguna declaración antes de verificar su firma.
app.get('/admin', (req, res) => {
    res.status(403).json({ error: 'Admins only' });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

Todas las lecciones de Autenticación y seguridad

Practica por tu cuenta: Playground de Express