Part of the Refresh Token Design series.

The Problem With Single Use

Single use refresh tokens are the standard advice and the advice is right. A refresh token that works twice is a refresh token somebody can steal and use behind you.

The trouble is the failure mode when a response goes missing.

A client sends its refresh token. The server rotates, writes the new pair, and answers. The answer never arrives, because the tunnel dropped, the phone changed cell, the laptop lid closed. The client still holds the old token, and the old token is now spent. It retries, gets refused, and the person is looking at a login screen because of one lost packet.

You can retry, but the client has nothing new to retry with. You can widen the window, which means a spent token works for a while, which is the property you just removed. Neither is satisfying because the real problem is that the server threw away the only thing that could answer a repeat question.

Keep the Answer

The fix is to stop discarding the pair being replaced. A session row holds at most two pairs, current and previous, and a repeat question gets the same answer it got the first time.

CREATE TABLE sessions (
    id                          uuid PRIMARY KEY,
    user_id                     uuid        NOT NULL,
    token_scope                 varchar(32) NOT NULL,

    primary_token               text,
    primary_token_hash          varchar(64),
    refresh_token               text,
    refresh_token_hash          varchar(64),

    previous_primary_token      text,
    previous_primary_token_hash varchar(64),
    previous_refresh_token      text,
    previous_refresh_token_hash varchar(64),

    refresh_token_exp           timestamptz NOT NULL,
    created_at                  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_sessions_primary_hash  ON sessions (primary_token_hash);
CREATE INDEX idx_sessions_refresh_hash  ON sessions (refresh_token_hash);

Note that the tokens themselves are stored, not only their hashes. That is the part people flinch at, and it is forced by the design: to hand back the identical pair you must still have it. A single use scheme can get away with storing hashes alone because it never needs to produce a token again.

So the token columns are encrypted at rest with AES-256-GCM through a column converter, and the hashes stay in the clear because that is what lookups run against. A database dump then contains hashes that identify nothing and ciphertext that needs a key held elsewhere.

What Each Match Means

Everything follows from which of the four hashes the presented token matches. The rotation window is open when previous_refresh_token_hash is populated.

PresentedMatchesWhat happens
Refreshprevious_refresh_token_hashReplay. Nothing is minted, the stored current pair is returned
Refreshrefresh_token_hash, window openReplay
Refreshrefresh_token_hash, window shutRotate. The only path that mints
RefreshNeither401
Accessprimary_token_hashServe, and close the window if it is open
Accessprevious_primary_token_hashServe, change nothing
AccessNeither401

The first row is the one that fixes the lost response. The client retries with the token it still holds and receives the identical pair it should have received the first time, from any instance, with the cache down, for as long as that token lives.

The Rule That Bounds Minting

The second row is the one people get wrong, and it is the load bearing rule of the whole scheme.

Presenting the current refresh token does not mint while the window is open. It replays as well.

Without that, anybody holding a refresh token could ask for a fresh pair on every call, forever. Each response would push the refresh expiry out another thirty days, and there would be no point at which the session settled. Rotation would generate tokens rather than replace them.

So a new pair is unlocked by one thing only: the client acknowledging the last one. The acknowledgement is a real request carrying the current access token, and it is unforgeable in the useful sense, because only somebody who received that response has the token to send.

UPDATE sessions SET
    previous_primary_token       = NULL,
    previous_primary_token_hash  = NULL,
    previous_refresh_token       = NULL,
    previous_refresh_token_hash  = NULL
WHERE id = :id
  AND primary_token_hash = :presented
  AND previous_refresh_token_hash IS NOT NULL;

The last condition matters more than it looks. Without it this statement fires on every authenticated request and turns a read path into a write path.

The One Exception

There is a case where the window is open and the current refresh token should still rotate: the stored access token has already expired.

That token can no longer close the loop. Refusing to mint would strand a client whose network came back after five minutes with nothing left to acknowledge, and no way out except a fresh login, which is the failure this design exists to remove.

Once it has expired the objection disappears, because the reason for holding the line was that an unacknowledged pair was still usable. It is not any more.

What the exception costs an attacker is the wait. Each new pair now requires a full access token lifetime to pass first. A real user was idle through it anyway. Somebody probing sits through five minutes per attempt.

A null access token counts as expired, since that is a forced refresh and rotating is the entire intent.

Worked Example

A user logs in holding P1 and R1. A minute later they start probing.

  1. They send R1. It matches the current refresh hash and the window is shut, so it rotates. P2 and R2 are minted and returned. Current is P2 and R2, previous is P1 and R1.
  2. They send R1 again, four more times. Each matches the previous refresh hash, so nothing is minted and the stored P2 and R2 come back every time.
  3. They try R2 instead, reasoning that the current refresh token should rotate. It does not. The window is open and P2 is alive, so this replays too. While two pairs exist, no amount of poking produces a third.
  4. They probe with P1. It matches the previous primary hash, so the request is served. They now hold P1 and P2 and it looks like a hole.
  5. They use P2. That closes the window, and all four previous columns are cleared in one statement.
  6. R1 now matches nothing, despite having 29 days left on its own expiry. P1 returns 401. Only P2 and R2 work.

They are back where they started, and the only thing that moved the session forward was using the current access token.

Two Nodes, One Token

Two instances can receive the same current refresh token at the same instant. Both read the row, both mint, and without a guard the second write silently overwrites the first. The client that received the losing pair then holds tokens that exist nowhere in the row: a 401 on the access token and a failed refresh on top of it, which is a hard logout arriving out of nowhere.

The guard is that the update is conditional on what it is replacing.

UPDATE sessions SET
    primary_token               = :newPrimary,
    primary_token_hash          = :newPrimaryHash,
    refresh_token               = :newRefresh,
    refresh_token_hash          = :newRefreshHash,
    previous_primary_token      = primary_token,
    previous_primary_token_hash = primary_token_hash,
    previous_refresh_token      = refresh_token,
    previous_refresh_token_hash = refresh_token_hash,
    refresh_token_exp           = :newExp
WHERE id = :id
  AND refresh_token_hash = :presented;

One instance wins. The loser’s update affects zero rows, so it re-reads, finds that the token it presented is now the previous one, and takes the replay path. Both callers end up holding the same tokens, which is the correct answer rather than a lucky one.

The same guard belongs on the close-the-loop statement above, since two tabs holding different access tokens can present them at the same moment.

Pair Id, Not Chained Hashes

Both tokens of a pair carry the same random pair id claim. They do not carry each other’s hashes.

Chaining creates an ordering problem: whichever token is minted first cannot contain the hash of one that does not exist yet. Generating the pair id first and stamping it into both removes the ordering entirely, and matching an access token to its refresh token becomes a comparison of one claim rather than a hash computation.

What actually binds a pair on the server is the row. The two hash columns are written by the same statement, so they are a pair by construction, and the claim is a convenience rather than the authority.

What This Does Not Solve

Two pairs mean a stolen refresh token is not detectable by presenting it twice. That is deliberate, and it is the subject of the fourth post in this series, along with the part of the trade that was worse than it was described as being at the time.


Next: Three Scope Tiers for a Half Authenticated Session