Short-lived, rotating tokens

15-minute access tokens; 7-day refresh tokens deleted on use.

From security-best-practices: JWT refresh rotation, OWASP A07.

Scrub the first hour

Drag the clock or press Play.

One 30-day tokenBad
Access tokenA1
If stolen now, works for
refresh_tokens0 rows

Nothing to revoke.

jwt.sign({ userId }, SECRET, { expiresIn: '30d' })
15m access, 7d refreshGood
Access tokenA1
If stolen now, works for
refresh_tokens
tokenexpiresAtstate
expiresIn: '15m'expiresIn: '7d'
// login
const accessToken  = jwt.sign({ userId }, ACCESS_SECRET,  { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });

await db.refreshToken.create({
  userId,
  token: refreshToken,
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});

Replay a stolen refresh token

Refresh as the client, then replay the stolen R1.

Verify, never deleteBad
Client holds
Attacker sessions0
refresh_tokens
      verifysign pair
      Delete on useGood
      Client holds
      Attacker sessions0
      refresh_tokens
          verifydeletesign pairstore
          app.post('/api/auth/refresh', async (req, res) => {
            const { refreshToken } = req.body;
            const payload = jwt.verify(refreshToken, REFRESH_SECRET);
          
            // single use
            const { count } = await db.refreshToken.deleteMany({ where: { token: refreshToken } });
            if (count === 0) return res.status(401).json({ error: 'refresh token already used' });
          
            const accessToken     = jwt.sign({ userId: payload.userId }, ACCESS_SECRET,  { expiresIn: '15m' });
            const newRefreshToken = jwt.sign({ userId: payload.userId }, REFRESH_SECRET, { expiresIn: '7d' });
            await db.refreshToken.create({
              userId: payload.userId,
              token: newRefreshToken,
              expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
            });
          
            res.json({ accessToken, refreshToken: newRefreshToken });
          });

          How long a leak stays open

          Set the lifetime and when the token leaks.

          Attacker windowCompare
          30-day token
          15-minute token

          window = lifetime × (1 − leakedAt)

          Why it matters, pros and cons

          Why it matters

          An access JWT can't be revoked, so its lifetime is the damage window.

          Pros

          • A stolen access token dies within 15 minutes.
          • Delete a user's rows to end every session.
          • A replay is a 401 you can alert on.

          Cons

          • A database write per user every 15 minutes.
          • If the attacker refreshes first, the real user is locked out.
          • Two tabs refreshing at once race; one gets a 401.