All posts

RedAuth4 min read

Catching a stolen token

How RedAuth notices that a refresh token has been copied, and why it signs the user out of every device when it does.

Most JWT tutorials stop at "sign a token, send it in a header". That works until someone copies the token. A JWT is valid until it expires, and the server can't tell whether the person holding it is the person it was issued to.

RedAuth uses two tokens, both in httpOnly cookies. The access token lives 15 minutes and is checked by signature, so a request doesn't need a database query to know who's calling. The refresh token lives seven days and is the only thing that can get you a new access token. That split is standard. What matters is what happens to the refresh token after it's used.

A refresh token works once

Every refresh token carries a tokenId in its payload, and there's a database record with that id holding a SHA-256 hash of the token, the device it belongs to and its expiry. The id in the payload makes the lookup a primary-key read instead of a hash search.

When a client trades in a refresh token, the server deletes that record and issues a new pair with a new id. The old token still has a valid signature for another week, but its record is gone.

Now say there are two copies: the real one on the user's laptop and a stolen one somewhere else. Whichever is used first wins and gets rotated. When the second arrives, the server sees a token that verifies but has no record. There's no innocent explanation for that, and RedAuth doesn't try to work out which side is the attacker:

if (!record) {
  if (strict) {
    // A valid token with no record: it was already rotated. Someone has a copy.
    const sessions = await prisma.token.findMany({
      where: { userId: decoded.id, type: "REFRESH_TOKEN" },
    });
    await prisma.token.deleteMany({ where: { userId: decoded.id } });
    await Promise.all(sessions.map((s) =>
      redis.set(`revoked-${s.id}`, "true", "EX", 60 * 30)));
  }
  throw new AppError(401, "Session revoked or expired. Please login again.");
}

Every session that user has, on every device, is deleted. The user signs in again everywhere. The attacker's copy is dead, and so is anything they rotated it into.

Why access tokens die too

Deleting refresh records doesn't stop an access token that's already out there. It would stay valid for up to 15 minutes. So revocation also writes revoked-<tokenId> to Redis for each session, and the auth middleware checks that key on every request. The key expires after 30 minutes, which is longer than any access token lives, so it never needs cleaning up. Logout uses the same key, which is how a single-device logout takes effect immediately.

That one Redis read per request is the price of instant revocation. I think it's the right price. A purely stateless access token means "logged out" really means "logged out in up to 15 minutes".

One session per device

Each device gets one refresh record, enforced by a unique index on user, device and token type. Signing in again on the same device replaces its record instead of piling up new ones. The user can list their sessions, with device name and last activity, and end any of them from their account.

The reuse rule has a cost. Two tabs on the same device refreshing at nearly the same moment can look like a replay: the first rotates the token, the second arrives with the old one. RedAuth treats that as theft and signs the user out. I chose that over trying to tell the cases apart, but a client that refreshes from several tabs should share one refresh in flight.

The other parts

  • Rate limits are a sliding window in a Redis sorted set, keyed by IP, so they hold across several server instances: 100 requests a minute overall, 10 per five minutes on auth routes, 3 per five minutes on anything that sends email.
  • Emails, like the alert sent on each new login, go through a BullMQ queue with retries and backoff, so nobody waits on an SMTP server to sign in.
  • Login, forgot-password and resend-verification do the same work whether or not the account exists, so response timing doesn't reveal which emails are registered.
  • Only hashes are stored: bcrypt for passwords, SHA-256 for tokens. A leaked database dump has nothing in it you can replay.

None of this is my invention. Refresh token rotation with reuse detection is in the OAuth security best practice, RFC 9700. Building it myself was the fastest way to understand why each part is there.

If I had to keep one rule from this project for the next auth system I build, it's the first one: a refresh token is good exactly once.