Menu
CoddyTech

Token Expiry

Part of the Auth and Security section of Coddy's Node.js journey. Lesson 17 of 24.

A server cannot take a token back: nothing is stored, so a stolen one works until it expires. Keep tokens short-lived, minutes rather than days, and let the client ask for a new one.

jwtVerify already refuses a token whose exp has passed. exp is a moment counted in seconds since 1 January 1970. It throws an errors.JWTExpired. err instanceof errors.JWTExpired is true for that kind of error, so you can tell an expired token from a forged one and say so: a client that reads Token expired knows to log in again.

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

Challenge

Easy

A server cannot take a token back, so a stolen token keeps working until it expires. Make tokens short lived, and tell an app clearly when its token has expired, so it knows to log in again.

  1. In server.js, in POST /token, make tokens expire 15 minutes after they are made, instead of one hour.
  2. In requireUser, look at the error that jwtVerify throws. For an expired token, jose throws an errors.JWTExpired. Import errors from jose. When the error is that kind, respond with status 401 and {"error": "Token expired"}.
  3. Every other problem still gets status 401 and {"error": "Log in first"}: no header, a changed token, or a token signed with another key.

Try it: Press Run. Send POST /signup, then POST /token, both with {"username": "ada", "password": "correct horse"}. Send GET /inspect?token= followed by the new token: exp is now 900 seconds after iat. Both count seconds since 1 January 1970. Next, here is a token this server's secret signed, which expired on 1 January 2026: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE In the Headers field, set Authorization to Bearer, a space, and that token, then send GET /me: status 401 and {"error": "Token expired"}. Change one letter in its middle part and send it again: status 401 and {"error": "Log in first"}. A changed token fails the signature check before its date is read.

Try it yourself

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 });
});

// Checks the token in the Authorization header, or answers 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);
});

// Shows what a token says: 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' });
    }
});

// The admin page. No claim can be trusted before its signature is checked.
app.get('/admin', (req, res) => {
    res.status(403).json({ error: 'Admins only' });
});

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

All lessons in Auth and Security

Practice on your own: Express playground