Agent Wranglers

ACC-80Required

Rotate refresh tokens and catch their reuse

Issue a new refresh token on every refresh and cancel the old one. When a cancelled refresh token comes back, revoke its whole family and make the person sign in again. A client that cannot rotate binds its refresh tokens to a key with DPoP or mutual TLS.

How to do it

RFC 9700 says refresh tokens for public clients, such as browser and mobile apps, must be sender-constrained or rotated. Store each refresh token as a hash, with a family ID, the client, the user and the scopes. Mark the old token used in the same transaction that stores its successor. A used token that comes back means two parties hold it, and the server cannot tell which one is the thief. So revoke every token in the family and write an event to the security log. DPoP, from RFC 9449, binds a token to a key pair the client keeps, and every request carries a signed proof. Expire a family after 30 days without use. End it at sign-out, as let people sign out of one device or all of them says.

Examples

JavaScript example

export async function refresh(db, presentedToken) {
  return db.transaction(async tx => {
    const stored = await tx.refreshTokens.findByHash(sha256(presentedToken));
    if (!stored) throw new InvalidGrant();
    if (stored.usedAt) {
      await tx.refreshTokens.revokeFamily(stored.familyId);
      await tx.securityLog.add(stored.userId, 'refresh_token_reuse');
      throw new InvalidGrant();
    }
    await tx.refreshTokens.markUsed(stored.id);
    return issueTokens(tx, stored.userId, stored.familyId, stored.scopes);
  });
}

Python example

def refresh(db, presented_token: str):
    with db.transaction() as tx:
        stored = tx.refresh_tokens.find_by_hash(sha256(presented_token))
        if stored is None:
            raise InvalidGrant()
        if stored.used_at is not None:
            tx.refresh_tokens.revoke_family(stored.family_id)
            tx.security_log.add(stored.user_id, "refresh_token_reuse")
            raise InvalidGrant()
        tx.refresh_tokens.mark_used(stored.id)
        return issue_tokens(tx, stored.user_id, stored.family_id, stored.scopes)

How to check

A test refreshes once and gets a new token. It then sends the first token again and gets invalid_grant. The second token is now refused too, and the security log holds one reuse event. A DPoP-bound token sent without a proof, or with a proof from another key, is refused.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/accounts/sessions-and-security-activity/refresh-token-rotation">Agent Wranglers accounts and identity playbook</a>. In code you write, add a comment next to it with the same link.